Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -30,7 +30,7 @@ class TestWrapCommand:
|
||||
wrapped = env._wrap_command("echo hello", "/tmp")
|
||||
|
||||
assert "source" in wrapped
|
||||
assert "cd /tmp" in wrapped or "cd '/tmp'" in wrapped
|
||||
assert "cd -- /tmp" in wrapped or "cd -- '/tmp'" in wrapped
|
||||
assert "eval 'echo hello'" in wrapped
|
||||
assert "__hermes_ec=$?" in wrapped
|
||||
assert "export -p >" in wrapped
|
||||
@@ -57,24 +57,31 @@ class TestWrapCommand:
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("ls", "~")
|
||||
|
||||
assert "cd ~" in wrapped
|
||||
assert "cd '~'" not in wrapped
|
||||
assert "cd -- ~" in wrapped
|
||||
assert "cd -- '~'" not in wrapped
|
||||
|
||||
def test_tilde_subpath_with_spaces_uses_home_and_quotes_suffix(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("ls", "~/my repo")
|
||||
|
||||
assert "cd $HOME/'my repo'" in wrapped
|
||||
assert "cd ~/my repo" not in wrapped
|
||||
assert "cd -- $HOME/'my repo'" in wrapped
|
||||
assert "cd -- ~/my repo" not in wrapped
|
||||
|
||||
def test_tilde_slash_maps_to_home(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("ls", "~/")
|
||||
|
||||
assert "cd $HOME" in wrapped
|
||||
assert "cd ~/" not in wrapped
|
||||
assert "cd -- $HOME" in wrapped
|
||||
assert "cd -- ~/" not in wrapped
|
||||
|
||||
def test_hyphen_prefixed_workdir_is_passed_after_double_dash(self):
|
||||
env = _TestableEnv()
|
||||
env._snapshot_ready = True
|
||||
wrapped = env._wrap_command("pwd", "-demo")
|
||||
|
||||
assert "builtin cd -- -demo || exit 126" in wrapped
|
||||
|
||||
def test_cd_failure_exit_126(self):
|
||||
env = _TestableEnv()
|
||||
|
||||
@@ -209,6 +209,13 @@ class TestFindAgentBrowser:
|
||||
|
||||
|
||||
class TestBrowserRequirements:
|
||||
def test_cdp_override_does_not_require_agent_browser_cli(self, monkeypatch):
|
||||
monkeypatch.setenv("BROWSER_CDP_URL", "ws://127.0.0.1:9222/devtools/browser/test")
|
||||
monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: (_ for _ in ()).throw(FileNotFoundError("not found")))
|
||||
|
||||
assert check_browser_requirements() is True
|
||||
|
||||
def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
|
||||
@@ -821,7 +821,9 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
self.assertEqual(creds["api_key"], "local-key")
|
||||
self.assertEqual(creds["api_mode"], "chat_completions")
|
||||
|
||||
def test_direct_endpoint_falls_back_to_openai_api_key_env(self):
|
||||
def test_direct_endpoint_returns_none_api_key_when_not_configured(self):
|
||||
# When base_url is set without api_key, api_key should be None so
|
||||
# _build_child_agent inherits the parent's key (effective_api_key = override or parent).
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "qwen2.5-coder",
|
||||
@@ -829,10 +831,11 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
}
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}, clear=False):
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["api_key"], "env-openai-key")
|
||||
self.assertIsNone(creds["api_key"])
|
||||
self.assertEqual(creds["provider"], "custom")
|
||||
|
||||
def test_direct_endpoint_does_not_fall_back_to_openrouter_api_key_env(self):
|
||||
def test_direct_endpoint_no_raise_when_only_provider_env_key_present(self):
|
||||
# Even if OPENAI_API_KEY is absent, no ValueError — _build_child_agent uses parent key.
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "qwen2.5-coder",
|
||||
@@ -846,9 +849,9 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
_resolve_delegation_credentials(cfg, parent)
|
||||
self.assertIn("OPENAI_API_KEY", str(ctx.exception))
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertIsNone(creds["api_key"])
|
||||
self.assertEqual(creds["provider"], "custom")
|
||||
|
||||
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
|
||||
def test_nous_provider_resolves_nous_credentials(self, mock_resolve):
|
||||
@@ -977,6 +980,48 @@ class TestDelegationProviderIntegration(unittest.TestCase):
|
||||
self.assertNotEqual(kwargs["base_url"], parent.base_url)
|
||||
self.assertNotEqual(kwargs["api_key"], parent.api_key)
|
||||
|
||||
@patch("tools.delegate_tool._load_config")
|
||||
@patch("tools.delegate_tool._resolve_delegation_credentials")
|
||||
def test_provider_override_clears_parent_openrouter_filters(
|
||||
self, mock_creds, mock_cfg
|
||||
):
|
||||
"""Delegated provider should not inherit parent provider-preference filters."""
|
||||
mock_cfg.return_value = {
|
||||
"max_iterations": 45,
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
"provider": "openrouter",
|
||||
}
|
||||
mock_creds.return_value = {
|
||||
"model": "google/gemini-3-flash-preview",
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "sk-or-key",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
parent = _make_mock_parent(depth=0)
|
||||
parent.providers_allowed = ["anthropic/claude-3.5-sonnet"]
|
||||
parent.providers_ignored = ["openai/gpt-4o-mini"]
|
||||
parent.providers_order = ["google/gemini-2.5-pro"]
|
||||
parent.provider_sort = "price"
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done",
|
||||
"completed": True,
|
||||
"api_calls": 1,
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
delegate_task(goal="Cross-provider test", parent_agent=parent)
|
||||
|
||||
_, kwargs = MockAgent.call_args
|
||||
self.assertEqual(kwargs["provider"], "openrouter")
|
||||
self.assertIsNone(kwargs["providers_allowed"])
|
||||
self.assertIsNone(kwargs["providers_ignored"])
|
||||
self.assertIsNone(kwargs["providers_order"])
|
||||
self.assertIsNone(kwargs["provider_sort"])
|
||||
|
||||
@patch("tools.delegate_tool._load_config")
|
||||
@patch("tools.delegate_tool._resolve_delegation_credentials")
|
||||
def test_direct_endpoint_credentials_reach_child_agent(self, mock_creds, mock_cfg):
|
||||
@@ -2403,5 +2448,52 @@ class TestSubagentApprovalCallback(unittest.TestCase):
|
||||
self.assertIsNone(_get_approval_callback())
|
||||
|
||||
|
||||
class TestFallbackModelInheritance(unittest.TestCase):
|
||||
"""Subagents must inherit the parent's fallback provider chain."""
|
||||
|
||||
def test_child_inherits_fallback_chain(self):
|
||||
"""_build_child_agent passes parent._fallback_chain as fallback_model."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
fallback_entry = {"provider": "openrouter", "model": "gpt-4o-mini", "api_key": "sk-or-x"}
|
||||
parent._fallback_chain = [fallback_entry]
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
MockAgent.return_value = MagicMock()
|
||||
_build_child_agent(
|
||||
task_index=0,
|
||||
goal="test fallback inheritance",
|
||||
context=None,
|
||||
toolsets=None,
|
||||
model=None,
|
||||
max_iterations=10,
|
||||
parent_agent=parent,
|
||||
task_count=1,
|
||||
)
|
||||
|
||||
_, kwargs = MockAgent.call_args
|
||||
self.assertEqual(kwargs["fallback_model"], [fallback_entry])
|
||||
|
||||
def test_child_gets_no_fallback_when_parent_chain_empty(self):
|
||||
"""When parent._fallback_chain is empty, fallback_model is None."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
parent._fallback_chain = []
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
MockAgent.return_value = MagicMock()
|
||||
_build_child_agent(
|
||||
task_index=0,
|
||||
goal="test no fallback",
|
||||
context=None,
|
||||
toolsets=None,
|
||||
model=None,
|
||||
max_iterations=10,
|
||||
parent_agent=parent,
|
||||
task_count=1,
|
||||
)
|
||||
|
||||
_, kwargs = MockAgent.call_args
|
||||
self.assertIsNone(kwargs["fallback_model"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -271,6 +271,58 @@ class TestShellFileOpsHelpers:
|
||||
ops = ShellFileOperations(env)
|
||||
assert ops.cwd == "/"
|
||||
|
||||
def test_read_file_strips_leaked_terminal_fence_markers(self, mock_env):
|
||||
leaked = (
|
||||
"'\x07__HERMES_FENCE_a9f7b3__\x1b]0;cat "
|
||||
"'/tmp/test/a.py' 2> /dev/null\x07\n"
|
||||
"print('ok')\n"
|
||||
"__HERMES_FENCE_a9f7b3__\x07'\n"
|
||||
)
|
||||
|
||||
def side_effect(command, **kwargs):
|
||||
if command.startswith("wc -c"):
|
||||
return {"output": "12\n", "returncode": 0}
|
||||
if command.startswith("head -c"):
|
||||
return {"output": "print('ok')\n", "returncode": 0}
|
||||
if command.startswith("sed -n"):
|
||||
return {"output": leaked, "returncode": 0}
|
||||
if command.startswith("wc -l"):
|
||||
return {"output": "1\n", "returncode": 0}
|
||||
return {"output": "", "returncode": 0}
|
||||
|
||||
mock_env.execute.side_effect = side_effect
|
||||
ops = ShellFileOperations(mock_env)
|
||||
result = ops.read_file("/tmp/test/a.py")
|
||||
|
||||
assert result.error is None
|
||||
assert "HERMES_FENCE" not in result.content
|
||||
assert "\x1b]" not in result.content
|
||||
assert "\x07" not in result.content
|
||||
assert " 1|print('ok')" in result.content
|
||||
|
||||
def test_read_file_raw_strips_leaked_terminal_fence_markers(self, mock_env):
|
||||
leaked = (
|
||||
"__HERMES_FENCE_a9f7b3__\x07'\n"
|
||||
"alpha\n"
|
||||
"\x1b]0;cat '/tmp/test/a.txt'\x07__HERMES_FENCE_a9f7b3__\n"
|
||||
)
|
||||
|
||||
def side_effect(command, **kwargs):
|
||||
if command.startswith("wc -c"):
|
||||
return {"output": "6\n", "returncode": 0}
|
||||
if command.startswith("head -c"):
|
||||
return {"output": "alpha\n", "returncode": 0}
|
||||
if command.startswith("cat "):
|
||||
return {"output": leaked, "returncode": 0}
|
||||
return {"output": "", "returncode": 0}
|
||||
|
||||
mock_env.execute.side_effect = side_effect
|
||||
ops = ShellFileOperations(mock_env)
|
||||
result = ops.read_file_raw("/tmp/test/a.txt")
|
||||
|
||||
assert result.error is None
|
||||
assert result.content == "alpha\n"
|
||||
|
||||
|
||||
class TestSearchPathValidation:
|
||||
"""Test that search() returns an error for non-existent paths."""
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for delegate heartbeat stale threshold configuration."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHeartbeatStaleThresholds:
|
||||
"""Verify the heartbeat stale threshold constants are correct."""
|
||||
|
||||
def test_idle_cycles_value(self):
|
||||
"""IDLE stale cycles should be 15 (15 * 30s = 450s)."""
|
||||
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IDLE
|
||||
assert _HEARTBEAT_STALE_CYCLES_IDLE == 15
|
||||
|
||||
def test_in_tool_cycles_value(self):
|
||||
"""IN_TOOL stale cycles should be 40 (40 * 30s = 1200s)."""
|
||||
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IN_TOOL
|
||||
assert _HEARTBEAT_STALE_CYCLES_IN_TOOL == 40
|
||||
|
||||
def test_idle_timeout_seconds(self):
|
||||
"""Effective idle stale timeout: 15 * 30 = 450s (> typical LLM response time)."""
|
||||
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IDLE, _HEARTBEAT_INTERVAL
|
||||
effective = _HEARTBEAT_STALE_CYCLES_IDLE * _HEARTBEAT_INTERVAL
|
||||
assert effective == 450
|
||||
assert effective > 300 # Must be > 5 minutes for slow LLM responses
|
||||
|
||||
def test_in_tool_timeout_seconds(self):
|
||||
"""Effective in-tool stale timeout: 40 * 30 = 1200s (= 20 minutes)."""
|
||||
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IN_TOOL, _HEARTBEAT_INTERVAL
|
||||
effective = _HEARTBEAT_STALE_CYCLES_IN_TOOL * _HEARTBEAT_INTERVAL
|
||||
assert effective == 1200
|
||||
|
||||
def test_interval_unchanged(self):
|
||||
"""Heartbeat interval should remain 30s."""
|
||||
from tools.delegate_tool import _HEARTBEAT_INTERVAL
|
||||
assert _HEARTBEAT_INTERVAL == 30
|
||||
@@ -467,8 +467,8 @@ def test_kanban_guidance_in_worker_prompt(monkeypatch, tmp_path):
|
||||
skip_memory=True,
|
||||
)
|
||||
prompt = a._build_system_prompt()
|
||||
# Header phrase
|
||||
assert "You are a Kanban worker" in prompt
|
||||
# Header phrase (identity-free — SOUL.md owns identity, layer 3 is protocol)
|
||||
assert "Kanban task execution protocol" in prompt
|
||||
# Lifecycle signals
|
||||
assert "kanban_show()" in prompt
|
||||
assert "kanban_complete" in prompt
|
||||
@@ -492,3 +492,121 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):
|
||||
assert 1_500 < len(KANBAN_GUIDANCE) < 4_096, (
|
||||
f"KANBAN_GUIDANCE is {len(KANBAN_GUIDANCE)} chars — too short (missing?) or too long"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker task-ownership enforcement (regression tests for #19534)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A worker process has HERMES_KANBAN_TASK set to its own task id. The
|
||||
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat)
|
||||
# must refuse to operate on any OTHER task id, even if the caller
|
||||
# supplies an explicit `task_id` argument. Workers legitimately call
|
||||
# kanban_show / kanban_comment / kanban_create / kanban_link on other
|
||||
# tasks, so those are unrestricted.
|
||||
#
|
||||
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
|
||||
# exempt — their job is routing, and they sometimes close out child
|
||||
# tasks on behalf of the child.
|
||||
|
||||
|
||||
def test_worker_complete_rejects_foreign_task_id(worker_env):
|
||||
"""A worker cannot complete a task that isn't its own (#19534)."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="sibling")
|
||||
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (other,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_complete({"task_id": other, "summary": "HIJACK"})
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is not True
|
||||
assert "refusing to mutate" in d.get("error", "")
|
||||
|
||||
# Sibling task must be untouched.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, other).status == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_block_rejects_foreign_task_id(worker_env):
|
||||
"""A worker cannot block a task that isn't its own (#19534)."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="sibling")
|
||||
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (other,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_block({"task_id": other, "reason": "evil"})
|
||||
d = json.loads(out)
|
||||
assert "refusing to mutate" in d.get("error", "")
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn, other).status == "ready"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_heartbeat_rejects_foreign_task_id(worker_env):
|
||||
"""A worker cannot heartbeat a task that isn't its own (#19534)."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
conn = kb.connect()
|
||||
try:
|
||||
other = kb.create_task(conn, title="sibling")
|
||||
# Put sibling in running state so heartbeat would otherwise succeed.
|
||||
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (other,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_heartbeat({"task_id": other})
|
||||
d = json.loads(out)
|
||||
assert "refusing to mutate" in d.get("error", "")
|
||||
|
||||
|
||||
def test_worker_complete_own_task_still_works(worker_env):
|
||||
"""The ownership check doesn't break the normal own-task happy path."""
|
||||
from tools import kanban_tools as kt
|
||||
# Both implicit (no task_id arg) and explicit (matching env) must work.
|
||||
out = kt._handle_complete({"task_id": worker_env, "summary": "explicit own"})
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is True and d.get("task_id") == worker_env
|
||||
|
||||
|
||||
def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path):
|
||||
"""Orchestrator profiles (no HERMES_KANBAN_TASK) can still complete
|
||||
any task via explicit task_id. The check only applies to workers."""
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
from pathlib import Path as _P
|
||||
monkeypatch.setattr(_P, "home", lambda: tmp_path)
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
kb.init_db()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="child to close out")
|
||||
conn.execute("UPDATE tasks SET status='ready' WHERE id=?", (tid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from tools import kanban_tools as kt
|
||||
out = kt._handle_complete({"task_id": tid, "summary": "orchestrator close"})
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is True and d.get("task_id") == tid
|
||||
|
||||
@@ -440,6 +440,7 @@ class TestBuildOAuthAuthNonInteractive:
|
||||
|
||||
def test_build_client_metadata_basic():
|
||||
"""_build_client_metadata returns metadata with expected defaults."""
|
||||
pytest.importorskip("mcp")
|
||||
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
|
||||
|
||||
cfg = {"client_name": "Test Client"}
|
||||
@@ -453,6 +454,7 @@ def test_build_client_metadata_basic():
|
||||
|
||||
def test_build_client_metadata_without_secret_is_public():
|
||||
"""Without client_secret, token endpoint auth is 'none' (public client)."""
|
||||
pytest.importorskip("mcp")
|
||||
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
|
||||
|
||||
cfg = {}
|
||||
@@ -463,6 +465,7 @@ def test_build_client_metadata_without_secret_is_public():
|
||||
|
||||
def test_build_client_metadata_with_secret_is_confidential():
|
||||
"""With client_secret, token endpoint auth is 'client_secret_post'."""
|
||||
pytest.importorskip("mcp")
|
||||
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
|
||||
|
||||
cfg = {"client_secret": "shh"}
|
||||
|
||||
@@ -46,6 +46,13 @@ def test_is_session_expired_detects_session_not_found():
|
||||
assert _is_session_expired_error(RuntimeError("Unknown session: abc123")) is True
|
||||
|
||||
|
||||
def test_is_session_expired_detects_session_terminated():
|
||||
"""Remote Playwright MCP reports transport loss as ``Session terminated``."""
|
||||
from tools.mcp_tool import _is_session_expired_error
|
||||
|
||||
assert _is_session_expired_error(RuntimeError("Session terminated")) is True
|
||||
|
||||
|
||||
def test_is_session_expired_is_case_insensitive():
|
||||
"""Match uses lower-cased comparison so servers that emit the
|
||||
message in different cases (SDK formatter quirks) still trigger."""
|
||||
|
||||
@@ -498,3 +498,65 @@ class TestSessionSearch:
|
||||
assert result["count"] == 0
|
||||
assert result["results"] == []
|
||||
assert result["sessions_searched"] == 0
|
||||
|
||||
def test_source_from_resolved_parent_not_fts5_child(self):
|
||||
"""source in output must reflect the resolved parent session, not the child that matched FTS5.
|
||||
|
||||
Regression test for #15909: when a delegation child session (source='telegram')
|
||||
resolves to a parent (source='api_server'), the result entry must report
|
||||
'api_server', not 'telegram'.
|
||||
"""
|
||||
from unittest.mock import MagicMock, AsyncMock, patch as _patch
|
||||
from tools.session_search_tool import session_search
|
||||
|
||||
mock_db = MagicMock()
|
||||
# FTS5 hit is in the child delegation session which carries source='telegram'
|
||||
mock_db.search_messages.return_value = [
|
||||
{
|
||||
"session_id": "child_sid",
|
||||
"content": "hello world",
|
||||
"source": "telegram", # child session source — wrong value to surface
|
||||
"session_started": 1709400000,
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
]
|
||||
|
||||
def _get_session(session_id):
|
||||
if session_id == "child_sid":
|
||||
return {
|
||||
"id": "child_sid",
|
||||
"parent_session_id": "parent_sid",
|
||||
"source": "telegram",
|
||||
"started_at": 1709400000,
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
if session_id == "parent_sid":
|
||||
return {
|
||||
"id": "parent_sid",
|
||||
"parent_session_id": None,
|
||||
"source": "api_server", # correct parent source
|
||||
"started_at": 1709300000,
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
return None
|
||||
|
||||
mock_db.get_session.side_effect = _get_session
|
||||
mock_db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hello world"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
|
||||
with _patch(
|
||||
"tools.session_search_tool.async_call_llm",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("no provider"),
|
||||
):
|
||||
result = json.loads(session_search(query="hello world", db=mock_db))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
entry = result["results"][0]
|
||||
assert entry["session_id"] == "parent_sid", "should report resolved parent session ID"
|
||||
assert entry["source"] == "api_server", (
|
||||
f"source should be parent's 'api_server', got {entry['source']!r}"
|
||||
)
|
||||
|
||||
@@ -531,10 +531,41 @@ class TestSkillManageDispatcher:
|
||||
assert result["success"] is False
|
||||
|
||||
def test_full_create_via_dispatcher(self, tmp_path):
|
||||
"""Foreground create does NOT mark the skill as agent-created.
|
||||
|
||||
Skills created by user-directed foreground turns belong to the user;
|
||||
only the background self-improvement review fork should mark its
|
||||
own sediment as agent-created (so the curator can later consolidate
|
||||
or prune it).
|
||||
"""
|
||||
with _skill_dir(tmp_path):
|
||||
raw = skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT)
|
||||
from tools.skill_usage import load_usage
|
||||
usage = load_usage()
|
||||
result = json.loads(raw)
|
||||
assert result["success"] is True
|
||||
# No provenance marker on a foreground create — record either missing
|
||||
# entirely (telemetry best-effort) or present with created_by unset.
|
||||
rec = usage.get("test-skill") or {}
|
||||
assert rec.get("created_by") in (None, "", False)
|
||||
|
||||
def test_create_from_background_review_marks_agent_created(self, tmp_path):
|
||||
"""Background-review fork creates ARE marked as agent-created."""
|
||||
from tools.skill_provenance import set_current_write_origin, BACKGROUND_REVIEW
|
||||
token = set_current_write_origin(BACKGROUND_REVIEW)
|
||||
try:
|
||||
with _skill_dir(tmp_path):
|
||||
raw = skill_manage(
|
||||
action="create", name="review-sediment", content=VALID_SKILL_CONTENT
|
||||
)
|
||||
from tools.skill_usage import load_usage
|
||||
usage = load_usage()
|
||||
finally:
|
||||
from tools.skill_provenance import reset_current_write_origin
|
||||
reset_current_write_origin(token)
|
||||
result = json.loads(raw)
|
||||
assert result["success"] is True
|
||||
assert usage["review-sediment"]["created_by"] == "agent"
|
||||
|
||||
def test_delete_via_dispatcher_threads_absorbed_into(self, tmp_path):
|
||||
# Dispatcher must plumb absorbed_into through to _delete_skill so the
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for tools/skill_provenance.py — write-origin ContextVar."""
|
||||
|
||||
import contextvars
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_default_origin_is_foreground():
|
||||
from tools.skill_provenance import get_current_write_origin
|
||||
# In a fresh ContextVar context, default kicks in.
|
||||
ctx = contextvars.copy_context()
|
||||
origin = ctx.run(get_current_write_origin)
|
||||
assert origin == "foreground"
|
||||
|
||||
|
||||
def test_set_and_get_origin():
|
||||
from tools.skill_provenance import (
|
||||
set_current_write_origin,
|
||||
reset_current_write_origin,
|
||||
get_current_write_origin,
|
||||
)
|
||||
token = set_current_write_origin("background_review")
|
||||
try:
|
||||
assert get_current_write_origin() == "background_review"
|
||||
finally:
|
||||
reset_current_write_origin(token)
|
||||
|
||||
|
||||
def test_reset_restores_prior_origin():
|
||||
from tools.skill_provenance import (
|
||||
set_current_write_origin,
|
||||
reset_current_write_origin,
|
||||
get_current_write_origin,
|
||||
)
|
||||
outer = set_current_write_origin("assistant_tool")
|
||||
try:
|
||||
inner = set_current_write_origin("background_review")
|
||||
try:
|
||||
assert get_current_write_origin() == "background_review"
|
||||
finally:
|
||||
reset_current_write_origin(inner)
|
||||
assert get_current_write_origin() == "assistant_tool"
|
||||
finally:
|
||||
reset_current_write_origin(outer)
|
||||
|
||||
|
||||
def test_is_background_review_truthy_only_for_review():
|
||||
from tools.skill_provenance import (
|
||||
set_current_write_origin,
|
||||
reset_current_write_origin,
|
||||
is_background_review,
|
||||
BACKGROUND_REVIEW,
|
||||
)
|
||||
for origin, expected in (
|
||||
("foreground", False),
|
||||
("assistant_tool", False),
|
||||
("random_other_value", False),
|
||||
(BACKGROUND_REVIEW, True),
|
||||
):
|
||||
token = set_current_write_origin(origin)
|
||||
try:
|
||||
assert is_background_review() is expected, (
|
||||
f"is_background_review() wrong for origin={origin!r}"
|
||||
)
|
||||
finally:
|
||||
reset_current_write_origin(token)
|
||||
|
||||
|
||||
def test_empty_origin_falls_back_to_foreground():
|
||||
from tools.skill_provenance import (
|
||||
set_current_write_origin,
|
||||
reset_current_write_origin,
|
||||
get_current_write_origin,
|
||||
)
|
||||
token = set_current_write_origin("")
|
||||
try:
|
||||
# Empty is coerced to "foreground" at the set() boundary.
|
||||
assert get_current_write_origin() == "foreground"
|
||||
finally:
|
||||
reset_current_write_origin(token)
|
||||
|
||||
|
||||
def test_context_isolation_between_copies():
|
||||
"""ContextVar scoping: modifications in one copy do not leak out."""
|
||||
from tools.skill_provenance import (
|
||||
set_current_write_origin,
|
||||
get_current_write_origin,
|
||||
BACKGROUND_REVIEW,
|
||||
)
|
||||
|
||||
# Start at the module default.
|
||||
original = get_current_write_origin()
|
||||
|
||||
def _run_in_copy():
|
||||
set_current_write_origin(BACKGROUND_REVIEW)
|
||||
return get_current_write_origin()
|
||||
|
||||
ctx = contextvars.copy_context()
|
||||
inside = ctx.run(_run_in_copy)
|
||||
assert inside == BACKGROUND_REVIEW
|
||||
# Parent context unaffected.
|
||||
assert get_current_write_origin() == original
|
||||
@@ -194,10 +194,11 @@ def test_forget_removes_record(skills_home):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_agent_created_excludes_bundled(skills_home):
|
||||
from tools.skill_usage import list_agent_created_skill_names
|
||||
from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
|
||||
skills_dir = skills_home / "skills"
|
||||
_write_skill(skills_dir, "bundled-skill", category="github")
|
||||
_write_skill(skills_dir, "my-skill")
|
||||
mark_agent_created("my-skill")
|
||||
# Seed a bundled manifest marking bundled-skill as upstream
|
||||
(skills_dir / ".bundled_manifest").write_text(
|
||||
"bundled-skill:abc123\n", encoding="utf-8",
|
||||
@@ -208,10 +209,11 @@ def test_agent_created_excludes_bundled(skills_home):
|
||||
|
||||
|
||||
def test_agent_created_excludes_hub_installed(skills_home):
|
||||
from tools.skill_usage import list_agent_created_skill_names
|
||||
from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
|
||||
skills_dir = skills_home / "skills"
|
||||
_write_skill(skills_dir, "hub-skill")
|
||||
_write_skill(skills_dir, "my-skill")
|
||||
mark_agent_created("my-skill")
|
||||
hub_dir = skills_dir / ".hub"
|
||||
hub_dir.mkdir()
|
||||
(hub_dir / "lock.json").write_text(
|
||||
@@ -238,9 +240,10 @@ def test_is_agent_created(skills_home):
|
||||
|
||||
|
||||
def test_agent_created_skips_archive_and_hub_dirs(skills_home):
|
||||
from tools.skill_usage import list_agent_created_skill_names
|
||||
from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
|
||||
skills_dir = skills_home / "skills"
|
||||
_write_skill(skills_dir, "real-skill")
|
||||
mark_agent_created("real-skill")
|
||||
# Dot-prefixed dirs must be ignored even if they contain SKILL.md
|
||||
archive = skills_dir / ".archive" / "old-skill"
|
||||
archive.mkdir(parents=True)
|
||||
@@ -368,27 +371,41 @@ def test_archive_collision_gets_suffix(skills_home):
|
||||
# Reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_agent_created_report_includes_defaults(skills_home):
|
||||
from tools.skill_usage import agent_created_report, bump_view
|
||||
def test_agent_created_report_includes_marked_skills_with_defaults(skills_home):
|
||||
from tools.skill_usage import agent_created_report, bump_view, mark_agent_created
|
||||
skills_dir = skills_home / "skills"
|
||||
_write_skill(skills_dir, "a")
|
||||
_write_skill(skills_dir, "b")
|
||||
mark_agent_created("a")
|
||||
mark_agent_created("b")
|
||||
bump_view("a")
|
||||
rows = agent_created_report()
|
||||
by_name = {r["name"]: r for r in rows}
|
||||
assert "a" in by_name and "b" in by_name
|
||||
assert by_name["a"]["view_count"] == 1
|
||||
# b has no usage record yet — must still appear with defaults
|
||||
# b has only the provenance marker — activity fields still default.
|
||||
assert by_name["b"]["view_count"] == 0
|
||||
assert by_name["b"]["state"] == "active"
|
||||
|
||||
|
||||
def test_manual_skill_with_usage_is_not_curator_managed(skills_home):
|
||||
from tools.skill_usage import agent_created_report, bump_view, list_agent_created_skill_names
|
||||
skills_dir = skills_home / "skills"
|
||||
_write_skill(skills_dir, "manual-skill")
|
||||
|
||||
bump_view("manual-skill")
|
||||
|
||||
assert "manual-skill" not in list_agent_created_skill_names()
|
||||
assert "manual-skill" not in {r["name"] for r in agent_created_report()}
|
||||
|
||||
|
||||
def test_agent_created_report_excludes_bundled_and_hub(skills_home):
|
||||
from tools.skill_usage import agent_created_report
|
||||
from tools.skill_usage import agent_created_report, mark_agent_created
|
||||
skills_dir = skills_home / "skills"
|
||||
_write_skill(skills_dir, "mine")
|
||||
_write_skill(skills_dir, "bundled")
|
||||
_write_skill(skills_dir, "hubbed")
|
||||
mark_agent_created("mine")
|
||||
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
|
||||
hub = skills_dir / ".hub"
|
||||
hub.mkdir()
|
||||
@@ -414,6 +431,7 @@ def test_agent_created_report_derives_activity_from_view_and_patch(skills_home,
|
||||
])
|
||||
monkeypatch.setattr(skill_usage, "_now_iso", lambda: next(timestamps))
|
||||
|
||||
skill_usage.mark_agent_created("mine")
|
||||
skill_usage.bump_view("mine")
|
||||
skill_usage.bump_patch("mine")
|
||||
|
||||
|
||||
@@ -901,6 +901,69 @@ class TestCheckForSkillUpdates:
|
||||
|
||||
assert bundle_content_hash(bundle) == content_hash(skill_dir)
|
||||
|
||||
def test_bundle_content_hash_accepts_binary_files(self):
|
||||
bundle = SkillBundle(
|
||||
name="demo-binary-skill",
|
||||
files={
|
||||
"SKILL.md": "# Demo\n",
|
||||
"assets/logo.png": b"\x89PNG\r\n\x1a\nbinary",
|
||||
},
|
||||
source="github",
|
||||
identifier="owner/repo/demo-binary-skill",
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
digest = bundle_content_hash(bundle)
|
||||
|
||||
assert digest.startswith("sha256:")
|
||||
|
||||
def test_bundle_content_hash_bytes_matches_str_equivalent(self):
|
||||
"""Bytes content must hash identically to its str-decoded form."""
|
||||
text_bundle = SkillBundle(
|
||||
name="demo-skill",
|
||||
files={
|
||||
"SKILL.md": "same content",
|
||||
"references/checklist.md": "- [ ] security\n",
|
||||
},
|
||||
source="github",
|
||||
identifier="owner/repo/demo-skill",
|
||||
trust_level="community",
|
||||
)
|
||||
bytes_bundle = SkillBundle(
|
||||
name="demo-skill",
|
||||
files={
|
||||
"SKILL.md": b"same content",
|
||||
"references/checklist.md": b"- [ ] security\n",
|
||||
},
|
||||
source="github",
|
||||
identifier="owner/repo/demo-skill",
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
assert bundle_content_hash(bytes_bundle) == bundle_content_hash(text_bundle)
|
||||
|
||||
def test_bundle_content_hash_mixed_matches_on_disk(self, tmp_path):
|
||||
"""In-memory bundle hash must equal on-disk content_hash for mixed bytes+str."""
|
||||
from tools.skills_guard import content_hash
|
||||
|
||||
bundle = SkillBundle(
|
||||
name="demo-skill",
|
||||
files={
|
||||
"SKILL.md": b"# Demo Skill\n",
|
||||
"references/checklist.md": "- [ ] security\n",
|
||||
},
|
||||
source="github",
|
||||
identifier="owner/repo/demo-skill",
|
||||
trust_level="community",
|
||||
)
|
||||
skill_dir = tmp_path / "demo-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_bytes(b"# Demo Skill\n")
|
||||
(skill_dir / "references").mkdir()
|
||||
(skill_dir / "references" / "checklist.md").write_text("- [ ] security\n")
|
||||
|
||||
assert bundle_content_hash(bundle) == content_hash(skill_dir)
|
||||
|
||||
def test_reports_update_when_remote_hash_differs(self):
|
||||
lock = MagicMock()
|
||||
lock.list_installed.return_value = [{
|
||||
|
||||
@@ -516,12 +516,25 @@ class TestPerToolThresholds:
|
||||
except ImportError:
|
||||
pytest.skip("terminal_tool not importable in test env")
|
||||
|
||||
def test_read_file_never_persisted(self):
|
||||
def test_read_file_result_size_cap(self):
|
||||
from tools.registry import registry
|
||||
try:
|
||||
import tools.file_tools # noqa: F401
|
||||
val = registry.get_max_result_size("read_file")
|
||||
assert val == float("inf")
|
||||
assert val == 100_000
|
||||
except ImportError:
|
||||
pytest.skip("file_tools not importable in test env")
|
||||
|
||||
def test_read_file_registry_cap_is_100k(self):
|
||||
"""Regression test: read_file must have a 100_000 char registry cap (Layer 2 safety net)."""
|
||||
from tools.registry import registry
|
||||
try:
|
||||
import tools.file_tools # noqa: F401
|
||||
val = registry.get_max_result_size("read_file")
|
||||
assert val == 100_000, (
|
||||
f"read_file registry cap must be 100_000, got {val!r}. "
|
||||
"float('inf') is not allowed — it disables the Layer 2 result-size guard."
|
||||
)
|
||||
except ImportError:
|
||||
pytest.skip("file_tools not importable in test env")
|
||||
|
||||
|
||||
@@ -415,6 +415,10 @@ class TestTranscribeLocalCommand:
|
||||
# _transcribe_local — additional tests
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not __import__("importlib").util.find_spec("faster_whisper"),
|
||||
reason="faster_whisper not installed",
|
||||
)
|
||||
class TestTranscribeLocalExtended:
|
||||
def test_model_reuse_on_second_call(self, tmp_path):
|
||||
"""Second call with same model should NOT reload the model."""
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for video_analyze tool in tools/vision_tools.py."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Awaitable
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.vision_tools import (
|
||||
_detect_video_mime_type,
|
||||
_video_to_base64_data_url,
|
||||
_handle_video_analyze,
|
||||
_MAX_VIDEO_BASE64_BYTES,
|
||||
_VIDEO_MIME_TYPES,
|
||||
_VIDEO_SIZE_WARN_BYTES,
|
||||
video_analyze_tool,
|
||||
VIDEO_ANALYZE_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_video_mime_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectVideoMimeType:
|
||||
"""Extension-based MIME detection for video files."""
|
||||
|
||||
def test_mp4(self, tmp_path):
|
||||
p = tmp_path / "clip.mp4"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mp4"
|
||||
|
||||
def test_webm(self, tmp_path):
|
||||
p = tmp_path / "clip.webm"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/webm"
|
||||
|
||||
def test_mov(self, tmp_path):
|
||||
p = tmp_path / "clip.mov"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mov"
|
||||
|
||||
def test_avi_fallback_mp4(self, tmp_path):
|
||||
p = tmp_path / "clip.avi"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mp4"
|
||||
|
||||
def test_mkv_fallback_mp4(self, tmp_path):
|
||||
p = tmp_path / "clip.mkv"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mp4"
|
||||
|
||||
def test_mpeg(self, tmp_path):
|
||||
p = tmp_path / "clip.mpeg"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mpeg"
|
||||
|
||||
def test_mpg(self, tmp_path):
|
||||
p = tmp_path / "clip.mpg"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mpeg"
|
||||
|
||||
def test_unsupported_extension(self, tmp_path):
|
||||
p = tmp_path / "clip.flv"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) is None
|
||||
|
||||
def test_case_insensitive(self, tmp_path):
|
||||
p = tmp_path / "clip.MP4"
|
||||
p.write_bytes(b"\x00" * 10)
|
||||
assert _detect_video_mime_type(p) == "video/mp4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _video_to_base64_data_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVideoToBase64DataUrl:
|
||||
"""Base64 encoding of video files."""
|
||||
|
||||
def test_produces_data_url(self, tmp_path):
|
||||
p = tmp_path / "test.mp4"
|
||||
p.write_bytes(b"\x00\x01\x02\x03")
|
||||
result = _video_to_base64_data_url(p)
|
||||
assert result.startswith("data:video/mp4;base64,")
|
||||
|
||||
def test_custom_mime_type(self, tmp_path):
|
||||
p = tmp_path / "test.webm"
|
||||
p.write_bytes(b"\x00\x01\x02\x03")
|
||||
result = _video_to_base64_data_url(p, mime_type="video/webm")
|
||||
assert result.startswith("data:video/webm;base64,")
|
||||
|
||||
def test_default_mime_for_unknown_ext(self, tmp_path):
|
||||
p = tmp_path / "test.xyz"
|
||||
p.write_bytes(b"\x00\x01\x02\x03")
|
||||
result = _video_to_base64_data_url(p)
|
||||
# Falls back to video/mp4
|
||||
assert result.startswith("data:video/mp4;base64,")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVideoAnalyzeSchema:
|
||||
"""Schema structure is correct."""
|
||||
|
||||
def test_schema_name(self):
|
||||
assert VIDEO_ANALYZE_SCHEMA["name"] == "video_analyze"
|
||||
|
||||
def test_schema_has_required_fields(self):
|
||||
params = VIDEO_ANALYZE_SCHEMA["parameters"]
|
||||
assert "video_url" in params["properties"]
|
||||
assert "question" in params["properties"]
|
||||
assert params["required"] == ["video_url", "question"]
|
||||
|
||||
def test_schema_description_mentions_video(self):
|
||||
assert "video" in VIDEO_ANALYZE_SCHEMA["description"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handle_video_analyze handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleVideoAnalyze:
|
||||
"""Tests for the registry handler wrapper."""
|
||||
|
||||
def test_returns_awaitable(self, tmp_path, monkeypatch):
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"\x00" * 100)
|
||||
monkeypatch.setenv("AUXILIARY_VIDEO_MODEL", "")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "")
|
||||
|
||||
with patch("tools.vision_tools.video_analyze_tool", new_callable=AsyncMock) as mock_tool:
|
||||
mock_tool.return_value = json.dumps({"success": True, "analysis": "test"})
|
||||
result = _handle_video_analyze({"video_url": str(video_file), "question": "what is this?"})
|
||||
# Should return an awaitable (coroutine)
|
||||
assert asyncio.iscoroutine(result)
|
||||
# Clean up the unawaited coroutine
|
||||
result.close()
|
||||
|
||||
def test_uses_auxiliary_video_model_env(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AUXILIARY_VIDEO_MODEL", "google/gemini-2.5-flash")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "other-model")
|
||||
|
||||
with patch("tools.vision_tools.video_analyze_tool", new_callable=AsyncMock) as mock_tool:
|
||||
mock_tool.return_value = json.dumps({"success": True, "analysis": "ok"})
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
_handle_video_analyze({"video_url": "/tmp/test.mp4", "question": "test"})
|
||||
)
|
||||
args = mock_tool.call_args[0]
|
||||
assert args[2] == "google/gemini-2.5-flash"
|
||||
|
||||
def test_falls_back_to_vision_model_env(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AUXILIARY_VIDEO_MODEL", "")
|
||||
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "google/gemini-flash")
|
||||
|
||||
with patch("tools.vision_tools.video_analyze_tool", new_callable=AsyncMock) as mock_tool:
|
||||
mock_tool.return_value = json.dumps({"success": True, "analysis": "ok"})
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
_handle_video_analyze({"video_url": "/tmp/test.mp4", "question": "test"})
|
||||
)
|
||||
args = mock_tool.call_args[0]
|
||||
assert args[2] == "google/gemini-flash"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# video_analyze_tool — integration-style tests with mocked LLM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVideoAnalyzeTool:
|
||||
"""Core video analysis function tests."""
|
||||
|
||||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
def test_local_file_success(self, tmp_path, monkeypatch):
|
||||
"""Analyze a local video file — happy path."""
|
||||
video = tmp_path / "demo.mp4"
|
||||
video.write_bytes(b"\x00" * 1024)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "A short video showing a demo."
|
||||
|
||||
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock, return_value=mock_response):
|
||||
with patch("tools.vision_tools.extract_content_or_reasoning", return_value="A short video showing a demo."):
|
||||
result = self._run(video_analyze_tool(str(video), "What is this?"))
|
||||
|
||||
data = json.loads(result)
|
||||
assert data["success"] is True
|
||||
assert "demo" in data["analysis"].lower()
|
||||
|
||||
def test_local_file_not_found(self, tmp_path):
|
||||
"""Non-existent file raises appropriate error."""
|
||||
result = self._run(video_analyze_tool("/nonexistent/video.mp4", "What?"))
|
||||
data = json.loads(result)
|
||||
assert data["success"] is False
|
||||
assert "invalid video source" in data["analysis"].lower()
|
||||
|
||||
def test_unsupported_format(self, tmp_path):
|
||||
"""Unsupported extension raises error."""
|
||||
video = tmp_path / "clip.flv"
|
||||
video.write_bytes(b"\x00" * 100)
|
||||
|
||||
result = self._run(video_analyze_tool(str(video), "What is this?"))
|
||||
data = json.loads(result)
|
||||
assert data["success"] is False
|
||||
assert "unsupported video format" in data["analysis"].lower()
|
||||
|
||||
def test_video_too_large(self, tmp_path, monkeypatch):
|
||||
"""Video exceeding max size is rejected."""
|
||||
video = tmp_path / "huge.mp4"
|
||||
# Don't actually write 50MB — mock the stat
|
||||
video.write_bytes(b"\x00" * 100)
|
||||
|
||||
# Patch the base64 encoding to return something huge
|
||||
with patch("tools.vision_tools._video_to_base64_data_url") as mock_encode:
|
||||
mock_encode.return_value = "data:video/mp4;base64," + "A" * (_MAX_VIDEO_BASE64_BYTES + 1)
|
||||
result = self._run(video_analyze_tool(str(video), "What?"))
|
||||
|
||||
data = json.loads(result)
|
||||
assert data["success"] is False
|
||||
assert "too large" in data["analysis"].lower()
|
||||
|
||||
def test_interrupt_check(self, tmp_path):
|
||||
"""Tool respects interrupt flag."""
|
||||
video = tmp_path / "test.mp4"
|
||||
video.write_bytes(b"\x00" * 100)
|
||||
|
||||
with patch("tools.interrupt.is_interrupted", return_value=True):
|
||||
result = self._run(video_analyze_tool(str(video), "What?"))
|
||||
|
||||
data = json.loads(result)
|
||||
assert data["success"] is False
|
||||
|
||||
def test_empty_response_retries(self, tmp_path):
|
||||
"""Retries once on empty model response."""
|
||||
video = tmp_path / "test.mp4"
|
||||
video.write_bytes(b"\x00" * 100)
|
||||
|
||||
call_count = 0
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "Video analysis result."
|
||||
|
||||
async def fake_llm(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_response
|
||||
|
||||
with patch("tools.vision_tools.async_call_llm", side_effect=fake_llm):
|
||||
with patch("tools.vision_tools.extract_content_or_reasoning", side_effect=["", "Video analysis result."]):
|
||||
result = self._run(video_analyze_tool(str(video), "What?"))
|
||||
|
||||
data = json.loads(result)
|
||||
assert data["success"] is True
|
||||
assert call_count == 2 # Initial call + retry
|
||||
|
||||
def test_file_scheme_stripped(self, tmp_path):
|
||||
"""file:// prefix is stripped correctly."""
|
||||
video = tmp_path / "test.mp4"
|
||||
video.write_bytes(b"\x00" * 100)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "OK"
|
||||
|
||||
with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock, return_value=mock_response):
|
||||
with patch("tools.vision_tools.extract_content_or_reasoning", return_value="OK"):
|
||||
result = self._run(video_analyze_tool(f"file://{video}", "What?"))
|
||||
|
||||
data = json.loads(result)
|
||||
assert data["success"] is True
|
||||
|
||||
def test_api_message_format(self, tmp_path):
|
||||
"""Verify the message sent to LLM uses video_url content type."""
|
||||
video = tmp_path / "test.mp4"
|
||||
video.write_bytes(b"\x00" * 100)
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
async def capture_llm(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "OK"
|
||||
return mock_response
|
||||
|
||||
with patch("tools.vision_tools.async_call_llm", side_effect=capture_llm):
|
||||
with patch("tools.vision_tools.extract_content_or_reasoning", return_value="OK"):
|
||||
self._run(video_analyze_tool(str(video), "Describe this"))
|
||||
|
||||
messages = captured_kwargs["messages"]
|
||||
assert len(messages) == 1
|
||||
content = messages[0]["content"]
|
||||
assert len(content) == 2
|
||||
assert content[0]["type"] == "text"
|
||||
assert content[1]["type"] == "video_url"
|
||||
assert "video_url" in content[1]
|
||||
assert content[1]["video_url"]["url"].startswith("data:video/mp4;base64,")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Toolset registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVideoToolsetRegistration:
|
||||
"""Verify the tool is registered correctly."""
|
||||
|
||||
def test_registered_in_video_toolset(self):
|
||||
from tools.registry import registry
|
||||
entry = registry.get_entry("video_analyze")
|
||||
assert entry is not None
|
||||
assert entry.toolset == "video"
|
||||
assert entry.is_async is True
|
||||
assert entry.emoji == "🎬"
|
||||
|
||||
def test_not_in_core_tools(self):
|
||||
"""video_analyze should NOT be in _HERMES_CORE_TOOLS (default disabled)."""
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
assert "video_analyze" not in _HERMES_CORE_TOOLS
|
||||
|
||||
def test_in_video_toolset_definition(self):
|
||||
"""Toolset 'video' should contain video_analyze."""
|
||||
from toolsets import TOOLSETS
|
||||
assert "video" in TOOLSETS
|
||||
assert "video_analyze" in TOOLSETS["video"]["tools"]
|
||||
@@ -1040,6 +1040,25 @@ class TestDisableVoiceModeReal:
|
||||
class TestVoiceSpeakResponseReal:
|
||||
"""Tests _voice_speak_response with real CLI instance."""
|
||||
|
||||
def test_async_scheduling_clears_done_before_thread_start(self):
|
||||
cli = _make_voice_cli(_voice_tts=True)
|
||||
starts = []
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, target=None, args=(), daemon=None):
|
||||
self.target = target
|
||||
self.args = args
|
||||
self.daemon = daemon
|
||||
|
||||
def start(self):
|
||||
starts.append(cli._voice_tts_done.is_set())
|
||||
|
||||
with patch("cli.threading.Thread", FakeThread):
|
||||
cli._voice_speak_response_async("Hello")
|
||||
|
||||
assert starts == [False]
|
||||
assert not cli._voice_tts_done.is_set()
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_early_return_when_tts_off(self, _cp):
|
||||
cli = _make_voice_cli(_voice_tts=False)
|
||||
|
||||
Reference in New Issue
Block a user