Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -14,6 +14,7 @@ from agent.anthropic_adapter import (
|
||||
_to_plain_data,
|
||||
_write_claude_code_credentials,
|
||||
build_anthropic_client,
|
||||
build_anthropic_bedrock_client,
|
||||
build_anthropic_kwargs,
|
||||
convert_messages_to_anthropic,
|
||||
convert_tools_to_anthropic,
|
||||
@@ -66,11 +67,9 @@ class TestBuildAnthropicClient:
|
||||
assert "claude-code-20250219" in betas
|
||||
assert "interleaved-thinking-2025-05-14" in betas
|
||||
assert "fine-grained-tool-streaming-2025-05-14" in betas
|
||||
# Default: 1M-context beta stays IN for OAuth so 1M-capable
|
||||
# subscriptions keep full context. The reactive recovery path
|
||||
# in run_agent.py flips it off only after a subscription
|
||||
# actually rejects the beta.
|
||||
assert "context-1m-2025-08-07" in betas
|
||||
# Native Anthropic does not get context-1m by default; accounts
|
||||
# without that beta reject even short auxiliary requests.
|
||||
assert "context-1m-2025-08-07" not in betas
|
||||
assert "api_key" not in kwargs
|
||||
|
||||
def test_oauth_drop_context_1m_beta_strips_only_1m(self):
|
||||
@@ -99,7 +98,7 @@ class TestBuildAnthropicClient:
|
||||
# API key auth should still get common betas
|
||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||
assert "interleaved-thinking-2025-05-14" in betas
|
||||
assert "context-1m-2025-08-07" in betas
|
||||
assert "context-1m-2025-08-07" not in betas
|
||||
assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present
|
||||
assert "claude-code-20250219" not in betas # OAuth-only beta NOT present
|
||||
|
||||
@@ -109,9 +108,27 @@ class TestBuildAnthropicClient:
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["base_url"] == "https://custom.api.com"
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07"
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}
|
||||
|
||||
def test_azure_anthropic_endpoint_keeps_context_1m_beta(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(
|
||||
"azure-key",
|
||||
base_url="https://example.services.ai.azure.com/models/anthropic",
|
||||
)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||
assert "context-1m-2025-08-07" in betas
|
||||
|
||||
def test_bedrock_client_keeps_context_1m_beta(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
mock_sdk.AnthropicBedrock = MagicMock()
|
||||
build_anthropic_bedrock_client("us-east-1")
|
||||
kwargs = mock_sdk.AnthropicBedrock.call_args[1]
|
||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||
assert "context-1m-2025-08-07" in betas
|
||||
|
||||
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(
|
||||
@@ -986,8 +1003,8 @@ class TestBuildAnthropicKwargs:
|
||||
)
|
||||
assert kwargs["model"] == "claude-sonnet-4-20250514"
|
||||
|
||||
def test_fast_mode_oauth_default_keeps_context_1m_beta(self):
|
||||
"""Default OAuth fast-mode requests still carry context-1m-2025-08-07."""
|
||||
def test_fast_mode_oauth_default_omits_context_1m_beta(self):
|
||||
"""Default OAuth fast-mode avoids context-1m for subscriptions without it."""
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
@@ -1000,7 +1017,7 @@ class TestBuildAnthropicKwargs:
|
||||
betas = kwargs["extra_headers"]["anthropic-beta"]
|
||||
assert "fast-mode-2026-02-01" in betas
|
||||
assert "oauth-2025-04-20" in betas
|
||||
assert "context-1m-2025-08-07" in betas
|
||||
assert "context-1m-2025-08-07" not in betas
|
||||
|
||||
def test_fast_mode_oauth_drop_context_1m_beta_strips_only_1m(self):
|
||||
"""drop_context_1m_beta=True strips context-1m from fast-mode
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +26,7 @@ from agent.auxiliary_client import (
|
||||
_normalize_aux_provider,
|
||||
_try_payment_fallback,
|
||||
_resolve_auto,
|
||||
_CodexCompletionsAdapter,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +60,18 @@ def codex_auth_dir(tmp_path, monkeypatch):
|
||||
return codex_dir
|
||||
|
||||
|
||||
class TestAuxiliaryMaxTokensParam:
|
||||
def test_uses_max_completion_tokens_for_github_copilot_custom_base(self):
|
||||
with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.githubcopilot.com", "key", None)), \
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None):
|
||||
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
|
||||
|
||||
def test_uses_max_completion_tokens_for_github_copilot_custom_base_path(self):
|
||||
with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.githubcopilot.com/chat/completions", "key", None)), \
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None):
|
||||
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
|
||||
|
||||
|
||||
class TestNormalizeAuxProvider:
|
||||
def test_maps_github_copilot_aliases(self):
|
||||
assert _normalize_aux_provider("github") == "copilot"
|
||||
@@ -1882,6 +1897,85 @@ class TestVisionAutoSkipsKimiCoding:
|
||||
})
|
||||
|
||||
|
||||
class TestCodexAuxiliaryAdapterTimeout:
|
||||
def test_forwards_timeout_to_responses_stream(self):
|
||||
class FakeStream:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
def get_final_response(self):
|
||||
return SimpleNamespace(
|
||||
output=[SimpleNamespace(
|
||||
type="message",
|
||||
content=[SimpleNamespace(type="output_text", text="summary")],
|
||||
)],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
class FakeResponses:
|
||||
def __init__(self):
|
||||
self.kwargs = None
|
||||
|
||||
def stream(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return FakeStream()
|
||||
|
||||
fake_client = SimpleNamespace(responses=FakeResponses())
|
||||
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
|
||||
|
||||
response = adapter.create(
|
||||
messages=[{"role": "user", "content": "summarize this"}],
|
||||
timeout=12.5,
|
||||
)
|
||||
|
||||
assert fake_client.responses.kwargs["timeout"] == 12.5
|
||||
assert response.choices[0].message.content == "summary"
|
||||
|
||||
def test_enforces_total_timeout_while_stream_keeps_emitting_events(self):
|
||||
class SlowAliveStream:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
for _ in range(5):
|
||||
time.sleep(0.03)
|
||||
yield SimpleNamespace(type="response.in_progress")
|
||||
|
||||
def get_final_response(self):
|
||||
return SimpleNamespace(
|
||||
output=[SimpleNamespace(
|
||||
type="message",
|
||||
content=[SimpleNamespace(type="output_text", text="late")],
|
||||
)],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
class FakeResponses:
|
||||
def stream(self, **kwargs):
|
||||
return SlowAliveStream()
|
||||
|
||||
fake_client = SimpleNamespace(responses=FakeResponses(), close=lambda: None)
|
||||
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(TimeoutError):
|
||||
adapter.create(
|
||||
messages=[{"role": "user", "content": "summarize this"}],
|
||||
timeout=0.05,
|
||||
)
|
||||
|
||||
assert time.monotonic() - started < 0.14
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_call_kwargs — tool dedup at API boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -994,6 +994,7 @@ class TestStreamConverseWithCallbacks:
|
||||
events, on_reasoning_delta=lambda t: reasoning.append(t),
|
||||
)
|
||||
assert reasoning == ["Let me think..."]
|
||||
assert result.choices[0].message.reasoning_content == "Let me think..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -191,6 +191,30 @@ class TestNonStringContent:
|
||||
kwargs = mock_call.call_args.kwargs
|
||||
assert "temperature" not in kwargs
|
||||
|
||||
def test_summary_prompt_avoids_filter_sensitive_handoff_framing(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call:
|
||||
c._generate_summary(messages)
|
||||
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "Your output will be injected" not in prompt
|
||||
assert "Do NOT respond" not in prompt
|
||||
assert "DIFFERENT assistant" not in prompt
|
||||
assert "different assistant" not in prompt
|
||||
assert "Treat the conversation turns below as source material" in prompt
|
||||
assert "structured checkpoint summary" in prompt
|
||||
|
||||
def test_summary_call_passes_live_main_runtime(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
|
||||
@@ -250,6 +250,42 @@ def test_exhausted_402_entry_resets_after_one_hour(tmp_path, monkeypatch):
|
||||
assert entry.last_status == "ok"
|
||||
|
||||
|
||||
def test_exhausted_401_entry_resets_after_five_minutes(tmp_path, monkeypatch):
|
||||
"""Transient auth failures should not strand single-key setups for an hour."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "primary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"last_status": "exhausted",
|
||||
"last_status_at": time.time() - 310,
|
||||
"last_error_code": 401,
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.id == "cred-1"
|
||||
assert entry.last_status == "ok"
|
||||
|
||||
|
||||
def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
# Prevent auto-seeding from Codex CLI tokens on the host
|
||||
@@ -924,6 +960,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch):
|
||||
assert get_custom_provider_pool_key("") is None
|
||||
|
||||
|
||||
def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch):
|
||||
"""When two custom providers share the same base_url, provider_name resolves to the correct one."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
import yaml
|
||||
config_path = tmp_path / "hermes" / "config.yaml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "provider-a",
|
||||
"base_url": "http://gateway:8080/v1",
|
||||
"api_key": "sk-aaa",
|
||||
},
|
||||
{
|
||||
"name": "provider-b",
|
||||
"base_url": "http://gateway:8080/v1",
|
||||
"api_key": "sk-bbb",
|
||||
},
|
||||
]
|
||||
}))
|
||||
|
||||
from agent.credential_pool import get_custom_provider_pool_key
|
||||
|
||||
# Without provider_name, first match wins (backward compatible)
|
||||
assert get_custom_provider_pool_key("http://gateway:8080/v1") == "custom:provider-a"
|
||||
|
||||
# With provider_name, exact name match wins regardless of order
|
||||
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-b") == "custom:provider-b"
|
||||
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-a") == "custom:provider-a"
|
||||
|
||||
# Name match with non-matching base_url still works via fallback
|
||||
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="nonexistent") == "custom:provider-a"
|
||||
|
||||
# Empty provider_name is same as None (backward compatible)
|
||||
assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="") == "custom:provider-a"
|
||||
|
||||
|
||||
def test_list_custom_pool_providers(tmp_path, monkeypatch):
|
||||
"""list_custom_pool_providers returns custom: pool keys from auth.json."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
|
||||
@@ -8,12 +8,21 @@ from agent.display import (
|
||||
build_tool_preview,
|
||||
capture_local_edit_snapshot,
|
||||
extract_edit_diff,
|
||||
get_cute_tool_message,
|
||||
set_tool_preview_max_len,
|
||||
_render_inline_unified_diff,
|
||||
_summarize_rendered_diff_sections,
|
||||
render_edit_diff_with_delta,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_tool_preview_max_len():
|
||||
set_tool_preview_max_len(0)
|
||||
yield
|
||||
set_tool_preview_max_len(0)
|
||||
|
||||
|
||||
class TestBuildToolPreview:
|
||||
"""Tests for build_tool_preview defensive handling and normal operation."""
|
||||
|
||||
@@ -102,6 +111,45 @@ class TestBuildToolPreview:
|
||||
assert build_tool_preview("terminal", []) is None
|
||||
|
||||
|
||||
class TestCuteToolMessagePreviewLength:
|
||||
def test_terminal_preview_unlimited_when_config_is_zero(self):
|
||||
set_tool_preview_max_len(0)
|
||||
command = "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")' | head -5"
|
||||
|
||||
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
|
||||
|
||||
assert command in line
|
||||
assert "..." not in line
|
||||
|
||||
def test_terminal_preview_uses_positive_configured_limit(self):
|
||||
set_tool_preview_max_len(80)
|
||||
command = "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")' | head -5"
|
||||
|
||||
line = get_cute_tool_message("terminal", {"command": command}, 0.1)
|
||||
|
||||
assert command[:77] in line
|
||||
assert "..." in line
|
||||
assert "head -5" not in line
|
||||
|
||||
def test_search_files_preview_uses_positive_configured_limit_not_default(self):
|
||||
set_tool_preview_max_len(80)
|
||||
pattern = "function.formatToolCall.context.preview.compactPreview.maxLength.truncate"
|
||||
|
||||
line = get_cute_tool_message("search_files", {"pattern": pattern}, 0.1)
|
||||
|
||||
assert pattern in line
|
||||
assert "..." not in line
|
||||
|
||||
def test_path_preview_uses_positive_configured_limit_not_default(self):
|
||||
set_tool_preview_max_len(80)
|
||||
path = "/tmp/hermes-test-preview-length/deeply/nested/path/test-output.txt"
|
||||
|
||||
line = get_cute_tool_message("read_file", {"path": path}, 0.1)
|
||||
|
||||
assert path in line
|
||||
assert "..." not in line
|
||||
|
||||
|
||||
class TestEditDiffPreview:
|
||||
def test_extract_edit_diff_for_patch(self):
|
||||
diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}')
|
||||
|
||||
@@ -109,6 +109,21 @@ class TestDecideImageInputMode:
|
||||
with patch("agent.image_routing._lookup_supports_vision", return_value=True):
|
||||
assert decide_image_input_mode("anthropic", "claude-sonnet-4", cfg) == "native"
|
||||
|
||||
def test_auto_uses_text_for_text_only_modalities_even_with_attachment_flag(self):
|
||||
registry = {
|
||||
"xiaomi": {
|
||||
"models": {
|
||||
"mimo-v2.5-pro": {
|
||||
"attachment": True,
|
||||
"modalities": {"input": ["text"]},
|
||||
"tool_call": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=registry):
|
||||
assert decide_image_input_mode("xiaomi", "mimo-v2.5-pro", {}) == "text"
|
||||
|
||||
|
||||
# ─── build_native_content_parts ──────────────────────────────────────────────
|
||||
|
||||
@@ -127,7 +142,11 @@ class TestBuildNativeContentParts:
|
||||
parts, skipped = build_native_content_parts("hello", [str(img)])
|
||||
assert skipped == []
|
||||
assert len(parts) == 2
|
||||
assert parts[0] == {"type": "text", "text": "hello"}
|
||||
assert parts[0]["type"] == "text"
|
||||
# User caption is preserved and a per-image path hint is appended so
|
||||
# the model can use the local path as a string argument for tools
|
||||
# that take ``image_url: str`` (issue #18960).
|
||||
assert parts[0]["text"] == f"hello\n\n[Image attached at: {img}]"
|
||||
assert parts[1]["type"] == "image_url"
|
||||
assert parts[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
@@ -137,17 +156,51 @@ class TestBuildNativeContentParts:
|
||||
parts, skipped = build_native_content_parts("", [str(img)])
|
||||
assert skipped == []
|
||||
# Even with empty user text, we insert a neutral prompt so the turn
|
||||
# isn't just pixels.
|
||||
# isn't just pixels, and the path hint is appended after.
|
||||
assert parts[0]["type"] == "text"
|
||||
assert parts[0]["text"] == "What do you see in this image?"
|
||||
assert parts[0]["text"] == (
|
||||
f"What do you see in this image?\n\n[Image attached at: {img}]"
|
||||
)
|
||||
assert parts[1]["type"] == "image_url"
|
||||
|
||||
def test_missing_file_is_skipped(self, tmp_path: Path):
|
||||
parts, skipped = build_native_content_parts("hi", [str(tmp_path / "missing.png")])
|
||||
assert skipped == [str(tmp_path / "missing.png")]
|
||||
# Only text remains.
|
||||
# Skipped paths are NOT advertised in the path hints — the model
|
||||
# would otherwise be told a non-existent file is attached.
|
||||
assert parts == [{"type": "text", "text": "hi"}]
|
||||
|
||||
def test_path_hint_appended(self, tmp_path: Path):
|
||||
"""The local path of each attached image is appended to the user
|
||||
text part so MCP/skill tools that take ``image_url: str`` can be
|
||||
invoked on the same image (issue #18960). Mirrors text-mode
|
||||
behaviour (`Runner._enrich_message_with_vision`).
|
||||
"""
|
||||
img = tmp_path / "scan.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
parts, _ = build_native_content_parts("attach this", [str(img)])
|
||||
text_part = next(p for p in parts if p.get("type") == "text")
|
||||
assert "[Image attached at:" in text_part["text"]
|
||||
assert str(img) in text_part["text"]
|
||||
# User caption is preserved verbatim ahead of the hint.
|
||||
assert text_part["text"].startswith("attach this")
|
||||
|
||||
def test_path_hint_one_per_attached_image(self, tmp_path: Path):
|
||||
"""Each successfully attached image gets its own path hint line;
|
||||
skipped images do NOT appear in the hints.
|
||||
"""
|
||||
good = tmp_path / "good.png"
|
||||
good.write_bytes(_png_bytes())
|
||||
missing = tmp_path / "missing.png" # never created
|
||||
parts, skipped = build_native_content_parts(
|
||||
"see attached", [str(good), str(missing)]
|
||||
)
|
||||
assert skipped == [str(missing)]
|
||||
text_part = next(p for p in parts if p.get("type") == "text")
|
||||
assert text_part["text"].count("[Image attached at:") == 1
|
||||
assert str(good) in text_part["text"]
|
||||
assert str(missing) not in text_part["text"]
|
||||
|
||||
def test_multiple_images(self, tmp_path: Path):
|
||||
img1 = tmp_path / "a.png"
|
||||
img2 = tmp_path / "b.png"
|
||||
@@ -157,21 +210,41 @@ class TestBuildNativeContentParts:
|
||||
assert skipped == []
|
||||
image_parts = [p for p in parts if p.get("type") == "image_url"]
|
||||
assert len(image_parts) == 2
|
||||
# Both paths surface in the text part, one per line.
|
||||
text_part = next(p for p in parts if p.get("type") == "text")
|
||||
assert text_part["text"].count("[Image attached at:") == 2
|
||||
assert str(img1) in text_part["text"]
|
||||
assert str(img2) in text_part["text"]
|
||||
|
||||
def test_mime_inference_jpg(self, tmp_path: Path):
|
||||
# Real JPEG bytes (SOI marker FF D8 FF): sniffing now wins over suffix.
|
||||
img = tmp_path / "photo.jpg"
|
||||
img.write_bytes(_png_bytes()) # bytes are PNG but extension is jpg
|
||||
img.write_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 32)
|
||||
parts, _ = build_native_content_parts("x", [str(img)])
|
||||
url = parts[1]["image_url"]["url"]
|
||||
assert url.startswith("data:image/jpeg;base64,")
|
||||
|
||||
def test_mime_inference_webp(self, tmp_path: Path):
|
||||
# Real WEBP bytes (RIFF....WEBP): sniffing now wins over suffix.
|
||||
img = tmp_path / "pic.webp"
|
||||
img.write_bytes(_png_bytes())
|
||||
img.write_bytes(b"RIFF\x24\x00\x00\x00WEBPVP8 " + b"\x00" * 32)
|
||||
parts, _ = build_native_content_parts("", [str(img)])
|
||||
url = parts[1]["image_url"]["url"]
|
||||
assert url.startswith("data:image/webp;base64,")
|
||||
|
||||
def test_mime_sniff_overrides_misleading_extension(self, tmp_path: Path):
|
||||
"""Discord-style bug: file is named .webp but contains PNG bytes.
|
||||
Anthropic rejects on MIME mismatch (HTTP 400) so we MUST sniff.
|
||||
Regression guard for the user-reported Discord PNG-as-WEBP failure.
|
||||
"""
|
||||
img = tmp_path / "discord_cached.webp"
|
||||
img.write_bytes(_png_bytes()) # bytes are PNG, suffix lies
|
||||
parts, _ = build_native_content_parts("", [str(img)])
|
||||
url = parts[1]["image_url"]["url"]
|
||||
assert url.startswith("data:image/png;base64,"), (
|
||||
f"Expected MIME sniffing to detect PNG bytes regardless of .webp suffix, got: {url[:60]}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Oversize handling ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -223,6 +223,13 @@ CAPS_REGISTRY = {
|
||||
"tool_call": True,
|
||||
"limit": {"context": 32000, "output": 8192},
|
||||
},
|
||||
"text-only-with-stale-attachment": {
|
||||
"id": "text-only-with-stale-attachment",
|
||||
"attachment": True,
|
||||
"tool_call": True,
|
||||
"modalities": {"input": ["text"]},
|
||||
"limit": {"context": 128000, "output": 8192},
|
||||
},
|
||||
},
|
||||
},
|
||||
"anthropic": {
|
||||
@@ -243,7 +250,7 @@ class TestGetModelCapabilities:
|
||||
"""Tests for get_model_capabilities vision detection."""
|
||||
|
||||
def test_vision_from_attachment_flag(self):
|
||||
"""Models with attachment=True should report supports_vision=True."""
|
||||
"""Models with attachment=True and no modalities should report supports_vision=True."""
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
|
||||
caps = get_model_capabilities("anthropic", "claude-sonnet-4")
|
||||
assert caps is not None
|
||||
@@ -257,6 +264,13 @@ class TestGetModelCapabilities:
|
||||
assert caps is not None
|
||||
assert caps.supports_vision is True
|
||||
|
||||
def test_text_only_modalities_override_stale_attachment_flag(self):
|
||||
"""Text-only modalities must win over stale attachment=True metadata."""
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
|
||||
caps = get_model_capabilities("google", "text-only-with-stale-attachment")
|
||||
assert caps is not None
|
||||
assert caps.supports_vision is False
|
||||
|
||||
def test_no_vision_without_attachment_or_modalities(self):
|
||||
"""Models with neither attachment nor image modality should be non-vision."""
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
|
||||
|
||||
@@ -177,6 +177,137 @@ class TestScanSkillCommands:
|
||||
assert "/telegram-only" not in telegram_again
|
||||
assert "/discord-only" in telegram_again
|
||||
|
||||
def test_get_skill_commands_rescans_when_session_platform_changes(self, tmp_path):
|
||||
"""``HERMES_SESSION_PLATFORM`` from the gateway session context must
|
||||
also trigger a rescan, not just ``HERMES_PLATFORM`` (#14536).
|
||||
|
||||
Exercises the real ContextVar path: the gateway sets the active
|
||||
adapter via ``set_session_vars(platform=...)`` and the resolver
|
||||
reads it via ``get_session_env``. Setting ``HERMES_SESSION_PLATFORM``
|
||||
in ``os.environ`` would only test ``get_session_env``'s legacy
|
||||
env-var fallback — a regression that swapped ``get_session_env``
|
||||
for plain ``os.getenv`` would still pass while breaking concurrent
|
||||
gateway sessions, which is the bug the ContextVar plumbing exists
|
||||
to prevent in the first place.
|
||||
"""
|
||||
import agent.skill_commands as sc_mod
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from gateway.session_context import (
|
||||
clear_session_vars,
|
||||
get_session_env,
|
||||
set_session_vars,
|
||||
)
|
||||
|
||||
def _disabled_skills():
|
||||
platform = (
|
||||
os.getenv("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
)
|
||||
if platform == "telegram":
|
||||
return {"telegram-only"}
|
||||
if platform == "discord":
|
||||
return {"discord-only"}
|
||||
return set()
|
||||
|
||||
with (
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||
patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills),
|
||||
patch.object(sc_mod, "_skill_commands", {}),
|
||||
patch.object(sc_mod, "_skill_commands_platform", None),
|
||||
):
|
||||
_make_skill(tmp_path, "shared")
|
||||
_make_skill(tmp_path, "telegram-only")
|
||||
_make_skill(tmp_path, "discord-only")
|
||||
|
||||
# First simulated gateway request: telegram handler.
|
||||
tokens = set_session_vars(platform="telegram")
|
||||
try:
|
||||
telegram_commands = dict(get_skill_commands())
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
assert "/shared" in telegram_commands
|
||||
assert "/discord-only" in telegram_commands
|
||||
assert "/telegram-only" not in telegram_commands
|
||||
|
||||
# Second simulated gateway request: discord handler. The cache
|
||||
# was just populated for telegram; the rescan trigger must fire
|
||||
# off the ContextVar change, not just an env-var change.
|
||||
tokens = set_session_vars(platform="discord")
|
||||
try:
|
||||
discord_commands = dict(get_skill_commands())
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
assert "/shared" in discord_commands
|
||||
assert "/telegram-only" in discord_commands
|
||||
assert "/discord-only" not in discord_commands
|
||||
|
||||
def test_get_skill_commands_rescans_when_leaving_platform_scope(self, tmp_path, monkeypatch):
|
||||
"""Returning to no-platform-scope (CLI / cron / RL) after a gateway
|
||||
session must rescan so the unfiltered view is repopulated (#14536).
|
||||
|
||||
A long-lived process running both gateway sessions and bare CLI
|
||||
invocations would otherwise stay stuck on whichever platform's
|
||||
filter was last applied.
|
||||
"""
|
||||
import agent.skill_commands as sc_mod
|
||||
from agent.skill_commands import get_skill_commands
|
||||
|
||||
def _disabled_skills():
|
||||
if os.getenv("HERMES_PLATFORM") == "telegram":
|
||||
return {"telegram-only"}
|
||||
return set()
|
||||
|
||||
with (
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||
patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills),
|
||||
patch.object(sc_mod, "_skill_commands", {}),
|
||||
patch.object(sc_mod, "_skill_commands_platform", None),
|
||||
):
|
||||
_make_skill(tmp_path, "shared")
|
||||
_make_skill(tmp_path, "telegram-only")
|
||||
|
||||
monkeypatch.setenv("HERMES_PLATFORM", "telegram")
|
||||
telegram_commands = dict(get_skill_commands())
|
||||
assert "/telegram-only" not in telegram_commands
|
||||
|
||||
# Drop back to no platform scope — bare CLI / cron / RL rollouts.
|
||||
monkeypatch.delenv("HERMES_PLATFORM", raising=False)
|
||||
bare_commands = dict(get_skill_commands())
|
||||
|
||||
assert "/telegram-only" in bare_commands
|
||||
assert sc_mod._skill_commands_platform is None
|
||||
|
||||
def test_get_skill_commands_does_not_rescan_when_platform_unchanged(self, tmp_path):
|
||||
"""Same-platform back-to-back calls must hit the cache, not rescan.
|
||||
|
||||
The rescan trigger is *change* in platform scope, not "always
|
||||
re-resolve." A gateway serving consecutive telegram requests must
|
||||
not pay the scan cost for each one.
|
||||
"""
|
||||
import agent.skill_commands as sc_mod
|
||||
from agent.skill_commands import get_skill_commands
|
||||
|
||||
with (
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||
patch.object(sc_mod, "_skill_commands", {}),
|
||||
patch.object(sc_mod, "_skill_commands_platform", None),
|
||||
patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}),
|
||||
):
|
||||
_make_skill(tmp_path, "shared")
|
||||
# Prime the cache.
|
||||
get_skill_commands()
|
||||
# Spy on rescans during the subsequent same-platform calls.
|
||||
with patch(
|
||||
"agent.skill_commands.scan_skill_commands",
|
||||
wraps=sc_mod.scan_skill_commands,
|
||||
) as scan_spy:
|
||||
get_skill_commands()
|
||||
get_skill_commands()
|
||||
get_skill_commands()
|
||||
assert scan_spy.call_count == 0
|
||||
|
||||
|
||||
def test_special_chars_stripped_from_cmd_key(self, tmp_path):
|
||||
"""Skill names with +, /, or other special chars produce clean cmd keys."""
|
||||
|
||||
@@ -142,6 +142,24 @@ class TestBedrockNormalize:
|
||||
assert len(nr.tool_calls) == 1
|
||||
assert nr.tool_calls[0].name == "terminal"
|
||||
|
||||
def test_raw_reasoning_content_response(self, transport):
|
||||
raw = {
|
||||
"output": {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"reasoningContent": {"text": "Let me think..."}},
|
||||
{"text": "Answer."},
|
||||
],
|
||||
}
|
||||
},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15},
|
||||
}
|
||||
nr = transport.normalize_response(raw)
|
||||
assert nr.reasoning == "Let me think..."
|
||||
assert nr.content == "Answer."
|
||||
|
||||
def test_already_normalized_response(self, transport):
|
||||
"""Test normalize_response handles already-normalized SimpleNamespace (from dispatch site)."""
|
||||
pre_normalized = SimpleNamespace(
|
||||
|
||||
Reference in New Issue
Block a user