chore: anchor fresh-start history to upstream

This commit is contained in:
2026-09-16 15:06:54 +08:00
parent 64a9b43261
commit 012c9c6bed
3514 changed files with 48322 additions and 187346 deletions
@@ -1,18 +1,17 @@
"""Tests for the bundled ``openai-codex`` image_gen plugin.
Mirrors ``test_openai_provider.py`` but targets the ChatGPT-OAuth-backed provider that posts to
the Codex backend's native ``images/generations`` / ``images/edits`` endpoints (the route the
official Codex client uses) — no chat host model, no hosted-tool SSE stream (#105398, #107076).
Mirrors ``test_openai_provider.py`` but targets the standalone
Codex/ChatGPT-OAuth-backed provider that uses the Responses
``image_generation`` tool path instead of the ``images.generate`` REST
endpoint.
"""
from __future__ import annotations
import base64
import importlib
import json
from pathlib import Path
import httpx
import pytest
# The plugin directory uses a hyphen, which is not a valid Python identifier
@@ -29,18 +28,14 @@ _PNG_HEX = (
)
def _png_bytes() -> bytes:
return bytes.fromhex(_PNG_HEX)
def _b64_png() -> str:
return base64.b64encode(_png_bytes()).decode()
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("OPENAI_IMAGE_MODEL", raising=False)
yield tmp_path
@@ -51,33 +46,6 @@ def provider(monkeypatch):
return codex_plugin.OpenAICodexImageGenProvider()
@pytest.fixture
def codex_backend(monkeypatch):
"""Route the plugin's ``httpx.Client`` at a fake Codex images backend; returns the request log
and lets a test swap the response via ``state["respond"]``."""
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
state = {"requests": [], "respond": None}
def _default(request):
return httpx.Response(200, json={
"created": 1, "data": [{"b64_json": _b64_png(), "generation_id": "gen_1"}],
"background": "opaque", "output_format": "png", "quality": "low", "size": "1254x1254",
}, headers={"x-codex-imagegen-request-id": "req_abc"}, request=request)
def _handler(request):
state["requests"].append(request)
return (state["respond"] or _default)(request)
real_client = httpx.Client
monkeypatch.setattr(
httpx, "Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler), headers=kwargs.get("headers"),
timeout=kwargs.get("timeout")),
)
return state
# ── Metadata ────────────────────────────────────────────────────────────────
@@ -98,7 +66,7 @@ class TestMetadata:
def test_setup_schema_has_no_required_env_vars(self, provider):
schema = provider.get_setup_schema()
assert schema["env_vars"] == []
assert "hermes auth codex" in schema["post_setup_hint"]
assert schema["badge"] == "free"
# ── Availability ────────────────────────────────────────────────────────────
@@ -106,20 +74,24 @@ class TestMetadata:
class TestAvailability:
def test_unavailable_without_codex_token(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
def test_available_with_codex_token(self, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "tok")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
# Codex plugin is intentionally orthogonal to the API-key plugin —
# the API key alone must NOT make it appear available.
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
# ── Generation ──────────────────────────────────────────────────────────────
# ── Generate ────────────────────────────────────────────────────────────────
class TestGenerate:
@@ -129,100 +101,354 @@ class TestGenerate:
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_text_to_image_posts_generations_with_no_host_model(self, provider, codex_backend, tmp_path):
result = provider.generate("a cat", aspect_ratio="portrait")
def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: {"b64": _b64_png(), "source": "final"})
result = provider.generate("a cat", aspect_ratio="landscape")
assert result["success"] is True
assert result["model"] == "gpt-image-2-medium"
assert result["provider"] == "openai-codex"
assert result["quality"] == "medium"
assert result["pixel_size"] == "1x1"
# Backend-reported values travel separately from what we asked for (#107233).
assert result["reported_quality"] == "low"
assert result["reported_size"] == "1254x1254"
assert result["imagegen_request_id"] == "req_abc"
assert result.get("image_source") == "final"
assert result.get("pixel_size") == "1x1"
saved = Path(result["image"])
assert saved.exists() and saved.parent == tmp_path / "cache" / "images"
assert saved.exists()
assert saved.parent == tmp_path / "cache" / "images"
# Filename prefix differs from the API-key plugin so cache audits can
# tell the two backends apart.
assert saved.name.startswith("openai_codex_")
(request,) = codex_backend["requests"]
assert request.url.path.endswith("/backend-api/codex/images/generations")
assert request.headers["Authorization"] == "Bearer codex-token"
assert request.headers["x-codex-image-turn-id"]
body = json.loads(request.content)
assert body == {
"prompt": "a cat", "model": "gpt-image-2", "n": 1, "quality": "medium",
"size": "1024x1536", "background": "opaque",
}
# The whole point of the native route: nothing about a chat model in the request.
assert not any(key in body for key in ("tools", "input", "instructions"))
def test_codex_stream_request_shape(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
def test_source_images_post_edits_with_inline_data_urls(self, provider, codex_backend, tmp_path):
local = tmp_path / "ref.png"
local.write_bytes(_png_bytes())
data_url = "data:image/png;base64," + _b64_png()
captured = {}
result = provider.generate("edit these", image_url=str(local), reference_image_urls=[data_url])
def _collect(token, *, prompt, size, quality, input_images=None):
captured.update(codex_plugin._build_responses_payload(
prompt=prompt,
size=size,
quality=quality,
input_images=input_images,
))
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
result = provider.generate("a cat", aspect_ratio="portrait")
assert result["success"] is True
assert result["modality"] == "image"
assert result["input_image_count"] == 2
(request,) = codex_backend["requests"]
assert request.url.path.endswith("/backend-api/codex/images/edits")
body = json.loads(request.content)
assert [img["image_url"] for img in body["images"]] == [data_url, data_url]
def test_remote_source_url_is_fetched_and_inlined(self, provider, codex_backend, monkeypatch):
# The backend's own URL downloader 400s on ordinary public images; we fetch client-side.
monkeypatch.setattr(
httpx, "get",
lambda url, **kw: httpx.Response(200, content=_png_bytes(), request=httpx.Request("GET", url)))
assert captured["model"] == "gpt-5.5"
assert captured["store"] is False
assert captured["input"][0]["type"] == "message"
assert captured["input"][0]["role"] == "user"
assert captured["input"][0]["content"][0]["type"] == "input_text"
# Regression for #19505: the Codex backend 400s on every tool_choice
# shape we have for the hosted ``image_generation`` tool, so the
# provider must omit tool_choice entirely and rely on instructions.
assert "tool_choice" not in captured
result = provider.generate("edit", image_url="https://example.com/ref.png")
assert result["success"] is True
body = json.loads(codex_backend["requests"][0].content)
assert body["images"] == [{"image_url": "data:image/png;base64," + _b64_png()}]
tool = captured["tools"][0]
assert tool["type"] == "image_generation"
assert tool["model"] == "gpt-image-2"
assert tool["quality"] == "medium"
assert tool["size"] == "1024x1536"
assert tool["output_format"] == "png"
assert tool["background"] == "opaque"
# Progressive previews disabled: partial frames were being saved as
# finals and presented as smeared/unfinished images.
assert tool["partial_images"] == 0
def test_capabilities_advertise_image_inputs(self, provider):
caps = provider.capabilities()
assert caps["modalities"] == ["text", "image"]
assert caps["max_reference_images"] == 16
def test_rejects_non_image_local_source(self, provider, codex_backend, tmp_path):
def test_rejects_non_image_local_source(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
text_path = tmp_path / "not-image.txt"
text_path.write_text("hello", encoding="utf-8")
text_path.write_text("hello")
result = provider.generate("edit this", image_url=str(text_path))
assert result["success"] is False
assert result["error_type"] == "invalid_image_input"
assert "not a supported image" in result["error"]
assert codex_backend["requests"] == []
def test_http_error_message_surfaces_verbatim_and_bounded(self, provider, codex_backend):
def test_partial_image_event_used_when_done_missing(self):
"""Extractor may surface partial b64 when no final exists (fallback only)."""
payload = {
"type": "response.image_generation_call.partial_image",
"partial_image_b64": _b64_png(),
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
result, partial = codex_plugin._extract_image_candidates(payload)
assert result is None
assert partial == _b64_png()
def test_final_result_wins_over_coexisting_partial_in_same_payload(self):
"""Blind spot that shipped the smear bug: both fields in one payload.
partial_image_b64 must never overwrite image_generation_call.result
when they coexist in the same event tree.
"""
final = _b64_png()
# Distinct non-empty stand-in so equality proves which field won.
partial = "cGFydGlhbC1vbmx5LW5vdC1hLXJlYWwtZmluYWw="
payload = {
"type": "response.output_item.done",
"item": {
"type": "image_generation_call",
"status": "completed",
"result": final,
"partial_image_b64": partial,
},
}
assert codex_plugin._extract_image_b64(payload) == final
result, got_partial = codex_plugin._extract_image_candidates(payload)
assert result == final
assert got_partial == partial
def test_nested_final_wins_over_sibling_partial(self):
payload = {
"type": "response.completed",
"response": {
"output": [{
"type": "image_generation_call",
"status": "completed",
"result": _b64_png(),
}],
},
"partial_image_b64": "cGFydGlhbC1zaWJsaW5n",
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
def test_sse_parser_handles_event_and_data_lines(self):
class _Response:
def iter_lines(self):
return iter([
"event: response.output_item.done",
'data: {"item": {"type": "image_generation_call", "result": "abc"}}',
"",
])
events = list(codex_plugin._iter_sse_json(_Response()))
assert events == [{
"type": "response.output_item.done",
"item": {"type": "image_generation_call", "result": "abc"},
}]
def test_final_response_sweep_recovers_image(self):
"""Completed response output is found by recursive payload scanning."""
payload = {
"type": "response.completed",
"response": {
"output": [{
"type": "image_generation_call",
"status": "completed",
"id": "ig_final",
"result": _b64_png(),
}],
},
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
def test_partial_only_stream_fails_closed_after_retry(self, provider, monkeypatch):
"""Partial-only streams must not return success:true with a smear frame."""
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _partial_only(*args, **kwargs):
calls["n"] += 1
return {"b64": _b64_png(), "source": "partial"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _partial_only)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "incomplete_image"
assert "partial" in result["error"].lower()
# One initial attempt + one content-agnostic retry.
assert calls["n"] == codex_plugin._NONFINAL_RETRIES + 1
def test_empty_stream_retries_then_fails(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _empty(*args, **kwargs):
calls["n"] += 1
return None
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _empty)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
assert calls["n"] == codex_plugin._NONFINAL_RETRIES + 1
def test_partial_then_final_on_retry_succeeds(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _then_final(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return {"b64": _b64_png(), "source": "partial"}
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _then_final)
result = provider.generate("a cat")
assert result["success"] is True
assert result.get("image_source") == "final"
assert calls["n"] == 2
def test_empty_then_final_on_retry_succeeds(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _then_final(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return None
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _then_final)
result = provider.generate("a cat")
assert result["success"] is True
assert result.get("image_source") == "final"
assert calls["n"] == 2
def test_empty_response_returns_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_NONFINAL_RETRIES", 0)
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: None)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_stream_exception_returns_api_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
def _boom(*args, **kwargs):
raise RuntimeError("cloudflare 403")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _boom)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "cloudflare 403" in result["error"]
def test_tool_choice_400_surfaces_verbatim_not_as_capability_error(
self, provider, monkeypatch
):
"""The tool_choice 400 must NOT be reported as an account limitation.
Regression for #19505 / #49008 / #31335: a previous version classified
this exact request-shape rejection as "Image generation is not enabled
for the current Codex account", telling every affected user to abandon
Codex over a bug in our own payload. The wire error must reach the user
unedited so it stays diagnosable.
Drives the REAL httpx boundary (not a mocked ``_collect_image_b64``) so
the classification path is actually exercised — mocking the collector
would skip the code under test entirely.
"""
import httpx
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
body = json.dumps({
"metadata": "x" * 600,
"error": {"message": "Missing required parameter: 'prompt'.", "type": "invalid_request_error"},
"error": {
"message": "Tool choice 'image_generation' not found in 'tools' parameter.",
"type": "invalid_request_error",
"param": "tool_choice",
}
})
codex_backend["respond"] = lambda request: httpx.Response(400, text=body, request=request)
def _handler(request):
return httpx.Response(400, text=body, request=request)
real_client = httpx.Client
monkeypatch.setattr(
httpx,
"Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler),
headers=kwargs.get("headers"),
timeout=kwargs.get("timeout"),
),
)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "HTTP 400" in result["error"]
assert "Missing required parameter: 'prompt'." in result["error"]
assert len(result["error"]) < len(body)
assert "tools' parameter" in result["error"]
# The account-entitlement misdiagnosis must not come back.
assert "not enabled for the current Codex account" not in result["error"]
assert result["error_type"] != "capability_unsupported"
def test_missing_image_data_is_empty_response(self, provider, codex_backend):
codex_backend["respond"] = lambda request: httpx.Response(
200, json={"created": 1, "data": []}, request=request)
result = provider.generate("a cat")
class TestRequestShape:
def test_payload_omits_tool_choice(self):
"""Codex rejects every tool_choice shape for hosted image_generation."""
payload = codex_plugin._build_responses_payload(
prompt="a red circle",
size="1024x1024",
quality="low",
)
assert "tool_choice" not in payload
# The hosted tool itself is still requested, and instructions do the steering.
assert payload["tools"][0]["type"] == "image_generation"
assert payload["instructions"]
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_http_error_body_is_truncated_but_preserved(self, monkeypatch):
"""A large error body is capped at 500 chars and still surfaced."""
import httpx
body = json.dumps({
"metadata": "x" * 600,
"error": {
"message": "Tool choice 'image_generation' not found in 'tools' parameter."
},
})
def _handler(request):
return httpx.Response(400, text=body, request=request)
real_client = httpx.Client
monkeypatch.setattr(
httpx,
"Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler),
headers=kwargs.get("headers"),
timeout=kwargs.get("timeout"),
),
)
with pytest.raises(RuntimeError, match="HTTP 400") as excinfo:
codex_plugin._collect_image_b64(
"codex-token",
prompt="a cat",
size="1024x1024",
quality="low",
)
message = str(excinfo.value)
# Body is capped, but the actionable wire message still reaches the user.
assert "tools' parameter" in message
assert len(message) < len(body)
# ── Plugin entry point ──────────────────────────────────────────────────────
@@ -1,64 +0,0 @@
"""User-editable plugin/auth JSON survives a Windows-editor BOM.
Notepad and PowerShell ``>`` prepend U+FEFF when saving; ``json.loads`` rejects it
("Unexpected UTF-8 BOM") and every loader below degrades to defaults, so a user who
edited mem0.json / honcho.json / hindsight config.json / supermemory.json lost the
whole config with no error (Qwen CLI creds raised ``qwen_auth_read_failed``). Same
class as the auth-store/.env sweep; these were the missed sibling readers.
"""
import json
from pathlib import Path
import pytest
def _write_bom_json(path: Path, payload: dict) -> Path:
path.write_text(json.dumps(payload), encoding="utf-8-sig")
assert path.read_bytes().startswith(b"\xef\xbb\xbf") # the BOM must really be on disk
return path
def _via_shared_reader(p: Path, monkeypatch) -> dict:
from utils import read_json_or_empty # mem0 / hindsight / honcho CLI all read through this
return read_json_or_empty(p)
def _via_supermemory(p: Path, monkeypatch) -> dict:
from plugins.memory.supermemory import _load_supermemory_config
return _load_supermemory_config(str(p.parent))
def _via_hindsight_runtime_deps(p: Path, monkeypatch) -> dict:
import hermes_cli.memory_setup as ms
monkeypatch.setattr(ms, "get_hermes_home", lambda: p.parent.parent) # <home>/hindsight/config.json
deps = ms._provider_pip_dependencies("hindsight", ["hindsight-client"])
return {"workspace": "bom-ws" if "hindsight-all" in deps else None, "container_tag": None}
def _via_honcho_client(p: Path, monkeypatch) -> dict:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config(config_path=p)
return {"workspace": cfg.workspace_id, "container_tag": None}
@pytest.mark.parametrize("filename, loader", [
("mem0.json", _via_shared_reader),
("supermemory.json", _via_supermemory),
("honcho.json", _via_honcho_client),
("hindsight/config.json", _via_hindsight_runtime_deps),
])
def test_plugin_config_json_tolerates_bom(tmp_path, monkeypatch, filename, loader):
target = tmp_path / filename
target.parent.mkdir(parents=True, exist_ok=True)
_write_bom_json(target, {"workspace": "bom-ws", "container_tag": "bom-tag", "enabled": True, "mode": "local_embedded"})
loaded = loader(target, monkeypatch)
assert "bom" in str(loaded.get("workspace") or loaded.get("container_tag"))
def test_qwen_cli_tokens_tolerate_bom(tmp_path, monkeypatch):
import hermes_cli.auth as auth_mod
creds = _write_bom_json(tmp_path / "oauth_creds.json", {"access_token": "tok", "expiry_date": 4102444800000})
monkeypatch.setattr(auth_mod, "_qwen_cli_auth_path", lambda: creds)
assert auth_mod._read_qwen_cli_tokens()["access_token"] == "tok"
@@ -1,77 +0,0 @@
"""ByteRover's ``brv`` child carries the SERVED profile's cloud identity, never the launch profile's.
Regression for #108993: ``_run_brv`` built the child env from raw ``os.environ``, which under
``gateway.multiplex_profiles`` is the default profile's ``.env`` — a secondary's turn curated into
the default's ByteRover cloud account while its local context tree was already profile-scoped."""
from __future__ import annotations
from pathlib import Path
import pytest
from agent import secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from plugins.memory import byterover
@pytest.fixture
def two_profiles(tmp_path, monkeypatch):
root = tmp_path / ".hermes"
prof_b = root / "profiles" / "b"
prof_b.mkdir(parents=True)
(root / ".env").write_text("BRV_API_KEY=DEFAULT-PROFILE-KEY\nHERMES_MODEL=default-model\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setenv("BRV_API_KEY", "DEFAULT-PROFILE-KEY") # the gateway loaded default's .env at boot
monkeypatch.setenv("HERMES_MODEL", "default-model")
monkeypatch.setattr(byterover, "_resolve_brv_path", lambda: "/opt/brv/bin/brv")
captured = {}
def fake_run(cmd, **kwargs):
captured["env"] = kwargs["env"]
class _R:
returncode, stdout, stderr = 0, "", ""
return _R()
monkeypatch.setattr(byterover.subprocess, "run", fake_run)
return root, prof_b, captured
def _served_turn(prof_home: Path, scope: dict):
secret_scope.set_multiplex_active(True)
home_tok = set_hermes_home_override(str(prof_home))
scope_tok = secret_scope.set_secret_scope(scope)
return home_tok, scope_tok
def _end_turn(tokens):
home_tok, scope_tok = tokens
secret_scope.reset_secret_scope(scope_tok)
reset_hermes_home_override(home_tok)
secret_scope.set_multiplex_active(False)
def test_secondary_profile_child_uses_its_own_key_not_defaults(two_profiles):
_root, prof_b, captured = two_profiles
tokens = _served_turn(prof_b, {"BRV_API_KEY": "PROFILE-B-KEY"})
try:
byterover._run_brv(["query", "--", "hello"], cwd=str(prof_b / "byterover"))
finally:
_end_turn(tokens)
env = captured["env"]
assert env["BRV_API_KEY"] == "PROFILE-B-KEY"
assert env["HERMES_HOME"] == str(prof_b)
assert "HERMES_MODEL" not in env # launch profile's .env residue is stripped too
assert env["PATH"].startswith("/opt/brv/bin")
def test_secondary_without_key_gets_no_key_never_defaults(two_profiles):
_root, prof_b, captured = two_profiles
tokens = _served_turn(prof_b, {"OTHER": "x"})
try:
byterover._run_brv(["curate", "--", "note"], cwd=str(prof_b / "byterover"))
finally:
_end_turn(tokens)
assert "BRV_API_KEY" not in captured["env"]
@@ -219,27 +219,3 @@ def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert memory_plugins.load_memory_provider("gatedmem") is not None
def test_unreadable_user_plugin_does_not_abort_memory_discovery(tmp_path, monkeypatch):
"""One mode-000 / ACL-denied ``$HERMES_HOME/plugins/<x>`` must not hide the bundled
providers or its readable siblings from the dashboard / ``hermes memory`` pickers (#111804)."""
plugins_root = tmp_path / "plugins"
_write_provider_dir(plugins_root, "goodmem")
denied = plugins_root / "denied"
denied.mkdir()
(denied / "__init__.py").write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
real_stat = Path.stat # chmod 000 does not bite as root; fail the child's stat instead
def stat(self, *args, **kwargs):
if self.parent == denied:
raise PermissionError(13, "Permission denied", str(self))
return real_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", stat)
names = memory_plugins.list_memory_provider_names()
assert "goodmem" in names
assert "denied" not in names
assert memory_plugins.find_provider_dir("denied") is None
@@ -1540,23 +1540,6 @@ def test_save_config_sets_owner_only_permissions(tmp_path):
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
def test_load_config_corrupt_profile_file_falls_through_to_env(tmp_path, monkeypatch):
"""A corrupt $HERMES_HOME/hindsight/config.json is not the config: the loader falls through
(legacy file, then env) instead of returning an empty, silently-unconfigured mapping."""
home = tmp_path / "home"
(home / "hindsight").mkdir(parents=True)
(home / "hindsight" / "config.json").write_text("{not json", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "nohome")
monkeypatch.setenv("HINDSIGHT_MODE", "local")
monkeypatch.setenv("HINDSIGHT_BANK_ID", "from-env")
cfg = _load_config()
assert cfg["mode"] == "local"
assert cfg["banks"]["hermes"]["bankId"] == "from-env"
class TestLoadSimpleEnv:
def test_bom_first_key_is_recognized(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still parse
@@ -0,0 +1,310 @@
"""Real SQLite source-linked tools, isolated from every user's memory store."""
import json
import pytest
from hermes_constants import get_hermes_home
from hermes_state import SessionDB
from plugins.memory.holographic import HolographicMemoryProvider
@pytest.fixture
def project_env(tmp_path):
home = get_hermes_home()
db = SessionDB(home / "state.db")
roots = {name: tmp_path / name for name in ("alpha", "beta")}
for root in roots.values():
root.mkdir()
for sid, project in (("current", "alpha"), ("source", "alpha"), ("other", "beta")):
db.create_session(sid, source="desktop", cwd=str(roots[project]))
mid = db.append_message("source", role="user", content='Use "Atlas" for deployment')
other = db.append_message("other", role="user", content="FOREIGN_PRIVATE deployment")
provider = HolographicMemoryProvider({"project_scoped": True, "hrr_dim": 64,
"db_path": str(tmp_path / "forbidden-global.db")})
provider.initialize("current")
yield provider, db, mid, other, roots
provider.shutdown()
db.close()
def call(provider, action, **args):
return json.loads(provider.handle_tool_call("fact_store", {"action": action, **args}))
def source(mid, sid="source", **extra):
return {"session_id": sid, "message_id": mid, **extra}
def test_source_linked_tool_roundtrip_and_revocation(project_env):
provider, db, mid, other, roots = project_env
rejected = call(provider, "add", content="unlinked candidate")
assert rejected["success"] is False
assert rejected["status"] == "sources_required"
added = call(provider, "add", content='"Atlas" deployment candidate', sources=[source(mid)])
assert added["success"] and added["status"] == "candidate"
assert added["identity"]["current_session_id"] == "current"
fact_id = added["fact_id"]
for query in ({"action": "list"}, {"action": "search", "query": "Atlas"},
{"action": "probe", "entity": "Atlas"}, {"action": "related", "entity": "Atlas"},
{"action": "reason", "entities": ["Atlas"]}):
result = call(provider, **query)
rows = result.get("facts", result.get("results"))
assert result["success"] and any(r["fact_id"] == fact_id for r in rows)
assert all(r["assertion"] == "candidate_not_confirmed" and r["sources"] for r in rows)
denied = call(provider, "add", content="FOREIGN_PRIVATE", sources=[source(other, "other")])
assert not denied["success"]
assert "FOREIGN_PRIVATE" not in json.dumps(denied)
assert not (roots["alpha"].parent / "forbidden-global.db").exists()
assert not (get_hermes_home() / "memory_store.db").exists()
db._conn.execute("UPDATE messages SET content=? WHERE id=?", ("new deployment source", mid))
db._conn.commit()
for query in ({"action": "list"}, {"action": "search", "query": "Atlas"},
{"action": "probe", "entity": "Atlas"}, {"action": "related", "entity": "Atlas"},
{"action": "reason", "entities": ["Atlas"]}, {"action": "contradict"}):
result = call(provider, **query)
assert result["success"]
assert "Atlas" not in json.dumps(result)
fresh = call(provider, "add", content="new candidate", sources=[source(mid)])
assert fresh["success"]
db._conn.execute("DELETE FROM messages WHERE id=?", (mid,))
db._conn.commit()
assert call(provider, "list")["facts"] == []
def test_project_switch_feedback_and_unlinked_hooks(project_env):
provider, db, mid, other, roots = project_env
first = call(provider, "add", content="alpha candidate", sources=[source(mid)])
alpha_path = provider._store.db_path
feedback = json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_ref": first["fact_ref"]}))
assert feedback["success"] and feedback["new_trust"] > feedback["old_trust"]
assert feedback["assertion"] == "candidate_not_confirmed"
# Updating current-session ownership must invalidate a live provider's cached store.
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["beta"]),))
db._conn.commit()
assert call(provider, "list")["facts"] == []
assert provider._store.db_path != alpha_path
denied = json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_ref": first["fact_ref"]}))
assert not denied["success"]
second = call(provider, "add", content="beta candidate", sources=[source(other, "other")])
assert second["success"]
provider._config["auto_extract"] = True
provider.on_memory_write("add", "user", "HOOK_PRIVATE")
provider.on_session_end([{"role": "user", "content": "I prefer HOOK_PRIVATE"}])
assert provider.prefetch("beta", session_id="other") == ""
assert "HOOK_PRIVATE" not in json.dumps(call(provider, "list"))
db._conn.execute("UPDATE sessions SET cwd=NULL WHERE id='current'")
db._conn.commit()
assert not call(provider, "list")["success"]
assert not call(provider, "add", content="no project", sources=[source(other, "other")])["success"]
assert not (get_hermes_home() / "memory_store.db").exists()
def test_source_safety_atomicity_and_legacy_contract(project_env, tmp_path, monkeypatch):
provider, db, mid, other, roots = project_env
for kwargs in ({"content": "password=hunter2"}, {"role": "tool", "content": "tool text"},
{"content": "hidden source", "display_kind": "hidden"},
{"content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,PRIVATE"}}]}):
role = kwargs.pop("role", "user")
unsafe = db.append_message("source", role=role, **kwargs)
assert not call(provider, "add", content="unsafe candidate", sources=[source(unsafe)])["success"]
for ref in (source(mid, content_hash="0" * 64), source(str(mid)), source(mid, content_hash="BAD")):
assert not call(provider, "add", content="invalid link", sources=[ref])["success"]
added = call(provider, "add", content="safe candidate", sources=[source(mid)])
assert added["success"]
assert not call(provider, "update", fact_ref=added["fact_ref"], content="edited without source")["success"]
updated = call(provider, "update", fact_ref=added["fact_ref"], content="updated candidate", sources=[source(mid)])
assert updated["success"] and updated["updated"]
store = provider._store
store._conn.execute("""CREATE TRIGGER reject_source BEFORE INSERT ON fact_sources
BEGIN SELECT RAISE(ABORT, 'source mapping failed'); END""")
assert not call(provider, "add", content="atomic rollback candidate", sources=[source(mid)])["success"]
assert not store._conn.execute("SELECT 1 FROM facts WHERE content='atomic rollback candidate'").fetchone()
store._conn.execute("DROP TRIGGER reject_source")
unlinked = store.add_fact("unlinked internal candidate")
assert "unlinked internal" not in json.dumps(call(provider, "list"))
assert not json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_id": unlinked}))["success"]
from tools.session_search_project import ProjectRecall
original = ProjectRecall.response
def revoke_before_response(recall, **payload):
db._conn.execute("UPDATE messages SET active=0,compacted=0 WHERE id=?", (mid,))
db._conn.commit()
return original(recall, **payload)
with monkeypatch.context() as patcher:
patcher.setattr(ProjectRecall, "response", revoke_before_response)
race = call(provider, "add", content="raced candidate", sources=[source(mid)])
assert not race["success"] and race["status"] == "source_revoked"
assert not store._conn.execute("SELECT 1 FROM facts WHERE content='raced candidate'").fetchone()
scoped_schema = provider.get_tool_schemas()[0]
assert "candidate" in scoped_schema["description"].lower()
assert "sources" in scoped_schema["parameters"]["properties"]
legacy = HolographicMemoryProvider({"project_scoped": "false", "db_path": str(tmp_path / "legacy.db"), "hrr_dim": 64})
try:
legacy.initialize("legacy")
result = call(legacy, "add", content="legacy source free")
assert result["status"] == "added"
assert call(legacy, "list")["facts"][0]["content"] == "legacy source free"
legacy.on_memory_write("add", "user", "legacy mirror")
assert "legacy mirror" in json.dumps(call(legacy, "list"))
assert "legacy source free" in legacy.prefetch("legacy")
assert legacy.get_tool_schemas()[0]["description"] != scoped_schema["description"]
finally:
legacy.shutdown()
def test_invalid_feedback_scope_race_and_remove(project_env, monkeypatch):
provider, db, mid, other, roots = project_env
added = call(provider, "add", content="stable candidate", sources=[source(mid)])
before = call(provider, "list")["facts"][0]["trust_score"]
denied = json.loads(provider.handle_tool_call("fact_feedback", {"action": "bogus", "fact_ref": added["fact_ref"]}))
assert not denied["success"] and denied["status"] == "invalid_request"
assert call(provider, "list")["facts"][0]["trust_score"] == before
from tools.session_search_project import ProjectRecall
original = ProjectRecall.response
def move_before_response(recall, **payload):
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["beta"]),))
db._conn.commit()
return original(recall, **payload)
store = provider._store
with monkeypatch.context() as patcher:
patcher.setattr(ProjectRecall, "response", move_before_response)
race = call(provider, "add", content="scope raced candidate", sources=[source(mid)])
assert not race["success"] and race["status"] == "scope_changed"
assert not store._conn.execute("SELECT 1 FROM facts WHERE content='scope raced candidate'").fetchone()
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["alpha"]),))
db._conn.commit()
removed = call(provider, "remove", fact_ref=added["fact_ref"])
assert removed["success"] and removed["removed"]
assert call(provider, "list")["facts"] == []
assert store.sources(added["fact_id"]) == []
foreign_home = roots["alpha"].parent / "another-profile"
foreign_home.mkdir()
with monkeypatch.context() as patcher:
patcher.setenv("HERMES_HOME", str(foreign_home))
assert call(provider, "list")["status"] == "profile_changed"
assert not (foreign_home / "state.db").exists()
def test_session_switch_releases_old_store_and_rebinds(project_env):
provider, db, mid, other, roots = project_env
assert call(provider, "add", content="alpha private", sources=[source(mid)])["success"]
old_store = provider._store
config = dict(provider._config)
provider.on_session_switch("other", parent_session_id="current", reset=True)
result = call(provider, "list")
assert result["success"] and result["facts"] == []
assert result["identity"]["current_session_id"] == "other"
assert old_store._entry is None
assert provider._config == config
provider.on_session_switch("current", rewound=True)
assert call(provider, "list")["facts"][0]["content"] == "alpha private"
@pytest.mark.parametrize("owner,other_owner", [("A", "B"), (None, ""), (None, "None")])
def test_same_project_same_content_is_principal_isolated(project_env, owner, other_owner):
provider, db, mid, other, roots = project_env
db._conn.execute("UPDATE sessions SET user_id=? WHERE id IN ('current','source')", (owner,))
db._conn.commit()
db.create_session("user-b", source="desktop", cwd=str(roots["alpha"]), user_id=other_owner)
b_mid = db.append_message("user-b", role="user", content="B source")
a = call(provider, "add", content="same candidate", tags="A_PRIVATE", sources=[source(mid)])
assert a["success"]
a_path = provider._store.db_path
b_provider = HolographicMemoryProvider({"project_scoped": True, "hrr_dim": 64})
try:
b_provider.initialize("user-b")
b = call(b_provider, "add", content="same candidate", tags="B_TAG", sources=[source(b_mid, "user-b")])
assert b["success"]
b_rows = call(b_provider, "list")
assert "A_PRIVATE" not in json.dumps(b_rows)
assert b_rows["facts"][0]["tags"] == "B_TAG"
assert b_provider._store.db_path != a_path
a_rows = call(provider, "list")["facts"]
assert len(a_rows) == 1 and a_rows[0]["tags"] == "A_PRIVATE"
assert a_rows[0]["sources"][0]["session_id"] == "source"
assert not call(b_provider, "add", content="foreign source", sources=[source(mid)])["success"]
assert not call(b_provider, "remove", fact_ref=a["fact_ref"], fact_id=b["fact_id"])["success"]
assert call(b_provider, "list")["facts"][0]["fact_ref"] == b["fact_ref"]
finally:
b_provider.shutdown()
def test_old_project_ref_cannot_mutate_colliding_fact_id(project_env):
provider, db, mid, other, roots = project_env
a = call(provider, "add", content="A candidate", sources=[source(mid)])
db._conn.execute("UPDATE sessions SET cwd=? WHERE id='current'", (str(roots["beta"]),))
db._conn.commit()
b = call(provider, "add", content="B candidate", sources=[source(other, "other")])
assert a["fact_id"] == b["fact_id"] == 1
before = call(provider, "list")["facts"]
old_ref = a.get("fact_ref", str(a["fact_id"]))
for tool, action in (("fact_feedback", "helpful"), ("fact_store", "remove"), ("fact_store", "update")):
result = json.loads(provider.handle_tool_call(tool, {
"action": action, "fact_ref": old_ref, "fact_id": b["fact_id"], "tags": "WRONG"}))
assert not result["success"]
assert result["status"] == "invalid_fact_ref"
assert call(provider, "list")["facts"] == before
def test_fact_ref_version_sources_feedback_and_recreated_store(project_env):
provider, db, mid, other, roots = project_env
a = call(provider, "add", content="version one", sources=[source(mid)])
ref = a["fact_ref"]
assert call(provider, "list")["facts"][0]["fact_ref"] == ref
assert call(provider, "list")["coverage"]["complete"] is False
for tool, action in (("fact_feedback", "helpful"), ("fact_store", "update"), ("fact_store", "remove")):
denied = json.loads(provider.handle_tool_call(tool, {"action": action, "fact_id": a["fact_id"]}))
assert not denied["success"] and denied["status"] == "fact_ref_required"
feedback = json.loads(provider.handle_tool_call("fact_feedback", {"action": "helpful", "fact_ref": ref}))
assert feedback["success"] and feedback["fact_ref"] == ref
updated = call(provider, "update", fact_ref=ref, content="version two", sources=[source(mid)])
assert updated["success"] and updated["fact_ref"] != ref
assert call(provider, "remove", fact_ref=ref)["status"] == "invalid_fact_ref"
mid2 = db.append_message("source", role="user", content="replacement source")
replaced = call(provider, "update", fact_ref=updated["fact_ref"], sources=[source(mid2)])
assert replaced["success"] and replaced["fact_ref"] != updated["fact_ref"]
assert call(provider, "remove", fact_ref=updated["fact_ref"])["status"] == "invalid_fact_ref"
path = provider._store.db_path
provider.shutdown()
provider.initialize("current")
assert call(provider, "list")["facts"][0]["fact_ref"] == replaced["fact_ref"]
provider.shutdown()
path.unlink()
provider.initialize("current")
recreated = call(provider, "add", content="version two", sources=[source(mid2)])
assert recreated["fact_id"] == a["fact_id"]
assert recreated["fact_ref"] != replaced["fact_ref"]
assert call(provider, "remove", fact_ref=replaced["fact_ref"])["status"] == "invalid_fact_ref"
schemas = {s["name"]: s["parameters"] for s in provider.get_tool_schemas()}
assert "fact_ref" in schemas["fact_store"]["properties"]
assert "fact_id" not in schemas["fact_store"]["properties"]
assert "fact_ref" in schemas["fact_feedback"]["required"]
def test_trusted_caller_context_fails_closed_during_queued_switch(project_env):
provider, db, mid, other, roots = project_env
assert call(provider, "add", content="queued old candidate", sources=[source(mid)])["success"]
result = json.loads(provider.handle_tool_call("fact_store", {"action": "list", "session_id": "current"}, session_id="other"))
assert not result["success"] and result["status"] == "session_changed"
assert "queued old candidate" not in json.dumps(result)
# Model arguments cannot authorize a rebind, nor override trusted metadata.
result = json.loads(provider.handle_tool_call("fact_store", {"action": "list", "session_id": "other", "user_id": "B"}, session_id="current"))
assert result["success"] and result["facts"][0]["content"] == "queued old candidate"
provider.on_session_switch("other")
result = json.loads(provider.handle_tool_call("fact_store", {"action": "list"}, session_id="other"))
assert result["success"] and result["facts"] == []
def test_principal_changed_before_commit_rolls_back(project_env, monkeypatch):
provider, db, mid, other, roots = project_env
from tools.session_search_project import ProjectRecall
original = ProjectRecall.response
def change_after_response(recall, **payload):
response = original(recall, **payload)
db._conn.execute("UPDATE sessions SET user_id='B' WHERE id='current'")
db._conn.commit()
return response
with monkeypatch.context() as patcher:
patcher.setattr(ProjectRecall, "response", change_after_response)
result = call(provider, "add", content="principal raced candidate", sources=[source(mid)])
assert not result["success"] and result["status"] == "scope_changed"
assert not provider._store._conn.execute("SELECT 1 FROM facts WHERE content='principal raced candidate'").fetchone()
@@ -1,39 +0,0 @@
"""``save_config`` of the holographic provider writes config.yaml through the canonical writer.
The provider used to ``yaml.dump`` straight over config.yaml, bypassing the config lock, the
managed-mode refusal and the atomic replace. Two contracts pin the canonical path: unrelated
sections survive a provider save, and a managed install refuses the write.
"""
from __future__ import annotations
import yaml
from plugins.memory.holographic import HolographicMemoryProvider
def _provider():
return HolographicMemoryProvider(config={})
def test_save_config_merges_into_existing_yaml(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("model:\n default: keep-me\nmemory:\n provider: holographic\n")
_provider().save_config({"db_path": "custom.db", "hrr_dim": "512"}, str(tmp_path))
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert raw["plugins"]["hermes-memory-store"] == {"db_path": "custom.db", "hrr_dim": "512"}
assert raw["model"]["default"] == "keep-me"
assert raw["memory"]["provider"] == "holographic"
def test_save_config_respects_managed_mode(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
before = "model:\n default: managed\n"
(tmp_path / "config.yaml").write_text(before)
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: True)
monkeypatch.setattr("hermes_cli.config.managed_error", lambda *_a, **_k: None)
_provider().save_config({"db_path": "custom.db"}, str(tmp_path))
assert (tmp_path / "config.yaml").read_text() == before
+2 -23
View File
@@ -335,7 +335,7 @@ class TestMem0V3Config:
class TestMem0ModeSwitch:
def test_oss_mode_initializes_without_platform_key_in_scope(
def test_oss_mode_initializes_without_unscoped_platform_key(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -349,11 +349,7 @@ class TestMem0ModeSwitch:
)
)
# Contract (#99121, restated for fail-loud reads): every production caller is scoped
# (turn/cron/kanban scope installers); an OSS profile whose scope simply lacks MEM0_API_KEY
# must initialize. A scope-LESS multiplex caller is a spawn-site bug and raises instead —
# see test_load_config_fails_closed_without_scope_even_for_identity_settings.
token = secret_scope.set_secret_scope({})
token = secret_scope.set_secret_scope(None)
secret_scope.set_multiplex_active(True)
try:
provider = Mem0MemoryProvider()
@@ -383,23 +379,6 @@ class TestMem0ModeSwitch:
secret_scope.set_multiplex_active(False)
secret_scope.reset_secret_scope(token)
def test_load_config_fails_closed_without_scope_even_for_identity_settings(
self, monkeypatch, tmp_path
):
"""A scope-less multiplex caller is a spawn-site bug: identity/mode reads must surface it,
not degrade to '' and route the turn's memories into the default profile's account."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "mem0.json").write_text(json.dumps({"mode": "oss", "oss": {"vector_store": {"provider": "qdrant"}}}))
token = secret_scope.set_secret_scope(None)
secret_scope.set_multiplex_active(True)
try:
with pytest.raises(secret_scope.UnscopedSecretError):
mem0_plugin._load_config()
finally:
secret_scope.set_multiplex_active(False)
secret_scope.reset_secret_scope(token)
def test_file_api_key_still_overrides_environment(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("MEM0_API_KEY", "env-key")
@@ -19,9 +19,6 @@ _DEFAULT_ENV = {
"OPENVIKING_API_KEY": "ov-default", "OPENVIKING_ACCOUNT": "acct-default", "OPENVIKING_USER": "user-default",
"OPENVIKING_AGENT": "agent-default", "OPENVIKING_ENDPOINT": "http://ov.default",
"HINDSIGHT_BANK_ID": "bank-default", "HINDSIGHT_MODE": "local_external", "HINDSIGHT_API_URL": "http://hs.default",
"HINDSIGHT_RETAIN_TAGS": "tag-default", "HINDSIGHT_RETAIN_OBSERVATION_SCOPES": "per_tag",
"HINDSIGHT_RETAIN_SOURCE": "source-default", "HINDSIGHT_RETAIN_USER_PREFIX": "UserDefault",
"HINDSIGHT_RETAIN_ASSISTANT_PREFIX": "AssistantDefault",
"HERMES_HONCHO_HOST": "host-default", "HONCHO_BASE_URL": "https://honcho.default",
"OPENAI_API_KEY": "sk-default", "OPENAI_BASE_URL": "https://openai.default/v1",
}
@@ -97,58 +94,3 @@ def test_mem0_oss_llm_never_borrows_default_profile_openai_key(secondary_profile
secret_scope.reset_secret_scope(token)
assert llm.client.api_key == "sk-b"
assert str(llm.client.base_url).startswith("https://openai.b/v1")
def test_hindsight_retain_shaping_is_not_re_read_from_the_default_profile_environ(secondary_profile):
"""The provider must retain with ITS OWN shaping, not the default profile's.
``_load_config`` resolves retain shaping through the secret scope, but the values it produces
pass through ``_apply_retain_settings``, whose ``cfg_or_env`` half re-read ``os.environ`` — so
every scoped miss came back as the default profile's tag, scope, source and speaker prefixes.
``metadata.source`` is opt-in by AGENTS.md, and tags are the retrieval partition, so a secondary
profile's memories were both mislabelled and selectable by the default profile's tag filters.
"""
import plugins.memory.hindsight as hindsight
from plugins.memory.hindsight.settings import _DEFAULT_RETAIN_SOURCE
cfg = hindsight._load_config()
assert (cfg["retain_source"], cfg["retain_user_prefix"], cfg["retain_assistant_prefix"]) == (
_DEFAULT_RETAIN_SOURCE, "User", "Assistant")
provider = hindsight.HindsightMemoryProvider()
provider._apply_retain_settings(cfg)
assert provider._retain_tags == []
assert provider._observation_scopes is None
assert provider._retain_source == _DEFAULT_RETAIN_SOURCE
assert (provider._retain_user_prefix, provider._retain_assistant_prefix) == ("User", "Assistant")
# A config.json install never reaches ``_load_config``'s scoped read at all, so the fallback
# inside ``_apply_retain_settings`` is the only gate for it.
provider._apply_retain_settings({})
assert provider._retain_tags == []
assert provider._retain_source == _DEFAULT_RETAIN_SOURCE
# The profile's OWN scoped values still win — this is isolation, not a blindfold.
token = secret_scope.set_secret_scope({"HINDSIGHT_RETAIN_TAGS": "tag-b", "HINDSIGHT_RETAIN_SOURCE": "source-b"})
try:
provider._apply_retain_settings({})
finally:
secret_scope.reset_secret_scope(token)
assert provider._retain_tags == ["tag-b"]
assert provider._retain_source == "source-b"
def test_hindsight_retain_shaping_still_reads_the_process_env_for_a_single_profile(monkeypatch, tmp_path):
"""No multiplexing: the process env IS this profile's own .env, so it must keep being read."""
import plugins.memory.hindsight as hindsight
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HINDSIGHT_RETAIN_TAGS", "solo-tag")
monkeypatch.setenv("HINDSIGHT_RETAIN_SOURCE", "solo-source")
monkeypatch.setenv("HINDSIGHT_RETAIN_USER_PREFIX", "Operator")
provider = hindsight.HindsightMemoryProvider()
provider._apply_retain_settings({})
assert provider._retain_tags == ["solo-tag"]
assert provider._retain_source == "solo-source"
assert provider._retain_user_prefix == "Operator"
@@ -1,111 +0,0 @@
"""Multiplex invariant: every memory provider's background thread runs under the spawner's profile.
Profile isolation is a ContextVar-scoped HERMES_HOME override; a plain ``threading.Thread`` starts
with an EMPTY context, so a provider's prefetch/sync/writer thread would silently resolve the DEFAULT
profile's home (and fail closed on scoped secrets). Each case drives the provider's real spawn path
with a fake backend and asserts the thread saw the parent's home.
"""
from __future__ import annotations
import threading
from unittest.mock import MagicMock
import pytest
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
def _probe_home(seen: dict, key: str = "home"):
def _record(*_args, **_kwargs):
seen[key] = get_hermes_home()
return _record
def _mem0(seen, tmp_path):
from plugins.memory.mem0 import Mem0MemoryProvider
p = Mem0MemoryProvider()
p._backend = MagicMock()
p._config = {"mode": "platform"}
p._add = _probe_home(seen)
p.sync_turn("a long enough user message", "assistant reply")
return [p._sync_thread]
def _retaindb(seen, tmp_path):
import plugins.memory.retaindb as retaindb
p = retaindb.RetainDBMemoryProvider()
p._client = MagicMock()
p._context_overlay = lambda query: {"context": seen.setdefault("home", get_hermes_home()) and "ctx"}
p._client.ask_user.return_value = {"answer": ""}
p._client.get_agent_model.return_value = {}
p.queue_prefetch("what do you know")
return list(p._prefetch_threads)
def _byterover(seen, tmp_path):
import plugins.memory.byterover as byterover
p = byterover.ByteRoverMemoryProvider()
p._curate = _probe_home(seen)
return [p._curate_in_background("content", name="brv-test", what="test")]
def _supermemory(seen, tmp_path):
import plugins.memory.supermemory as supermemory
p = supermemory.SupermemoryMemoryProvider()
p._active = p._write_enabled = True
p._client = MagicMock()
p._client.add_memory = _probe_home(seen)
p.on_memory_write("add", "user", "a fact")
return [p._write_thread]
def _openviking(seen, tmp_path):
import plugins.memory.openviking as openviking
p = openviking.OpenVikingMemoryProvider()
workers: set = set()
p._spawn_tracked("ov-test", _probe_home(seen), threading.Lock(), lambda: workers)
return list(workers)
def _hindsight(seen, tmp_path):
import plugins.memory.hindsight as hindsight
p = hindsight.HindsightMemoryProvider()
p._recall_sync, p._memory_mode, p._auto_recall, p._prefetch_waits_for_retain = False, "hybrid", True, False
p._do_recall = lambda query: (seen.setdefault("home", get_hermes_home()) and "text", 1)
p.queue_prefetch("remember this")
return [p._prefetch_thread]
def _honcho(seen, tmp_path):
from plugins.memory.honcho import HonchoMemoryProvider
return [HonchoMemoryProvider()._spawn_write(_probe_home(seen), "honcho-test", "failed %s")]
_PROVIDERS = {
"mem0": _mem0, "retaindb": _retaindb, "byterover": _byterover, "supermemory": _supermemory,
"openviking": _openviking, "hindsight": _hindsight, "honcho": _honcho,
}
@pytest.mark.parametrize("name", sorted(_PROVIDERS))
def test_provider_background_thread_sees_spawner_profile_home(name, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))
profile_home = tmp_path / "profiles" / "b"
profile_home.mkdir(parents=True)
seen: dict = {}
token = set_hermes_home_override(profile_home)
try:
threads = _PROVIDERS[name](seen, tmp_path)
finally:
reset_hermes_home_override(token)
for t in threads:
if t is not None:
t.join(timeout=10)
assert seen.get("home") == profile_home, f"{name}: background thread resolved {seen.get('home')}"
+75 -242
View File
@@ -3,15 +3,10 @@ import os
import stat
import threading
from datetime import datetime, timezone
import pytest
from plugins.memory.supermemory import (
SupermemoryMemoryProvider,
_MAX_PENDING_BYTES,
_MAX_PENDING_TURNS,
_capture_custom_id,
_clean_text_for_capture,
_format_connection_summary,
_format_prefetch_context,
@@ -21,27 +16,6 @@ from plugins.memory.supermemory import (
)
@pytest.fixture
def frozen_capture_clock(monkeypatch):
"""Pin the capture clock so custom_id expectations cannot straddle a 4h-bucket boundary.
Both the provider's write and the test's expectation call now() separately; near a
bucket edge (hh:59:59.99 → hh:00:00) those two reads can land in different buckets
and fail the equality assert. Freezing the module's datetime makes both reads
identical by construction.
"""
fixed = datetime(2026, 9, 15, 10, 30, 0, tzinfo=timezone.utc)
class _FrozenDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fixed
import plugins.memory.supermemory as sm
monkeypatch.setattr(sm, "datetime", _FrozenDatetime)
return fixed
class FakeClient:
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid",
base_url: str = ""):
@@ -53,14 +27,12 @@ class FakeClient:
self.add_calls = []
self.search_results = []
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
self.fail_add = False
self.ingest_calls = []
self.forgotten_ids = []
self.forget_by_query_response = {"success": True, "message": "Forgot"}
def add_memory(self, content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
if self.fail_add:
raise RuntimeError("boom")
self.add_calls.append({
"content": content,
"metadata": metadata,
@@ -82,6 +54,9 @@ class FakeClient:
def forget_by_query(self, query, *, container_tag=None):
return self.forget_by_query_response
def ingest_conversation(self, session_id, messages, metadata=None):
self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata})
@pytest.fixture
def provider(monkeypatch, tmp_path):
@@ -112,11 +87,6 @@ def test_clean_text_for_capture_strips_injected_context():
assert _clean_text_for_capture(text) == "hello\nworld"
def test_clean_text_for_capture_strips_inline_data_uri():
text = "look: data:image/png;base64,iVBORw0KGgoAAAANSUhEUg== ok"
assert _clean_text_for_capture(text) == "look: [image] ok"
def test_format_prefetch_context_deduplicates_overlap():
result = _format_prefetch_context(
static_facts=["Jordan prefers short answers"],
@@ -142,204 +112,33 @@ def test_prefetch_includes_profile_on_first_turn(provider):
assert "Relevant Memories" in result
def test_capture_custom_id_buckets_by_four_hours():
a = _capture_custom_id("session-1", datetime(2026, 9, 12, 3, 59, tzinfo=timezone.utc))
b = _capture_custom_id("session-1", datetime(2026, 9, 12, 4, 0, tzinfo=timezone.utc))
assert a == "session_1_2026-09-12_b0"
assert b == "session_1_2026-09-12_b1"
assert _capture_custom_id("", datetime(2026, 9, 12, 23, 0, tzinfo=timezone.utc)) == "hermes_2026-09-12_b5"
def test_sync_turn_writes_turn_to_session_document(provider, frozen_capture_clock):
# Every completed turn is appended to one document per session per 4h window.
provider.sync_turn("hello", "hi there", session_id="session-1")
assert len(provider._client.add_calls) == 1
call = provider._client.add_calls[0]
assert call["custom_id"] == _capture_custom_id("session-1")
assert call["content"] == "[role: user]\nhello\n[user:end]\n[role: assistant]\nhi there\n[assistant:end]"
assert call["metadata"]["type"] == "conversation"
assert call["metadata"]["session_id"] == "session-1"
assert call["entity_context"]
assert provider._pending_turns == []
def test_pending_turns_drops_oldest_past_turn_cap(provider):
# A persistently failing service must not grow the retry buffer without bound.
provider._client.fail_add = True
for i in range(_MAX_PENDING_TURNS + 5):
provider.sync_turn(f"turn {i:03d}", f"reply {i:03d}", session_id="session-1")
assert len(provider._pending_turns) == _MAX_PENDING_TURNS
assert provider._pending_turns[0]["user"] == "turn 005" # oldest dropped, newest kept
assert provider._pending_turns[-1]["user"] == f"turn {_MAX_PENDING_TURNS + 4:03d}"
def test_pending_turns_drops_oldest_past_byte_cap(provider):
provider._client.fail_add = True
big = "x" * 20000 # 20 KB sides; 13 pending turns exceed the 256 KiB cap
for i in range(13):
provider.sync_turn(big, big, session_id="session-1")
total = sum(len(t["user"]) + len(t["assistant"]) for t in provider._pending_turns)
assert total <= _MAX_PENDING_BYTES
assert len(provider._pending_turns) < 13 # oldest dropped
def test_write_treats_none_client_result_as_success(provider, monkeypatch):
# A stub returning None (the most common mock idiom) must not be read as failure —
# only a raised exception re-queues the batch.
calls = []
def none_returning_add(content, metadata=None, **kwargs):
calls.append(content)
return None
monkeypatch.setattr(provider._client, "add_memory", none_returning_add)
provider.sync_turn("hello", "hi there", session_id="session-1")
assert len(calls) == 1
assert provider._pending_turns == []
def test_sync_turn_skips_empty_turn(provider):
provider.sync_turn("", "<supermemory-context>x</supermemory-context>", session_id="session-1")
def test_sync_turn_buffers_short_messages(provider):
# Trivial filtering is no longer applied at sync time — every non-empty turn
# is buffered and only the full session is written at session boundaries.
provider.sync_turn("ok", "sure", session_id="session-1")
assert provider._session_turns == [{"user": "ok", "assistant": "sure"}]
assert provider._client.add_calls == []
def test_failed_turn_write_is_retried_at_session_end(provider, frozen_capture_clock):
provider._client.fail_add = True
provider.sync_turn("hello", "hi there", session_id="session-1")
assert provider._client.add_calls == []
assert provider._pending_turns == [{"user": "hello", "assistant": "hi there", "session_id": "session-1"}]
provider._client.fail_add = False
provider.on_session_end([])
assert len(provider._client.add_calls) == 1
call = provider._client.add_calls[0]
assert call["custom_id"] == _capture_custom_id("session-1")
assert "hello" in call["content"]
assert provider._pending_turns == []
def test_pending_turns_are_batched_with_next_turn(provider, frozen_capture_clock):
provider._client.fail_add = True
provider.sync_turn("one", "uno", session_id="session-1")
provider._client.fail_add = False
provider.sync_turn("two", "dos", session_id="session-1")
assert len(provider._client.add_calls) == 1
assert provider._client.add_calls[0]["content"].index("one") < provider._client.add_calls[0]["content"].index("two")
assert provider._pending_turns == []
def test_session_switch_flushes_pending_to_old_session(provider, frozen_capture_clock):
provider._client.fail_add = True
provider.sync_turn("hello", "hi", session_id="session-1")
provider._client.fail_add = False
provider.on_session_switch("session-2", reset=True)
assert provider._client.add_calls[0]["custom_id"] == _capture_custom_id("session-1")
assert provider._session_id == "session-2"
assert provider._pending_turns == []
def test_failed_switch_flush_keeps_old_session_turns_for_later_retry(provider, frozen_capture_clock):
provider._client.fail_add = True
provider.sync_turn("old turn", "old reply", session_id="session-1")
provider.on_session_switch("session-2", reset=True) # flush fails: service unavailable at the boundary
assert provider._session_id == "session-2"
assert provider._pending_turns == [{"user": "old turn", "assistant": "old reply", "session_id": "session-1"}]
provider._client.fail_add = False
provider.sync_turn("new turn", "new reply", session_id="session-2")
calls = provider._client.add_calls
assert [c["custom_id"] for c in calls] == [_capture_custom_id("session-1"), _capture_custom_id("session-2")]
assert calls[0]["metadata"]["session_id"] == "session-1" and "old turn" in calls[0]["content"]
assert calls[1]["metadata"]["session_id"] == "session-2" and "new turn" in calls[1]["content"]
assert provider._pending_turns == []
def test_concurrent_sync_turn_and_session_switch_do_not_duplicate_pending(provider, monkeypatch):
# Worker thread: sync_turn(B) with pending [A] snapshots [A, B] and blocks inside add_memory.
# Caller thread: on_session_switch must wait for that write, not re-send A from a stale snapshot.
provider._client.fail_add = True
provider.sync_turn("A", "a", session_id="session-1")
provider._client.fail_add = False
entered, release = threading.Event(), threading.Event()
real_add = provider._client.add_memory
def slow_add(content, metadata=None, **kwargs):
entered.set()
assert release.wait(timeout=2)
return real_add(content, metadata=metadata, **kwargs)
monkeypatch.setattr(provider._client, "add_memory", slow_add)
worker = threading.Thread(target=provider.sync_turn, args=("B", "b"), kwargs={"session_id": "session-1"})
worker.start()
assert entered.wait(timeout=2)
switcher = threading.Thread(target=provider.on_session_switch, args=("session-2",), kwargs={"reset": True})
switcher.start()
switcher.join(timeout=0.2)
assert switcher.is_alive() # blocked on the capture lock while the worker's write is in flight
release.set()
worker.join(timeout=2); switcher.join(timeout=2)
assert not worker.is_alive() and not switcher.is_alive()
assert len(provider._client.add_calls) == 1
assert provider._client.add_calls[0]["content"].count("[role: user]") == 2 # A and B, once each
assert provider._pending_turns == []
assert provider._session_id == "session-2"
def test_failed_switch_flush_is_retried_at_shutdown(provider, frozen_capture_clock):
provider._client.fail_add = True
provider.sync_turn("old turn", "old reply", session_id="session-1")
provider.on_session_switch("session-2", reset=True)
provider._client.fail_add = False
provider.shutdown()
assert provider._client.add_calls[0]["custom_id"] == _capture_custom_id("session-1")
assert provider._pending_turns == []
def test_shutdown_waits_for_inflight_write_and_does_not_resend(provider, monkeypatch):
"""While a worker thread owns an in-flight write, shutdown's flush blocks on the capture lock
(in production the wait is bounded by the SDK timeout; this test gates it with an Event), and
the pending batch is never re-sent by a second owner. Without the lock, the flusher snapshots
the pending [P] alongside the in-flight A and sends it twice."""
# Pre-seed one FAILED turn so the buffer actually holds a resend candidate.
provider._client.fail_add = True
provider.sync_turn("P", "p", session_id="session-1")
provider._client.fail_add = False
assert len(provider._pending_turns) == 1
entered, release = threading.Event(), threading.Event()
real_add = provider._client.add_memory
def slow_add(content, metadata=None, **kwargs):
entered.set()
assert release.wait(timeout=2)
return real_add(content, metadata=metadata, **kwargs)
monkeypatch.setattr(provider._client, "add_memory", slow_add)
worker = threading.Thread(target=provider.sync_turn, args=("A", "a"), kwargs={"session_id": "session-1"})
worker.start()
assert entered.wait(timeout=2) # worker owns the write and is blocked inside add_memory
flusher = threading.Thread(target=provider.shutdown, name="shutdown-flusher")
flusher.start()
flusher.join(timeout=0.2)
assert flusher.is_alive() # shutdown's flush waits for the capture lock, it does not duplicate the write
release.set()
worker.join(timeout=2)
flusher.join(timeout=2)
assert not worker.is_alive() and not flusher.is_alive()
# The in-flight A and the previously pending P are sent in ONE batch, exactly once each.
assert len(provider._client.add_calls) == 1
assert provider._client.add_calls[0]["content"].count("[role: user]") == 2
assert provider._pending_turns == []
def test_sync_turn_drops_inline_image_payloads(provider, frozen_capture_clock):
blob = "A" * 4096
provider.sync_turn(f"describe this data:image/png;base64,{blob}", "a screenshot", session_id="session-1")
call = provider._client.add_calls[0]
assert "describe this [image]" in call["content"]
assert blob not in json.dumps(call)
def test_on_session_end_ingests_clean_messages(provider):
messages = [
{"role": "system", "content": "skip"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
provider.on_session_end(messages)
assert len(provider._client.ingest_calls) == 1
payload = provider._client.ingest_calls[0]
assert payload["session_id"] == "session-1"
assert payload["messages"] == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
assert payload["metadata"]["type"] == "full_session"
assert payload["metadata"]["session_id"] == "session-1"
assert payload["metadata"]["message_count"] == 2
# Buffer is cleared after a normal session-end ingest.
assert provider._session_turns == []
def test_merge_metadata_stamps_sm_source():
@@ -359,36 +158,31 @@ def test_merge_metadata_stamps_sm_source():
assert "source" not in merged2
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch, frozen_capture_clock):
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch):
started = threading.Event()
release = threading.Event()
def slow_add_memory(content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
if provider._client.fail_add:
raise RuntimeError("boom")
started.set()
release.wait(timeout=1)
provider._client.add_calls.append({
"content": content,
"metadata": metadata,
"entity_context": entity_context,
"custom_id": custom_id,
})
return {"id": "mem_slow"}
monkeypatch.setattr(provider._client, "add_memory", slow_add_memory)
# A failed turn write stays pending; shutdown retries it.
provider._client.fail_add = True
# sync_turn now only buffers — no thread is spawned.
provider.sync_turn(
"Please remember this request in long-term memory",
"Absolutely, I will keep that in long-term memory.",
session_id="session-1",
)
provider._client.fail_add = False
assert provider._sync_thread is None
assert len(provider._pending_turns) == 1
assert len(provider._session_turns) == 1
# on_memory_write still runs on a background thread.
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
@@ -402,10 +196,14 @@ def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch, frozen
assert provider._sync_thread is None
assert provider._write_thread is None
assert provider._prefetch_thread is None
# Explicit memory write and the retried turn both went through.
assert len(provider._client.add_calls) == 2
flushed = next(c for c in provider._client.add_calls if c.get("custom_id") == _capture_custom_id("session-1"))
assert provider._pending_turns == []
# Explicit memory write went through.
assert len(provider._client.add_calls) == 1
# Buffered turn was flushed as a partial full-session ingest.
assert len(provider._client.ingest_calls) == 1
payload = provider._client.ingest_calls[0]
assert payload["session_id"] == "session-1"
assert payload["metadata"]["partial"] is True
assert payload["metadata"]["type"] == "full_session"
def test_store_tool_returns_saved_payload(provider):
@@ -499,7 +297,7 @@ def test_base_url_defaults_to_cloud(monkeypatch, tmp_path):
def test_client_passes_custom_base_url_to_sdk(monkeypatch):
"""SDK client receives the normalized base URL."""
"""SDK operations and raw conversation ingest share one normalized base URL."""
import sys
import types
@@ -527,6 +325,41 @@ def test_client_passes_custom_base_url_to_sdk(monkeypatch):
assert captured["base_url"] == "http://localhost:6767"
@pytest.mark.parametrize(
("base_url", "expected_url"),
[
("https://api.supermemory.ai", "https://api.supermemory.ai/v4/conversations"),
("http://localhost:6767", "http://localhost:6767/v4/conversations"),
],
)
def test_ingest_conversation_uses_client_base_url(monkeypatch, base_url, expected_url):
"""Raw conversation ingest follows the same endpoint as SDK operations."""
from plugins.memory.supermemory import _SupermemoryClient
client = _SupermemoryClient.__new__(_SupermemoryClient)
client._api_key = "test-key"
client._container_tag = "hermes"
client._timeout = 1.0
client._base_url = base_url
captured = {}
class _FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
return _FakeResponse()
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}])
assert captured["url"] == expected_url
# -- Multi-container tests ----------------------------------------------------
@@ -85,30 +85,32 @@ class TestCommandCodeProfileIdentity:
assert commandcode_profile.get_hostname() == "api.commandcode.ai"
class TestCommandCodeReasoningWireControls:
"""DeepSeek V4+ defaults to thinking when ``thinking`` is omitted, so the profile
must put the user's setting on the wire (#95232); other families stay a no-op."""
class TestCommandCodeProfileNoThinkingInterference:
"""Chat completions profile is a no-op for thinking config — it delegates
to the underlying model's provider (DeepSeek, Qwen, etc.) for wire format.
"""
def test_deepseek_disabled_reasoning_sends_thinking_disabled(self, commandcode_profile):
def test_passthrough_no_reasoning_config(self, commandcode_profile):
extra_body, top_level = commandcode_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="deepseek/deepseek-v4-flash",
reasoning_config=None, model="deepseek/deepseek-v4-pro"
)
assert extra_body.get("thinking") == {"type": "disabled"}
assert top_level == {}
# Chat completions profile doesn't inject thinking params — that's
# the DeepSeek provider's job when routed through DeepSeek's own profile.
# When routed through CommandCode, the underlying model API handles it.
assert isinstance(extra_body, dict)
assert isinstance(top_level, dict)
# Default ProviderProfile returns ({}, {}).
def test_deepseek_effort_matches_native_profile_and_others_noop(self, commandcode_profile):
from plugins.model_providers.deepseek import deepseek
def test_passthrough_with_reasoning_config(self, commandcode_profile):
extra_body, top_level = commandcode_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="deepseek/deepseek-v4-pro",
)
assert isinstance(extra_body, dict)
assert isinstance(top_level, dict)
rc = {"enabled": True, "effort": "low"}
expected = deepseek.build_api_kwargs_extras(reasoning_config=rc, model="deepseek-v4.1-flash")
assert expected[1].get("reasoning_effort") == "low" # equality below must not be ({}, {}) == ({}, {})
assert commandcode_profile.build_api_kwargs_extras(
reasoning_config=rc, model="deepseek/deepseek-v4.1-flash"
) == expected
assert commandcode_profile.build_api_kwargs_extras(
reasoning_config=rc, model="Qwen/Qwen3.7-Max"
) == ({}, {})
# ── Anthropic Messages profile ────────────────────────────────────────────────
class TestCommandCodeAnthropicProfileIdentity:
"""Anthropic-compatible profile metadata."""
@@ -355,7 +357,9 @@ class TestCommandCodeBaseUrlOverride:
captured["url"] = req.full_url
return _FakeResp()
with mock_patch.object(cc_mod, "open_credentialed_url", side_effect=fake_urlopen):
with mock_patch.object(
cc_mod.urllib.request, "urlopen", side_effect=fake_urlopen
):
result = commandcode_profile.fetch_models(
api_key="k", base_url=cc_mod._COMMANDCODE_BASE + "/"
)
@@ -1,64 +0,0 @@
"""DeepInfra profile puts the reasoning switch on the wire (#111872).
DeepInfra's OpenAI-compatible endpoint reads one top-level ``reasoning_effort`` field validated
against a gateway-wide enum (``none``..``max``; Hermes-internal ``ultra`` is rejected). The
profile is the ONLY source of that field on the transport's profile path, and the core
``_supports_reasoning_extra_body`` allowlist passes ``supports_reasoning=False`` for this host,
so the profile must emit without gating on it.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def deepinfra_profile():
import model_tools # noqa: F401 (plugin discovery registers the profile)
import providers
profile = providers.get_provider_profile("deepinfra")
assert profile is not None, "deepinfra provider profile must be registered"
return profile
@pytest.mark.parametrize(
"reasoning_config, expected_top_level",
[
({"enabled": True, "effort": "high"}, {"reasoning_effort": "high"}),
({"enabled": True, "effort": "xhigh"}, {"reasoning_effort": "xhigh"}), # native, never folded into max
({"enabled": True, "effort": "ultra"}, {"reasoning_effort": "max"}), # Hermes-internal tier clamps
({"enabled": False}, {"reasoning_effort": "none"}), # the only off switch for default-on models
({"enabled": True, "effort": "none"}, {"reasoning_effort": "none"}),
(None, {}), # nothing requested → keep DeepInfra's per-model default
({"enabled": True}, {}),
({"enabled": True, "effort": "future-tier"}, {}), # unknown level omitted rather than 422
],
)
def test_profile_translates_reasoning_config_to_top_level_effort(deepinfra_profile, reasoning_config, expected_top_level):
extra_body, top_level = deepinfra_profile.build_api_kwargs_extras(
reasoning_config=reasoning_config, supports_reasoning=False, model="deepseek-ai/DeepSeek-V4.1-Flash",
)
assert extra_body == {}
assert top_level == expected_top_level
def test_transport_main_turn_carries_reasoning_effort_without_capability_gate(deepinfra_profile):
"""The main turn builds through ``_build_kwargs_from_profile`` with ``supports_reasoning=False``
(core allowlist excludes this host) — the field must still reach the request."""
from agent.transports.chat_completions import ChatCompletionsTransport
build = ChatCompletionsTransport().build_kwargs
on = build(
model="deepseek-ai/DeepSeek-V4.1-Flash", messages=[{"role": "user", "content": "ping"}], tools=None,
provider_profile=deepinfra_profile, provider_name="deepinfra",
reasoning_config={"enabled": True, "effort": "high"}, supports_reasoning=False,
)
off = build(
model="zai-org/GLM-4.6", messages=[{"role": "user", "content": "ping"}], tools=None,
provider_profile=deepinfra_profile, provider_name="deepinfra",
reasoning_config={"enabled": False}, supports_reasoning=False,
)
assert on["reasoning_effort"] == "high"
assert off["reasoning_effort"] == "none"
assert "reasoning" not in (on.get("extra_body") or {})
@@ -141,7 +141,7 @@ class TestDeepSeekModelGating:
class TestDeepSeekFullKwargsIntegration:
"""End-to-end: the transport's full kwargs match DeepSeek's live wire format.
The live test harness in ``tests/agent/test_deepseek_v4_thinking_live.py``
The live test harness in ``tests/run_agent/test_deepseek_v4_thinking_live.py``
sends ``{"reasoning_effort": "high", "extra_body": {"thinking": {"type":
"enabled"}}}``. Confirm the transport produces that exact shape when wired
through the registered DeepSeek profile.
@@ -181,18 +181,3 @@ class TestMinimaxM3OpenAIReasoningWireShape:
"reasoning_split": True,
"thinking": {"type": "adaptive"},
}
class TestMinimaxOauthAliases:
"""Every ``--provider`` alias the user guide (website/docs/guides/minimax-oauth.md)
promises for ``minimax-oauth`` must resolve through the plugin registry, not only the
CLI alias tables — ``get_provider_profile(agent.provider)`` is what selects the
anthropic_messages wire, extra_body and headers (#107928)."""
def test_each_documented_oauth_alias_resolves_to_minimax_oauth(self):
import model_tools # noqa: F401
import providers
for alias in ("minimax_oauth", "minimax-portal", "minimax-global"):
resolved = providers.get_provider_profile(alias)
assert resolved is not None and resolved.name == "minimax-oauth", alias
@@ -1,45 +0,0 @@
"""Thinking-toggle / reasoning_effort wire invariants shared by the Moonshot- and
DeepSeek-style chat_completions profiles (all route through
``agent.reasoning_effort.thinking_toggle_extras``)."""
import pytest
from agent.reasoning_effort import DEEPSEEK_V4_EFFORTS
from providers import get_provider_profile
REASONING_MATRIX = (
None,
{"enabled": False},
{"enabled": True},
*({"enabled": True, "effort": e} for e in ("low", "medium", "high", "xhigh", "max", "none")),
)
@pytest.mark.parametrize("reasoning_config", REASONING_MATRIX, ids=str)
def test_thinking_toggle_and_effort_never_both_on_moonshot_wire(reasoning_config):
for provider, model in (("kimi-coding", "kimi-k3"), ("opencode-go", "kimi-k2.6"), ("opencode-go", "deepseek-v4-pro")):
extra_body, top_level = get_provider_profile(provider).build_api_kwargs_extras(
reasoning_config=reasoning_config, model=model
)
assert not ("thinking" in extra_body and "reasoning_effort" in top_level), (provider, model, reasoning_config)
# DeepSeek's own API wants the toggle on every request (omitting it defaults thinking on
# and then demands reasoning_content echoes); effort rides alongside only when supported.
extra_body, top_level = get_provider_profile("deepseek").build_api_kwargs_extras(
reasoning_config=reasoning_config, model="deepseek-v4-pro"
)
assert extra_body["thinking"]["type"] in ("enabled", "disabled")
assert top_level.get("reasoning_effort", DEEPSEEK_V4_EFFORTS[0]) in DEEPSEEK_V4_EFFORTS
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
assert (extra_body, top_level) == ({"thinking": {"type": "disabled"}}, {})
@pytest.mark.parametrize("reasoning_config", REASONING_MATRIX, ids=str)
def test_ox_alpha_translation_identical_on_zen_and_free(reasoning_config):
zen = get_provider_profile("opencode-zen").build_api_kwargs_extras(
reasoning_config=reasoning_config, model="x-preview-f-free"
)
free = get_provider_profile("opencode-free").build_api_kwargs_extras(
reasoning_config=reasoning_config, model="x-preview-f-free"
)
assert zen == free
@@ -4,7 +4,7 @@
secret scope is installed, so a Bitwarden-managed ``BUZZ_PRIVATE_KEY`` (only
``BWS_ACCESS_TOKEN`` in ``.env``) was invisible to the bare env read and Buzz
was silently skipped. The fix adds a one-shot ``build_profile_secret_scope``
consultation to the unscoped fallback of ``_shared.get_scoped_secret(external_fallback=True)``.
consultation to the unscoped fallback of ``_get_scoped_secret``.
The key values below are synthesized placeholders (never a usable secret).
"""
@@ -20,12 +20,12 @@ _STUB_KEY = os.environ.get("BUZZ_TEST_STUB_KEY") or ("k" * 8)
@pytest.fixture(autouse=True)
def _reset_unscoped_cache():
import gateway.platforms._shared as shared
import plugins.platforms.buzz.adapter as adapter
prev = shared._UNSCOPED_PROFILE_SECRETS
shared._UNSCOPED_PROFILE_SECRETS = None
prev = adapter._UNSCOPED_PROFILE_SECRETS
adapter._UNSCOPED_PROFILE_SECRETS = None
yield
shared._UNSCOPED_PROFILE_SECRETS = prev
adapter._UNSCOPED_PROFILE_SECRETS = prev
def _install_fake_scope(monkeypatch, secrets):
@@ -1,50 +0,0 @@
"""A present-but-blank ``matrix:`` key in config.yaml means "unset": the env-var fallback must fire
exactly as it does when the key is absent (0.21.2 started seeding blank YAML values into
``config.extra``, which flipped the precedence and silently disabled free-response rooms)."""
import pytest
from gateway.config import PlatformConfig
@pytest.mark.parametrize("blank", ["", " \t "])
def test_blank_yaml_values_fall_through_to_env(monkeypatch, blank):
from plugins.platforms.matrix.adapter import MatrixAdapter, _extra_csv_set, _resolve_max_message_length
monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", "!home:example.org")
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "9000")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
config = PlatformConfig(enabled=True, extra={
"free_response_rooms": blank, "max_message_length": blank, "auto_thread": blank})
assert _extra_csv_set(config, "free_response_rooms", "MATRIX_FREE_RESPONSE_ROOMS") == {"!home:example.org"}
assert _resolve_max_message_length(config) == 9000
assert MatrixAdapter._extra_truthy(config, "auto_thread", "MATRIX_AUTO_THREAD", "true") is False
def test_explicit_env_beats_yaml_and_yaml_beats_default(monkeypatch):
"""Per-profile precedence: explicit scoped env → the profile's YAML → default. A blank env
value is unset (it must not clobber YAML); an explicit empty list is a real "no rooms" value."""
from plugins.platforms.matrix.adapter import MatrixAdapter, _extra_csv_set, _resolve_max_message_length
yaml_config = PlatformConfig(enabled=True, extra={
"free_response_rooms": ["!a:example.org", " !b:example.org "], "max_message_length": 4000,
"auto_thread": False})
monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", "!env:example.org")
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "9000")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "true")
assert _extra_csv_set(yaml_config, "free_response_rooms", "MATRIX_FREE_RESPONSE_ROOMS") == {"!env:example.org"}
assert _resolve_max_message_length(yaml_config) == 9000
assert MatrixAdapter._extra_truthy(yaml_config, "auto_thread", "MATRIX_AUTO_THREAD", "true") is True
for name in ("MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_MAX_MESSAGE_LENGTH", "MATRIX_AUTO_THREAD"):
monkeypatch.setenv(name, " ")
assert _extra_csv_set(yaml_config, "free_response_rooms", "MATRIX_FREE_RESPONSE_ROOMS") == {"!a:example.org", "!b:example.org"}
assert _resolve_max_message_length(yaml_config) == 4000
assert MatrixAdapter._extra_truthy(yaml_config, "auto_thread", "MATRIX_AUTO_THREAD", "true") is False
monkeypatch.delenv("MATRIX_AUTO_THREAD")
assert MatrixAdapter._extra_truthy(PlatformConfig(enabled=True, extra={}), "auto_thread", "MATRIX_AUTO_THREAD", "true") is True
assert _extra_csv_set(PlatformConfig(enabled=True, extra={"free_response_rooms": []}),
"free_response_rooms", "MATRIX_FREE_RESPONSE_ROOMS") == set()
+7 -54
View File
@@ -1,7 +1,8 @@
"""Inbound dispatch + dedup tests for PhotonAdapter.
These exercise the sidecar-event stream and parsing without spawning the
Node sidecar or binding ports.
These bypass the loopback HTTP stream — they call ``_dispatch_inbound`` /
``_on_inbound_line`` / ``_is_duplicate`` directly, exercising the
sidecar-event parsing without spawning the Node sidecar or binding ports.
"""
from __future__ import annotations
@@ -149,60 +150,12 @@ async def test_on_inbound_line_dispatches_and_dedups(
assert captured[0].text == "ping"
@pytest.mark.asyncio
async def test_ndjson_stream_preserves_unicode_line_separators(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
separated = "first\u2028second\u2029third\u0085fourth"
payloads = [
json.dumps(_dm_event(separated, msg_id="unicode-lines"), ensure_ascii=False),
json.dumps(_dm_event("ordinary", msg_id="ordinary-line")),
]
stream = payloads[0] + "\n\n" + payloads[1]
received: List[str] = []
class ChunkedResponse:
status_code = 200
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def aiter_text(self):
for chunk in (stream[:17], stream[17:43], stream[43:]):
yield chunk
async def aiter_lines(self):
for line in stream.splitlines():
yield line
class Client:
def stream(self, *_args, **_kwargs):
return ChunkedResponse()
async def capture_line(line: str) -> None:
received.append(line)
if len(received) == 2:
adapter._inbound_running = False
adapter._http_client = Client()
adapter._inbound_running = True
monkeypatch.setattr(adapter, "_on_inbound_line", capture_line)
await adapter._inbound_loop()
assert received == payloads
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
assert adapter._dedup.is_duplicate("id-1") is False
assert adapter._dedup.is_duplicate("id-1") is True
assert adapter._dedup.is_duplicate("id-2") is False
assert adapter._dedup.is_duplicate("id-1") is True # still dup
assert adapter._is_duplicate("id-1") is False
assert adapter._is_duplicate("id-1") is True
assert adapter._is_duplicate("id-2") is False
assert adapter._is_duplicate("id-1") is True # still dup
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -113,8 +113,7 @@ async def test_send_with_retry_uses_structured_retryable_flag(
assert result.success is True
assert result.message_id == "m-2"
assert calls == 2
# base backoff: base_delay * 2**0 plus up to 1s of jitter
assert len(sleeps) == 1 and 0.25 <= sleeps[0] <= 1.25
assert sleeps == [0.25]
# -- Gap 2: typing-indicator cooldown ---------------------------------------
@@ -1,65 +0,0 @@
"""``hermes config set telegram.allowed_chats '["a","b"]'`` stores a JSON-encoded *string*; every
Telegram allowlist reader must decode it instead of comma-splitting the brackets onto the ids."""
from types import SimpleNamespace
from gateway.config import Platform, PlatformConfig
def _adapter(extra):
from plugins.platforms.telegram.adapter import TelegramAdapter
adapter = object.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM
adapter.config = PlatformConfig(enabled=True, token="***", extra=extra)
adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
return adapter
def _group_msg(chat_id):
return SimpleNamespace(
message_id=42, text="hello", caption=None, entities=[], caption_entities=[],
message_thread_id=None, reply_to_message=None, date=None,
chat=SimpleNamespace(id=chat_id, type="group", title="G", is_forum=False),
from_user=SimpleNamespace(id=111, full_name="A B", first_name="A"),
)
def test_json_string_allowlists_decode_across_every_key():
adapter = _adapter({
"allowed_chats": '["-100","-200"]',
"group_allowed_chats": '["-300"]',
"allowed_topics": '["5"]',
"free_response_chats": '["-400"]',
"free_response_topics": '["-100:3"]',
"ignored_threads": '["7", "9"]',
})
assert adapter._telegram_allowed_chats() == {"-100", "-200"}
assert adapter._telegram_group_allowed_chats() == {"-300"}
assert adapter._telegram_allowed_topics() == {"5"}
assert adapter._telegram_free_response_chats() == {"-400"}
assert adapter._telegram_free_response_topics() == {"-100:3"}
assert adapter._telegram_ignored_threads() == {7, 9}
# The user-visible symptom: a JSON-string allowlist dropped every group message.
gated = _adapter({"allowed_chats": '["-100","-200"]'})
assert gated._should_process_message(_group_msg(-100)) is True
assert gated._should_process_message(_group_msg(-999)) is False
def test_comma_and_malformed_strings_keep_the_legacy_split():
assert _adapter({"allowed_chats": "-100, -200"})._telegram_allowed_chats() == {"-100", "-200"}
assert _adapter({"allowed_chats": ["-100", "-200"]})._telegram_allowed_chats() == {"-100", "-200"}
assert _adapter({"allowed_chats": '["-100", "-200'})._telegram_allowed_chats() == {'["-100"', '"-200'}
def test_runner_side_allow_set_decodes_json_string(monkeypatch):
"""The runner's central gate reads the same env chain (``TELEGRAM_GROUP_ALLOWED_CHATS``
via the YAML bridge) and must not comma-split the brackets onto the ids either."""
from gateway.authz_mixin import _coerce_allow_set
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", '["-100","-200"]')
from gateway.platforms._shared import platform_gate_env
assert _coerce_allow_set(platform_gate_env("TELEGRAM_GROUP_ALLOWED_CHATS")) == {"-100", "-200"}
assert _coerce_allow_set("-100, -200") == {"-100", "-200"}
assert _coerce_allow_set(["-100"]) == {"-100"}
@@ -1,71 +0,0 @@
"""Every platform plugin's ``interactive_setup`` routes its "already configured? Reconfigure?"
gate through ``hermes_cli.setup_platforms.declines_reconfigure``.
The wizards used to hand-roll the gate (env check + info line + ``prompt_yes_no(..., False)``)
with drifting wording and, for two of them, raw ``input()`` loops. Fixes to the shared gate
(wording, default, non-interactive handling) only reach a wizard that actually calls it, so the
test spies on the real primitive: with the primary env var set and the user answering "no", the
wizard must have asked the shared gate about that env var and returned without persisting or
prompting anything else.
"""
import importlib
import pytest
import hermes_cli.setup_platforms as setup_platforms_mod
# (plugin module, primary env var the shared gate keys on)
_WIZARDS = [
("plugins.platforms.buzz.adapter", "BUZZ_RELAY_URL"),
("plugins.platforms.dingtalk.adapter", "DINGTALK_CLIENT_ID"),
("plugins.platforms.discord.adapter", "DISCORD_BOT_TOKEN"),
("plugins.platforms.feishu.adapter", "FEISHU_APP_ID"),
("plugins.platforms.google_chat.adapter", "GOOGLE_CHAT_SUBSCRIPTION_NAME"),
("plugins.platforms.irc.adapter", "IRC_SERVER"),
("plugins.platforms.line.adapter", "LINE_CHANNEL_ACCESS_TOKEN"),
("plugins.platforms.matrix.adapter", "MATRIX_PASSWORD"),
("plugins.platforms.mattermost.adapter", "MATTERMOST_TOKEN"),
("plugins.platforms.raft.adapter", "RAFT_PROFILE"),
("plugins.platforms.simplex.adapter", "SIMPLEX_WS_URL"),
("plugins.platforms.slack.adapter", "SLACK_BOT_TOKEN"),
("plugins.platforms.teams.adapter", "TEAMS_CLIENT_ID"),
("plugins.platforms.wecom.adapter", "WECOM_BOT_ID"),
]
@pytest.mark.parametrize("module_name, env_var", _WIZARDS, ids=[m.split(".")[-2] for m, _ in _WIZARDS])
def test_declining_reconfigure_goes_through_shared_gate(monkeypatch, tmp_path, module_name, env_var):
import hermes_cli.cli_output as cli_output_mod
import hermes_cli.config as config_mod
import hermes_cli.setup as setup_mod
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv(env_var, "already-set")
gated: list[tuple[str, ...]] = []
real_gate = setup_platforms_mod.declines_reconfigure
def _spy_gate(label, question, *env_vars):
gated.append(env_vars)
return real_gate(label, question, *env_vars)
def _no_save(*_a, **_kw):
raise AssertionError("wizard persisted env after the user declined to reconfigure")
def _no_prompt(*_a, **_kw):
raise AssertionError("wizard fell through to its own prompts after the user declined")
# Wizards import the gate lazily from setup_platforms; the gate reads prompt_yes_no through
# hermes_cli.setup. Everything else a wizard could do after declining must not happen.
monkeypatch.setattr(setup_platforms_mod, "declines_reconfigure", _spy_gate)
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False)
for mod in (setup_mod, cli_output_mod):
monkeypatch.setattr(mod, "prompt", _no_prompt)
monkeypatch.setattr(mod, "save_env_value", _no_save, raising=False)
monkeypatch.setattr(config_mod, "save_env_value", _no_save)
importlib.import_module(module_name).interactive_setup()
assert gated and env_var in gated[0], f"{module_name}: gate not routed through declines_reconfigure: {gated}"
-118
View File
@@ -18,7 +18,6 @@ from __future__ import annotations
import asyncio
import json
import socket
import threading
import time
import urllib.error
import urllib.request
@@ -500,123 +499,6 @@ class TestTaskStore:
# Second sweep does nothing (already terminal).
assert store.fail_orphans(timeout_seconds=300) == []
def test_watchdog_preserves_active_requests_and_reply_window(self, monkeypatch):
monkeypatch.setenv("A2A_REPLY_TIMEOUT", "600")
adapter, _base = _make_live_adapter(monkeypatch)
now = time.time()
for task_id, age in (("t-live", 700), ("t-orphan", 700), ("t-within-reply-window", 400)):
adapter.tasks.create(task_id, "c1", "p")
adapter.tasks.set_state(task_id, protocol.STATE_WORKING)
adapter.tasks._tasks[task_id]["created_at"] = now - age
adapter._add_pending("t-live", "c1")
agent = {"slug": "dev", "tenant": "dev", "profile": "dev", "local": False, "timeout": 900}
def fake_forward(*_args):
forwarded_id = next(tid for tid in adapter.tasks._tasks if tid not in {
"t-live", "t-orphan", "t-within-reply-window"
})
adapter.tasks._tasks[forwarded_id]["created_at"] = now - 700
assert adapter._fail_orphans_once() == ["t-orphan"]
return "forwarded reply", protocol.STATE_COMPLETED
monkeypatch.setattr(adapter, "_forward_to_profile", fake_forward)
terminal, pending = adapter._prepare_task(
{"message": protocol.text_message(protocol.ROLE_USER, "hello", context_id="forwarded")},
"peer", agent=agent,
)
assert pending is None
assert adapter.tasks.get(terminal["id"])["state"] == protocol.STATE_COMPLETED
assert adapter.tasks.get("t-live")["state"] == protocol.STATE_WORKING
assert adapter.tasks.get("t-within-reply-window")["state"] == protocol.STATE_WORKING
adapter._pop_pending("t-live")
assert adapter._fail_orphans_once() == ["t-live"]
def test_orphan_timeout_is_bounded_and_disconnect_clears_active_tasks(self, monkeypatch):
from plugins.platforms.a2a import adapter as mod
monkeypatch.setenv("A2A_REPLY_TIMEOUT", "1e18")
assert mod._orphan_timeout() == mod._MAX_ORPHAN_TIMEOUT
adapter, _base = _make_live_adapter(monkeypatch)
adapter._add_pending("t-live", "c1")
asyncio.run(adapter.disconnect())
assert adapter._active_tasks == set()
def test_watchdog_cannot_race_local_finalization(self, monkeypatch):
adapter, _base = _make_live_adapter(monkeypatch)
rec = adapter.tasks.create("t-live", "c1", "peer")
adapter.tasks.set_state("t-live", protocol.STATE_WORKING)
adapter.tasks._tasks["t-live"]["created_at"] = time.time() - 700
future = adapter._add_pending("t-live", "c1")
future.set_result((protocol.STATE_COMPLETED, "reply"))
pending = {
"task_id": "t-live", "context_id": "c1", "peer": "peer",
"future": future, "created_iso": rec["created_iso"], "started": time.time(),
}
original_redact = security.redact_outbound
finalizing = threading.Event()
resume = threading.Event()
result = []
def pause_while_finalizing(reply):
finalizing.set()
assert resume.wait(timeout=1)
return original_redact(reply)
monkeypatch.setattr(security, "redact_outbound", pause_while_finalizing)
thread = threading.Thread(
target=lambda: result.append(adapter._finalize_task(pending, *adapter._await_reply(pending)))
)
thread.start()
assert finalizing.wait(timeout=1)
try:
assert adapter._fail_orphans_once() == []
finally:
resume.set()
thread.join(timeout=1)
assert not thread.is_alive()
assert result == [(protocol.STATE_COMPLETED, "reply")]
assert adapter.tasks.get("t-live")["state"] == protocol.STATE_COMPLETED
def test_stream_disconnect_releases_active_request(self, monkeypatch):
adapter, _base = _make_live_adapter(monkeypatch)
rec = adapter.tasks.create("t-live", "c1", "peer")
adapter.tasks.set_state("t-live", protocol.STATE_WORKING)
pending = {
"task_id": "t-live", "context_id": "c1", "peer": "peer",
"future": adapter._add_pending("t-live", "c1"),
"created_iso": rec["created_iso"], "started": time.time(),
}
monkeypatch.setattr(adapter, "_prepare_task", lambda *_args, **_kwargs: (None, pending))
class BrokenWriter:
def write(self, _chunk):
raise BrokenPipeError
class Handler:
wfile = BrokenWriter()
def send_response(self, _status):
pass
def send_header(self, _name, _value):
pass
def end_headers(self):
pass
adapter._rpc_message_stream(Handler(), 1, {}, "peer")
stored = adapter.tasks.get("t-live")
assert stored["state"] == protocol.STATE_FAILED
assert stored["reply"] == "[client disconnected]"
assert "t-live" not in adapter._pending
assert "t-live" not in adapter._active_tasks
def test_list_newest_first_with_filters(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
+9 -28
View File
@@ -13,7 +13,6 @@ import asyncio
import hashlib
import hmac
import json
import re
import os
import socket
import threading
@@ -177,28 +176,17 @@ class TestInjectionFilter:
class TestOutboundRedaction:
def test_every_canonical_credential_class_is_scrubbed(self):
"""Invariant: redact_outbound masks everything redact_sensitive_text masks. A2A ships text to a
REMOTE peer, so a private subset here silently drops every prefix later added to agent/redact.py.
Corpus: one synthetic token per registered prefix pattern, built from the pattern's literal prefix."""
from agent import redact as R
def test_openai_key_redacted(self):
out = security.redact_outbound("my key is sk-abcdefghij1234567890XYZ")
assert "sk-abcdefghij" not in out
assert "[redacted]" in out
bodies = ("Qq7zP2mX9vLk4nRt8wYb1cDf6gHj3sA0", "QQ7ZP2MX9VLK4NRT", "b-Qq7zP2mX9vLk4nRt8wYb1cDf6gHj3sA0",
".Qq7zP2mX9vLk4nRt8wYb1cDf6gHj3sA0", "1-Qq7zP2mX9vLk4nRt8wYb1cDf6gHj3sA0",
"Qq7zP2mX9vLk4nRt8wYb1cDf6gHj3sA0.Qq7zP2mX9vLk4nRt8wYb1cDf6gHj3sA0")
tokens = []
for pattern in R._PREFIX_PATTERNS + R._plugin_patterns():
prefix = R._extract_literal_prefix(pattern)
token = next((prefix + body for body in bodies if re.fullmatch(pattern, prefix + body)), None)
assert token, f"could not synthesize a token for {pattern!r}"
tokens.append(token)
assert len(tokens) == len(R._PREFIX_PATTERNS) + len(R._plugin_patterns())
for token in tokens:
assert token not in security.redact_outbound(f"peer, here: {token}"), token
def test_github_token_redacted(self):
out = security.redact_outbound("token ghp_0123456789abcdefghij0123")
assert "ghp_0123456789" not in out
def test_bearer_and_email_redacted(self):
out = security.redact_outbound("Authorization: Bearer opaque0123456789abcdef; contact me at alice@example.com")
assert "opaque0123456789abcdef" not in out
def test_email_redacted(self):
out = security.redact_outbound("contact me at alice@example.com")
assert "alice@example.com" not in out
assert "[redacted-email]" in out
@@ -1660,7 +1648,6 @@ _A2A_ENV_VARS = (
"A2A_AGENT_NAME",
"A2A_ADVERTISED_TOOLSETS",
"A2A_AGENT_DESCRIPTION",
"A2A_PUBLIC_URL",
)
@@ -1700,7 +1687,6 @@ def default_profile_env(monkeypatch):
monkeypatch.setenv("A2A_AGENT_NAME", "default-profile-agent")
monkeypatch.setenv("A2A_ADVERTISED_TOOLSETS", "default-only-toolset")
monkeypatch.setenv("A2A_AGENT_DESCRIPTION", "Default profile's own agent.")
monkeypatch.setenv("A2A_PUBLIC_URL", "https://default-profile.example.com/")
class TestMultiplexConstructionScope:
@@ -1723,10 +1709,6 @@ class TestMultiplexConstructionScope:
assert adapter._agents[""]["description"] == (
"Hermes Agent — a general-purpose agent reachable over A2A."
)
# _public_url was captured at construction time via a bare os.getenv, missed by the
# scoped retrofit the sibling fields above already got.
assert adapter._public_url != "https://default-profile.example.com/"
assert adapter._public_url == ""
def test_default_profile_unscoped_keeps_env_precedence(
self, monkeypatch, default_profile_env
@@ -1745,4 +1727,3 @@ class TestMultiplexConstructionScope:
assert adapter.port == 9111
assert adapter.agent_name == "default-profile-agent"
assert adapter._agents[""]["description"] == "Default profile's own agent."
assert adapter._public_url == "https://default-profile.example.com/"
+1 -33
View File
@@ -123,7 +123,7 @@ def _install_fake_session_db(plugin_api, fake_db):
and cannot leak into unrelated tests in the same xdist worker.
"""
fake_module = type(sys)("hermes_state")
fake_module.SessionDB = lambda **_kw: fake_db
fake_module.SessionDB = lambda: fake_db
plugin_api._test_monkeypatch.setitem(sys.modules, "hermes_state", fake_module)
@@ -301,35 +301,3 @@ def test_partial_snapshots_do_not_persist_unlock_timestamps(plugin_api):
"partial scans must not record unlock timestamps — a later session "
"could change whether the badge deserves to be unlocked yet"
)
def test_scan_sessions_never_opens_a_writable_session_db(plugin_api, tmp_path, monkeypatch):
"""The scan is a pure read that runs inside the dashboard process (per background scan,
per /rescan). A writable ``SessionDB()`` there was one more writer connection on the
dashboard's own state.db each time — the same-process handle leak behind the
``N live SessionDB handles`` precursor (#100896). Real store, real open, no fakes."""
import hermes_state
from hermes_state import SessionDB
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
seed = SessionDB(db_path=tmp_path / "state.db")
seed.create_session("s1", source="cli")
seed.append_message("s1", "user", "hello")
seed.close()
writable_opens = []
real_init = SessionDB.__init__
def spy(self, *args, **kwargs):
if not kwargs.get("read_only"):
writable_opens.append(kwargs)
return real_init(self, *args, **kwargs)
monkeypatch.setattr(SessionDB, "__init__", spy)
result = plugin_api.scan_sessions()
assert result.get("error") is None
assert [s["session_id"] for s in result["sessions"]] == ["s1"]
assert writable_opens == [], "the achievements scan must attach read-only"
@@ -1,109 +0,0 @@
"""Concurrency test for get_honcho_client() — the TOCTOU race fix (#24759).
Proves the Honcho client is constructed exactly once even when many threads
race the first call, by stubbing the SDK constructor and counting invocations.
"""
import sys
import threading
import types
import pytest
from plugins.memory.honcho import client as honcho_client
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
reset_honcho_client,
)
@pytest.fixture(autouse=True)
def _reset_singleton():
reset_honcho_client()
yield
reset_honcho_client()
def _install_fake_honcho_sdk(monkeypatch, build_count, build_lock):
"""Make `from honcho import Honcho` resolve to a counting fake."""
class _FakeHoncho:
def __init__(self, **kwargs):
with build_lock:
build_count["n"] += 1
import time
time.sleep(0.01) # widen the race window
self.kwargs = kwargs
fake_mod = types.ModuleType("honcho")
fake_mod.Honcho = _FakeHoncho
monkeypatch.setitem(sys.modules, "honcho", fake_mod)
# Skip the lazy-install path entirely.
monkeypatch.setattr(
honcho_client, "_resolve_optional_float", lambda *a, **k: None, raising=False
)
def test_get_honcho_client_builds_once_under_concurrent_first_call(monkeypatch):
build_count = {"n": 0}
build_lock = threading.Lock()
_install_fake_honcho_sdk(monkeypatch, build_count, build_lock)
config = HonchoClientConfig(
api_key="test-key",
workspace_id="ws",
environment="production",
)
barrier = threading.Barrier(20)
results = []
results_lock = threading.Lock()
def worker():
barrier.wait()
c = get_honcho_client(config)
with results_lock:
results.append(c)
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert build_count["n"] == 1, "Honcho client must be constructed exactly once"
assert len(results) == 20
assert all(r is results[0] for r in results), "all threads share one client"
def test_reset_allows_rebuild(monkeypatch):
build_count = {"n": 0}
build_lock = threading.Lock()
_install_fake_honcho_sdk(monkeypatch, build_count, build_lock)
config = HonchoClientConfig(
api_key="test-key", workspace_id="ws", environment="production"
)
c1 = get_honcho_client(config)
assert build_count["n"] == 1
# Cached: no rebuild.
assert get_honcho_client(config) is c1
assert build_count["n"] == 1
reset_honcho_client()
c2 = get_honcho_client(config)
assert build_count["n"] == 2
assert c2 is not c1
def test_missing_credentials_still_raises_before_build(monkeypatch):
build_count = {"n": 0}
build_lock = threading.Lock()
_install_fake_honcho_sdk(monkeypatch, build_count, build_lock)
bad = HonchoClientConfig(api_key="", base_url="", workspace_id="ws")
with pytest.raises(ValueError):
get_honcho_client(bad)
assert build_count["n"] == 0
-256
View File
@@ -1,256 +0,0 @@
"""Tests for Honcho client configuration."""
import json
import os
import stat
from pathlib import Path
import pytest
from plugins.memory.honcho.client import HonchoClientConfig
from plugins.memory.honcho import HonchoMemoryProvider
class TestHonchoClientConfigAutoEnable:
"""Test auto-enable behavior when API key is present."""
def test_auto_enables_when_api_key_present_no_explicit_enabled(self, tmp_path):
"""When API key exists and enabled is not set, should auto-enable."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
# Note: no "enabled" field
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is True # Auto-enabled because API key exists
def test_respects_explicit_enabled_false(self, tmp_path):
"""When enabled is explicitly False, should stay disabled even with API key."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
"enabled": False, # Explicitly disabled
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is False # Respects explicit setting
def test_disabled_when_no_api_key_and_no_explicit_enabled(self, tmp_path):
"""When no API key and enabled not set, should be disabled."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"workspace": "test",
# No apiKey, no enabled
}))
# Clear env var if set
env_key = os.environ.pop("HONCHO_API_KEY", None)
try:
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key is None
assert cfg.enabled is False # No API key = not enabled
finally:
if env_key:
os.environ["HONCHO_API_KEY"] = env_key
def test_from_env_always_enabled(self, monkeypatch):
"""from_env() should always set enabled=True."""
monkeypatch.setenv("HONCHO_API_KEY", "env-test-key")
cfg = HonchoClientConfig.from_env()
assert cfg.api_key == "env-test-key"
assert cfg.enabled is True
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path, monkeypatch):
"""honcho.json is created atomically with 0o600, not chmod-after-write."""
import utils
calls = []
real_atomic = utils.atomic_json_write
def spy(path, data, **kwargs):
calls.append(kwargs.get("mode"))
return real_atomic(path, data, **kwargs)
monkeypatch.setattr(utils, "atomic_json_write", spy)
provider = HonchoMemoryProvider()
provider.save_config({"api_key": "hc-test-key"}, str(tmp_path))
assert calls == [0o600]
config_file = tmp_path / "honcho.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
class TestLatencyFlagResolution:
def test_host_block_wins(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_BASE_URL', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'apiKey': 'k',
'queryRewrite': False,
'firstTurnBaseWait': 3,
'hosts': {'hermes': {
'queryRewrite': True,
'firstTurnBaseWait': 0,
'firstTurnDialecticWait': 0.5,
}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.query_rewrite is True
assert cfg.first_turn_base_wait == 0.0
assert cfg.first_turn_dialectic_wait == 0.5
def test_per_host_timeout_wins_over_global(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_TIMEOUT', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'apiKey': 'k',
'timeout': 30,
'hosts': {'hermes': {'timeout': 5}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.timeout == 5.0
class TestHonchoBaseUrlSanitize:
def test_clean_base_url_accepted(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_BASE_URL', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'apiKey': 'k',
'baseUrl': 'https://honcho.example.com',
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.base_url == 'https://honcho.example.com'
def test_nonprintable_base_url_dropped(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_BASE_URL', raising=False)
config_path = tmp_path / 'config.json'
bad = 'https://honcho.example.com\x1b'
config_path.write_text(json.dumps({
'apiKey': 'k',
'baseUrl': bad,
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.base_url is None
def test_env_nonprintable_dropped(self, monkeypatch):
monkeypatch.setenv('HONCHO_BASE_URL', 'https://x.example\x1b')
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
cfg = HonchoClientConfig.from_env()
assert cfg.base_url is None
class TestProfileKeyIsolationWarning:
"""#36098 / #66125: a named-profile host block without apiKey does NOT
inherit the default host's key (isolation by design), but the failure
must be loud, not silent."""
def test_keyless_profile_block_warns_when_default_has_key(self, tmp_path, monkeypatch, caplog):
import logging
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'hosts': {
'hermes': {'apiKey': 'shared-key'},
'hermes_coder': {'baseUrl': 'http://192.168.1.50:8000'},
},
}))
with caplog.at_level(logging.WARNING, logger='plugins.memory.honcho.client'):
cfg = HonchoClientConfig.from_global_config(
host='hermes_coder', config_path=config_path,
)
assert cfg.api_key is None # isolation preserved — no silent inheritance
assert any('NOT inherited' in r.message for r in caplog.records)
def test_no_warning_when_profile_block_has_key(self, tmp_path, monkeypatch, caplog):
import logging
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'hosts': {
'hermes': {'apiKey': 'shared-key'},
'hermes_coder': {'apiKey': 'coder-key'},
},
}))
with caplog.at_level(logging.WARNING, logger='plugins.memory.honcho.client'):
cfg = HonchoClientConfig.from_global_config(
host='hermes_coder', config_path=config_path,
)
assert cfg.api_key == 'coder-key'
assert not any('NOT inherited' in r.message for r in caplog.records)
def test_no_warning_for_default_host(self, tmp_path, monkeypatch, caplog):
import logging
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'hosts': {'hermes': {'baseUrl': 'http://localhost:8000'}},
}))
with caplog.at_level(logging.WARNING, logger='plugins.memory.honcho.client'):
HonchoClientConfig.from_global_config(
host='hermes', config_path=config_path,
)
assert not any('NOT inherited' in r.message for r in caplog.records)
def test_save_config_refuses_to_replace_a_file_that_does_not_parse(tmp_path):
"""A corrupt honcho.json must not be rewritten from the new values alone."""
config_path = tmp_path / "honcho.json"
config_path.write_text("{ not json")
with pytest.raises(ValueError):
HonchoMemoryProvider().save_config({"api_key": "hc-test-key"}, str(tmp_path))
assert config_path.read_text() == "{ not json"
def test_save_config_merges_into_a_parseable_file(tmp_path):
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"other": {"apiKey": "keep-me"}}}))
HonchoMemoryProvider().save_config({"api_key": "hc-test-key"}, str(tmp_path))
data = json.loads(config_path.read_text())
assert data["hosts"]["other"]["apiKey"] == "keep-me" and data["api_key"] == "hc-test-key"
def test_save_config_holds_the_refresh_locks_so_a_rotation_survives(tmp_path, monkeypatch):
"""A refresh thread that wants the locks while save_config reads must land after its write, not under it."""
import threading
from plugins.memory.honcho import oauth
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"hermes": {"apiKey": "hch-at-old", "oauth": {"refreshToken": "hch-rt-old"}}}}))
rotated = oauth.OAuthCredential("hch-at-new", "hch-rt-new", 10_000, "hermes-desktop", "http://localhost:8000/oauth/token")
save_read, rotation_done = threading.Event(), threading.Event()
real_read = oauth._read_config_strict
def read_then_wait(path):
raw = real_read(path)
if not save_read.is_set():
save_read.set()
rotation_done.wait(0.5) # the refresh gets this window; only a held lock keeps it out
return raw
def rotate():
save_read.wait(2)
with oauth._refresh_lock, oauth._config_refresh_lock(config_path):
oauth._persist_credential(config_path, "hermes", rotated)
rotation_done.set()
monkeypatch.setattr(oauth, "_read_config_strict", read_then_wait)
thread = threading.Thread(target=rotate)
thread.start()
HonchoMemoryProvider().save_config({"logging": True}, str(tmp_path))
thread.join(2)
assert rotation_done.is_set()
data = json.loads(config_path.read_text())
assert data["hosts"]["hermes"]["apiKey"] == "hch-at-new" and data["logging"] is True
@@ -1,433 +0,0 @@
"""Regression tests for bounded growth of the Honcho local session cache.
Covers the fix for unbounded RSS growth in long-running gateways: prior to
this, ``HonchoSession.messages`` grew forever (never trimmed after a sync),
and ``HonchoSessionManager``'s ``_cache``/``_sessions_cache``/``_context_cache``
had no eviction path short of an explicit ``/new`` reset.
"""
import threading
import time
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from plugins.memory.honcho.session import (
HonchoSession,
HonchoSessionManager,
_PEERS_CACHE_MAX_SIZE,
_SESSION_CACHE_MAX_SIZE,
_SESSION_IDLE_TTL_SECONDS,
_SESSION_MESSAGE_RETENTION,
)
def _session(key="k", honcho_session_id=None):
return HonchoSession(
key=key,
user_peer_id="user",
assistant_peer_id="assistant",
honcho_session_id=honcho_session_id or f"hs-{key}",
)
def _manager():
cfg = SimpleNamespace(
write_frequency="turn",
dialectic_reasoning_level="low",
dialectic_dynamic=True,
dialectic_max_chars=600,
observation_mode="directional",
user_observe_me=True,
user_observe_others=True,
ai_observe_me=True,
ai_observe_others=True,
message_max_chars=25000,
dialectic_max_input_chars=10000,
)
return HonchoSessionManager(honcho=SimpleNamespace(), config=cfg)
def test_trim_synced_messages_caps_total_length():
session = _session()
for i in range(250):
session.add_message("user", f"msg{i}", _synced=True)
for i in range(250, 255):
session.add_message("user", f"msg{i}", _synced=False)
assert len(session.messages) == 255
HonchoSessionManager._trim_synced_messages(session)
assert len(session.messages) == _SESSION_MESSAGE_RETENTION
# oldest synced messages are the ones dropped; the unsynced tail survives intact
assert not any(m.get("_synced") is False for m in session.messages[:-5])
assert all(m.get("_synced") is False for m in session.messages[-5:])
def test_trim_synced_messages_never_drops_an_unsynced_message():
session = _session()
for i in range(300):
session.add_message("user", f"m{i}", _synced=(i != 10))
HonchoSessionManager._trim_synced_messages(session)
contents = [m["content"] for m in session.messages]
assert "m10" in contents
idx = contents.index("m10")
assert session.messages[idx].get("_synced") is False
# trimming stops at the first unsynced message from the front — it does
# not skip past it to keep reducing, so everything from there on survives
assert contents[idx:] == [f"m{i}" for i in range(10, 300)]
def test_trim_synced_messages_is_a_noop_under_the_cap():
session = _session()
for i in range(10):
session.add_message("user", f"m{i}", _synced=True)
HonchoSessionManager._trim_synced_messages(session)
assert len(session.messages) == 10
def test_sweep_idle_sessions_evicts_stale_entries_across_all_caches():
mgr = _manager()
stale = _session(key="stale", honcho_session_id="hs-stale")
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
fresh = _session(key="fresh", honcho_session_id="hs-fresh")
mgr._cache = {"stale": stale, "fresh": fresh}
mgr._sessions_cache = {"hs-stale": object(), "hs-fresh": object()}
mgr._context_cache = {"stale": {"x": 1}, "fresh": {"x": 1}}
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 1
assert set(mgr._cache) == {"fresh"}
assert set(mgr._sessions_cache) == {"hs-fresh"}
assert set(mgr._context_cache) == {"fresh"}
def test_sweep_idle_sessions_keeps_fresh_entries():
mgr = _manager()
fresh = _session(key="fresh")
mgr._cache = {"fresh": fresh}
mgr._sessions_cache = {fresh.honcho_session_id: object()}
mgr._context_cache = {"fresh": {}}
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 0
assert "fresh" in mgr._cache
def test_maybe_sweep_idle_sessions_is_rate_limited():
mgr = _manager()
stale = _session(key="stale")
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"stale": stale}
mgr._last_idle_sweep_ts = time.time() # just swept — this call should no-op
mgr._maybe_sweep_idle_sessions()
assert "stale" in mgr._cache
mgr._last_idle_sweep_ts = 0.0 # force the interval to have elapsed
mgr._maybe_sweep_idle_sessions()
assert "stale" not in mgr._cache
def test_get_or_create_triggers_sweep_without_blocking_on_lock_reentrancy():
"""`_cache_lock` is an RLock specifically so a sweep triggered from inside
`get_or_create` (which also takes the lock) can't deadlock the manager's
own thread. Guard against that regressing silently.
"""
mgr = _manager()
stale = _session(key="stale")
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"stale": stale}
mgr._last_idle_sweep_ts = 0.0
done = threading.Event()
def call_it():
mgr._maybe_sweep_idle_sessions()
done.set()
t = threading.Thread(target=call_it)
t.start()
t.join(timeout=5)
assert done.is_set(), "sweep did not complete — possible deadlock"
assert "stale" not in mgr._cache
# ---------------------------------------------------------------------------
# hard caps, unsynced buffers, peers, and read activity (follows #71463)
# ---------------------------------------------------------------------------
def _fill_sessions(mgr, count, unsynced_keys=()):
for i in range(count):
key = f"k{i}"
session = _session(key=key)
if key in unsynced_keys:
session.add_message("user", "pending", _synced=False)
mgr._cache[key] = session
mgr._sessions_cache[session.honcho_session_id] = object()
mgr._session_observation[session.honcho_session_id] = {"ai_observe_others": False}
mgr._context_cache[key] = {"representation": "r"}
def test_size_cap_evicts_least_recently_used_sessions_with_their_entries():
mgr = _manager()
_fill_sessions(mgr, _SESSION_CACHE_MAX_SIZE + 2)
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert len(mgr._cache) == _SESSION_CACHE_MAX_SIZE
assert "k0" not in mgr._cache and "k1" not in mgr._cache
assert "k2" in mgr._cache
for gone in ("k0", "k1"):
assert f"hs-{gone}" not in mgr._sessions_cache
assert f"hs-{gone}" not in mgr._session_observation
assert gone not in mgr._context_cache
assert "hs-k2" in mgr._sessions_cache and "hs-k2" in mgr._session_observation
def test_size_cap_never_evicts_a_session_with_unsynced_messages():
mgr = _manager()
_fill_sessions(mgr, _SESSION_CACHE_MAX_SIZE + 1, unsynced_keys={"k0"})
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert "k0" in mgr._cache # the only copy until its flush lands
assert "k1" not in mgr._cache
assert len(mgr._cache) == _SESSION_CACHE_MAX_SIZE
def test_idle_sweep_keeps_sessions_with_unsynced_messages():
mgr = _manager()
stale = _session(key="stale")
stale.add_message("user", "pending", _synced=False)
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"stale": stale}
with mgr._cache_lock:
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 0
assert "stale" in mgr._cache
def test_peers_cap_evicts_unreferenced_peers_oldest_first():
mgr = _manager()
live = _session(key="live")
mgr._cache = {"live": live}
mgr._peers_cache[live.user_peer_id] = object()
mgr._peers_cache[live.assistant_peer_id] = object()
for i in range(_PEERS_CACHE_MAX_SIZE):
mgr._peers_cache[f"guest{i}"] = object()
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert len(mgr._peers_cache) == _PEERS_CACHE_MAX_SIZE
assert live.user_peer_id in mgr._peers_cache and live.assistant_peer_id in mgr._peers_cache
assert "guest0" not in mgr._peers_cache and "guest1" not in mgr._peers_cache
assert f"guest{_PEERS_CACHE_MAX_SIZE - 1}" in mgr._peers_cache
def test_recall_read_counts_as_activity_for_the_idle_sweep():
mgr = _manager()
session = _session(key="read-only")
session.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"read-only": session}
assert mgr._cached_session("read-only") is session
with mgr._cache_lock:
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 0
assert "read-only" in mgr._cache
def test_sdk_object_hit_moves_the_key_to_the_recent_end():
mgr = _manager()
mgr._peers_cache = {"a": object(), "b": object()}
mgr._cached_sdk_object(mgr._peers_cache, "a", lambda: None)
assert list(mgr._peers_cache) == ["b", "a"]
def test_get_or_create_stores_observation_flags_with_the_entry_and_eviction_drops_them():
mgr = _manager()
mgr._config.ai_peer = "hermes"
mgr._config.peer_name = "operator" # unnamed peers now fail closed instead of minting a fallback
flags = {"user_observe_me": True, "user_observe_others": True, "ai_observe_me": True, "ai_observe_others": False}
mgr._get_or_create_peer = lambda peer_id: object()
mgr._get_or_create_honcho_session = lambda sid, user, assistant: (object(), [], dict(flags))
session = mgr.get_or_create("cli:one")
assert mgr._session_observation[session.honcho_session_id] == flags
assert mgr._ai_observes_others(session) is False
with mgr._cache_lock:
mgr._evict_session_locked("cli:one", session)
assert session.honcho_session_id not in mgr._session_observation
def test_cap_enforcement_drops_observation_flags_a_post_eviction_flush_stored():
"""A flush that rebuilds an evicted session's SDK session stores its flags again; the next cap pass
must prune that orphan like every other per-session entry, or the dict grows one entry per evicted-then-
flushed session."""
mgr = _manager()
live = _session(key="live")
mgr._cache = {"live": live}
mgr._session_observation = {live.honcho_session_id: {"ai_observe_others": True}, "hs-gone": {"ai_observe_others": False}}
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert set(mgr._session_observation) == {live.honcho_session_id}
def test_flush_does_not_resurrect_an_evicted_session():
mgr = _manager()
session = _session(key="gone")
session.add_message("user", "late", _synced=False)
peer = SimpleNamespace(message=lambda content: content)
mgr._get_or_create_peer = lambda peer_id: peer
mgr._sessions_cache[session.honcho_session_id] = SimpleNamespace(add_messages=lambda messages: None)
assert mgr._flush_session(session) is True
assert "gone" not in mgr._cache
assert all(m["_synced"] for m in session.messages)
def _sdk_session():
return SimpleNamespace(add_messages=lambda messages: None)
def test_flush_that_recreates_the_sdk_session_stores_its_observation_flags():
"""After an eviction the flush path rebuilds the SDK session. The flags it configured must be kept."""
mgr = _manager()
session = _session(key="back")
session.add_message("user", "hello", _synced=False)
flags = {"user_observe_me": False, "user_observe_others": True, "ai_observe_me": True, "ai_observe_others": False}
mgr._get_or_create_peer = lambda peer_id: SimpleNamespace(message=lambda content: content)
mgr._get_or_create_honcho_session = lambda sid, user, assistant: (_sdk_session(), [], flags)
assert mgr._flush_session(session) is True
assert mgr._session_observation[session.honcho_session_id] == flags
def test_cached_sdk_session_returns_the_flags_stored_for_it():
mgr = _manager()
flags = {"user_observe_me": True, "user_observe_others": False, "ai_observe_me": True, "ai_observe_others": True}
sdk = _sdk_session()
mgr._sessions_cache["hs-x"] = sdk
mgr._session_observation["hs-x"] = flags
assert mgr._get_or_create_honcho_session("hs-x", None, None) == (sdk, [], flags)
@pytest.mark.parametrize("synced, kept", [(False, True), (True, False)])
def test_deferred_save_keeps_an_evicted_session_only_while_it_holds_unsynced_messages(synced, kept):
"""write_frequency "session" defers to flush_all(), which only sees cached sessions."""
mgr = _manager()
mgr._write_frequency = "session"
session = _session(key="evicted")
session.add_message("user", "pending", _synced=synced)
mgr.save(session)
assert (mgr._cache.get("evicted") is session) is kept
def test_deferred_save_flushes_inline_when_a_newer_object_owns_the_key():
mgr = _manager()
mgr._write_frequency = "session"
newer = _session(key="k")
mgr._cache["k"] = newer
stale = _session(key="k")
stale.add_message("user", "late", _synced=False)
flushed = []
mgr._flush_session = lambda s: flushed.append(s) or True
mgr.save(stale)
assert flushed == [stale]
assert mgr._cache["k"] is newer
def _failing_then_recording_uploads(mgr, session):
"""The SDK session for ``session`` refuses the first batch; ``restore()`` swaps in one that records."""
uploads = []
def refuse(messages):
raise ConnectionError("upload refused")
mgr._get_or_create_peer = lambda peer_id: SimpleNamespace(message=lambda content: content)
mgr._sessions_cache[session.honcho_session_id] = SimpleNamespace(add_messages=refuse)
def restore():
mgr._sessions_cache[session.honcho_session_id] = SimpleNamespace(add_messages=lambda ms: uploads.extend(ms))
return uploads, restore
def test_failed_turn_save_after_an_eviction_is_retried_by_flush_all():
"""A clean session can be evicted while its caller still holds it. The caller's next save flushes inline in
turn mode, and a failed upload used to leave that object nowhere flush_all() could find it."""
mgr = _manager()
session = _session(key="k")
mgr._cache["k"] = session
with mgr._cache_lock:
mgr._evict_session_locked("k", session)
session.add_message("user", "late")
uploads, restore = _failing_then_recording_uploads(mgr, session)
mgr.save(session)
assert mgr._cache["k"] is session
restore()
mgr.flush_all()
assert uploads == ["late"]
assert session.messages[0]["_synced"] is True
def test_failed_collision_flush_waits_for_flush_all_without_displacing_the_newer_object():
mgr = _manager()
mgr._write_frequency = "session"
newer = _session(key="k")
mgr._cache["k"] = newer
stale = _session(key="k")
stale.add_message("user", "late")
uploads, restore = _failing_then_recording_uploads(mgr, stale)
mgr.save(stale)
assert mgr._cache["k"] is newer
assert mgr._retry_sessions == [stale]
restore()
mgr.flush_all()
assert uploads == ["late"]
assert mgr._retry_sessions == []
assert mgr._cache["k"] is newer
def test_a_retained_session_is_listed_once_across_repeated_failures():
mgr = _manager()
mgr._cache["k"] = _session(key="k")
stale = _session(key="k")
stale.add_message("user", "late")
_failing_then_recording_uploads(mgr, stale)
mgr.save(stale)
mgr.save(stale)
assert mgr._retry_sessions == [stale]
@@ -1,98 +0,0 @@
"""Tests for Honcho session context peer resolution."""
from types import SimpleNamespace
from plugins.memory.honcho.session import HonchoSession, HonchoSessionManager
class _FakeSummary:
content = "summary"
class _FakeContext:
summary = _FakeSummary()
peer_representation = "representation"
peer_card = ["fact"]
messages = []
class _RecordingHonchoSession:
def __init__(self):
self.calls = []
def context(self, **kwargs):
self.calls.append(kwargs)
return _FakeContext()
def _manager_with_cached_session(*, ai_observe_others=True):
cfg = SimpleNamespace(
write_frequency="turn",
dialectic_reasoning_level="low",
dialectic_dynamic=True,
dialectic_max_chars=600,
observation_mode="directional",
user_observe_me=True,
user_observe_others=True,
ai_observe_me=True,
ai_observe_others=ai_observe_others,
message_max_chars=25000,
dialectic_max_input_chars=10000,
)
mgr = HonchoSessionManager(honcho=SimpleNamespace(), config=cfg)
session = HonchoSession(
key="test-session",
user_peer_id="chris",
assistant_peer_id="hermes",
honcho_session_id="test-session",
)
fake_honcho_session = _RecordingHonchoSession()
mgr._cache[session.key] = session
mgr._sessions_cache[session.honcho_session_id] = fake_honcho_session
return mgr, fake_honcho_session
def test_session_context_user_alias_uses_assistant_observer_when_ai_can_observe_others():
mgr, fake = _manager_with_cached_session(ai_observe_others=True)
result = mgr.get_session_context("test-session", peer="user")
assert result["summary"] == "summary"
assert fake.calls == [
{
"summary": True,
"tokens": None,
"peer_target": "chris",
"peer_perspective": "hermes",
}
]
def test_session_context_explicit_user_peer_matches_user_alias():
mgr, fake = _manager_with_cached_session(ai_observe_others=True)
mgr.get_session_context("test-session", peer="chris")
assert fake.calls == [
{
"summary": True,
"tokens": None,
"peer_target": "chris",
"peer_perspective": "hermes",
}
]
def test_session_context_user_alias_uses_user_self_observer_when_ai_cannot_observe_others():
mgr, fake = _manager_with_cached_session(ai_observe_others=False)
mgr.get_session_context("test-session", peer="user")
assert fake.calls == [
{
"summary": True,
"tokens": None,
"peer_target": "chris",
"peer_perspective": "chris",
}
]
@@ -1,615 +0,0 @@
"""Regression tests for Honcho startup fail-open behavior."""
from __future__ import annotations
import json
import threading
import time
from types import SimpleNamespace
import pytest
from plugins.memory.honcho import HonchoMemoryProvider
class _FakeHonchoConfig(SimpleNamespace):
raw: dict = {}
host: str = "hermes"
def resolve_session_name(self, **kwargs):
return "test-session"
def _configured_hybrid_config() -> _FakeHonchoConfig:
return _FakeHonchoConfig(
enabled=True,
api_key=None,
base_url="http://127.0.0.1:8000",
recall_mode="hybrid",
init_on_session_start=False,
injection_frequency="every-turn",
context_cadence=1,
dialectic_cadence=1,
query_rewrite=False,
first_turn_base_wait=3.0,
first_turn_dialectic_wait=2.0,
dialectic_depth=1,
dialectic_depth_levels=None,
reasoning_heuristic=True,
reasoning_level_cap="high",
context_tokens=None,
message_max_chars=25000,
session_strategy="per-directory",
)
def _configured_tools_config(*, init_on_session_start: bool = False) -> _FakeHonchoConfig:
cfg = _configured_hybrid_config()
cfg.recall_mode = "tools"
cfg.init_on_session_start = init_on_session_start
return cfg
def test_stalled_init_only_delays_first_turn_prefetch(monkeypatch):
"""A stalled session init may bound-wait on turn 1 only; every later
prefetch must keep the fail-open contract and return immediately."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
release = threading.Event()
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def stalled_session_init(self, cfg, session_id, **kwargs):
release.wait(timeout=10)
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", stalled_session_init)
provider.initialize("session-1", platform="cli")
provider._FIRST_TURN_BASE_TIMEOUT = 1.0
try:
provider._turn_count = 1
start = time.perf_counter()
assert provider.prefetch("first question") == ""
assert time.perf_counter() - start >= 0.5 # turn 1 waited (bounded)
for turn in (2, 3, 4):
provider._turn_count = turn
start = time.perf_counter()
assert provider.prefetch("follow-up question") == ""
assert time.perf_counter() - start < 0.4 # fail-open, no wait
finally:
release.set()
init_thread = getattr(provider, "_init_thread", None)
if init_thread:
init_thread.join(timeout=1)
def test_honcho_background_init_rechecks_state_after_lock_race():
"""Startup should not spawn/crash if init completes while waiting for lock."""
provider = HonchoMemoryProvider()
provider._config = _configured_hybrid_config()
provider._lazy_init_kwargs = {"platform": "cli"}
provider._lazy_init_session_id = "session-1"
class RacingLock:
def __enter__(self):
provider._session_initialized = True
provider._lazy_init_kwargs = None
return self
def __exit__(self, exc_type, exc, tb):
return False
provider._init_lock = RacingLock()
provider._start_session_init_background()
assert provider._init_thread is None
assert provider._session_initialized is True
def test_first_turn_base_wait_is_shared_by_init_and_context_fetch():
"""Session init and base retrieval share one configured turn-1 deadline."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
cfg.first_turn_base_wait = 0.5
cfg.timeout = None
release_context = threading.Event()
class SlowManager:
def get_prefetch_context(self, session_key, user_message=None):
release_context.wait(timeout=5)
return {"representation": "late"}
def set_context_result(self, session_key, result):
pass
def pop_context_result(self, session_key):
return {}
def finish_init():
time.sleep(0.3)
provider._manager = SlowManager()
provider._session_initialized = True
provider._config = cfg
provider._session_key = "test-session"
provider._recall_mode = "context"
provider._turn_count = 1
provider._last_dialectic_turn = 0
provider._FIRST_TURN_BASE_TIMEOUT = cfg.first_turn_base_wait
provider._init_thread = threading.Thread(target=finish_init, daemon=True)
provider._init_thread.start()
try:
started = time.perf_counter()
assert provider.prefetch("what do you know about me?") == ""
elapsed = time.perf_counter() - started
# Property: prefetch waits for init (0.3s sleep) but is bounded by
# first_turn_base_wait rather than blocking forever on the slow
# context fetch. The old 0.4..0.65 window was 0.25s wide — pure
# scheduler noise on a loaded runner. Lower bound proves the wait
# happened; loose upper bound proves it didn't hang.
assert 0.25 <= elapsed < 2.0
finally:
release_context.set()
provider._init_thread.join(timeout=10)
def test_honcho_sync_turn_waits_for_full_background_startup(monkeypatch):
"""Manager assignment alone is not readiness while background init continues."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
session_created = threading.Event()
migration_started = threading.Event()
release_migration = threading.Event()
get_calls = []
class StartupManager:
def __init__(self, *args, **kwargs):
pass
def get_or_create(self, session_key):
get_calls.append(session_key)
session_created.set()
return SimpleNamespace(messages=[])
def migrate_memory_files(self, session_key, mem_dir):
migration_started.set()
release_migration.wait(timeout=5)
def prefetch_context(self, session_key, user_message=None):
pass
def _flush_session(self, session):
pass
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object())
monkeypatch.setattr("plugins.memory.honcho.session.HonchoSessionManager", StartupManager)
provider.initialize("session-1", platform="cli")
try:
assert session_created.wait(timeout=1)
assert migration_started.wait(timeout=1)
assert provider._manager is not None
assert provider._session_initialized is False
provider.sync_turn("hello", "world")
assert provider._sync_thread is None
assert get_calls == ["test-session"]
finally:
release_migration.set()
init_thread = getattr(provider, "_init_thread", None)
if init_thread:
init_thread.join(timeout=1)
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=1)
assert provider._session_initialized is True
def test_honcho_system_prompt_advertises_active_while_background_init_runs(monkeypatch):
"""Prompt metadata should not require a completed network session."""
provider = HonchoMemoryProvider()
cfg = _configured_hybrid_config()
release = threading.Event()
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def slow_session_init(self, cfg, session_id, **kwargs):
release.wait(timeout=5)
self._session_initialized = True
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", slow_session_init)
provider.initialize("session-1", platform="cli")
try:
prompt = provider.system_prompt_block()
assert "Honcho Memory" in prompt
assert "hybrid mode" in prompt
finally:
release.set()
init_thread = getattr(provider, "_init_thread", None)
if init_thread:
init_thread.join(timeout=1)
def test_honcho_tools_eager_init_failure_does_not_leave_ready_manager(monkeypatch):
"""Failed eager tools startup must not leave hooks seeing a ready session."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
def failing_session_init(self, cfg, session_id, **kwargs):
self._manager = SimpleNamespace()
self._session_key = "test-session"
raise RuntimeError("boom")
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", failing_session_init)
provider.initialize("session-1", platform="cli")
assert provider._session_initialized is False
assert provider._manager is None
background_started = threading.Event()
provider._start_session_init_background = background_started.set
provider.sync_turn("hello", "world")
provider.on_memory_write("add", "user", "prefers safe Honcho startup")
assert provider._sync_thread is None
assert not background_started.is_set()
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
assert "could not be initialized" in result["error"]
assert provider._manager is None
def _init_with_unresolved_peer(monkeypatch, cfg, platform: str = "cli") -> tuple[HonchoMemoryProvider, list[int]]:
"""Provider whose session init fails because no user peer can be named. Returns the attempt log."""
from plugins.memory.honcho.session_peers import HonchoPeerUnresolvedError
provider = HonchoMemoryProvider()
monkeypatch.setattr("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", lambda: cfg)
attempts: list[int] = []
def no_peer(self, cfg, session_id, **kwargs):
attempts.append(1)
raise HonchoPeerUnresolvedError("Honcho has no user peer for session 'x': honcho.json declares no peerName.")
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", no_peer)
provider.initialize("session-1", platform=platform)
if provider._init_thread:
provider._init_thread.join(timeout=5)
return provider, attempts
@pytest.mark.parametrize("platform, present, absent", [
("cli", "hermes honcho peer --user", "Do not suggest peerName"),
("telegram", "Do not suggest peerName", "hermes honcho peer --user"),
])
def test_honcho_unresolved_peer_notices_once_and_stops_retrying(monkeypatch, platform, present, absent):
"""No runtime identity and no peerName: memory stays off for the session, the model hears it once, and
later turns do not re-run init for a config gap that cannot heal (#93326). The notice recommends peerName
only on a local platform: on a multi-user gateway a shared peerName would merge every human onto one peer."""
provider, attempts = _init_with_unresolved_peer(monkeypatch, _configured_hybrid_config(), platform=platform)
assert provider._manager is None
assert provider._can_start_init() is False
notice = provider.prefetch("what did we decide about the schema?")
assert "Honcho memory is off" in notice and "peerName" in notice
assert present in notice and absent not in notice
assert provider.prefetch("second question") == ""
provider.sync_turn("hello", "world")
assert attempts == [1]
@pytest.mark.parametrize("platform, present, absent", [
("cli", "hermes honcho peer --user", "could not be initialized"),
("discord", "supplied no user id", "hermes honcho peer --user"),
])
def test_honcho_unresolved_peer_tool_error_names_the_fix_for_the_platform(monkeypatch, platform, present, absent):
cfg = _configured_tools_config(init_on_session_start=True)
provider, _ = _init_with_unresolved_peer(monkeypatch, cfg, platform=platform)
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
assert "peerName" in result["error"]
assert present in result["error"] and absent not in result["error"]
def test_honcho_tools_lazy_hooks_do_not_prestart_background_init(monkeypatch):
"""tools lazy mode lets the first tool call own session initialization."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=False)
monkeypatch.setattr(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
lambda: cfg,
)
provider.initialize("session-1", platform="cli")
background_started = threading.Event()
provider._start_session_init_background = background_started.set
provider.prefetch("what do you know?")
provider.queue_prefetch("what do you know?")
provider.sync_turn("hello", "world")
provider.on_memory_write("add", "user", "prefers fail-open memory")
assert not background_started.is_set()
assert provider._session_initialized is False
class ToolManager:
def get_peer_card(self, session_key, peer="user"):
return ["ready"]
init_calls = []
def fake_session_init(self, cfg, session_id, **kwargs):
init_calls.append(session_id)
self._manager = ToolManager()
self._session_key = "test-session"
self._session_initialized = True
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", fake_session_init)
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
assert result == {"result": ["ready"]}
assert init_calls == ["session-1"]
assert not background_started.is_set()
# ---------------------------------------------------------------------------
# Write-containment regression tests
# ---------------------------------------------------------------------------
def test_honcho_sync_turn_skips_write_when_save_messages_is_disabled():
"""The resolved write-disable switch must gate an initialized provider."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
cfg.save_messages = False
manager_calls = []
class Manager:
def get_or_create(self, session_key):
manager_calls.append(session_key)
return SimpleNamespace()
provider._config = cfg
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.sync_turn("a genuine user turn", "a genuine assistant reply")
assert provider._sync_thread is None
assert manager_calls == []
def test_honcho_sync_turn_skips_anchored_gateway_notifications():
"""Known bracketed gateway wrappers must not become durable messages."""
wrappers = (
"[ASYNC DELEGATION BATCH COMPLETE — deleg_1]\nworker results follow",
"[ASYNC DELEGATION COMPLETE — deleg_2]",
"[CONTEXT COMPACTION — REFERENCE ONLY]\nsummary follows",
"[CONTEXT COMPACTION - REFERENCE ONLY]",
"[CONTEXT COMPACTION]",
"[PRIOR CONTEXT — for reference only; not a new message]",
"[Your active task list was preserved across context compression]",
"[CONTEXT SUMMARY]: previous context",
"[IMPORTANT: Background process 12 matched watch pattern \"foo\"\nCommand: x",
)
for wrapper in wrappers:
provider = HonchoMemoryProvider()
manager_calls = []
class Manager:
def get_or_create(self, session_key):
manager_calls.append(session_key)
return SimpleNamespace()
provider._config = _configured_tools_config(init_on_session_start=True)
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.sync_turn(wrapper, "assistant reply")
assert provider._sync_thread is None, f"wrapper not suppressed: {wrapper[:60]!r}"
assert manager_calls == [], f"wrapper not suppressed: {wrapper[:60]!r}"
def test_honcho_sync_turn_skips_prose_gateway_notifications():
"""Prose-form gateway notifications must not become durable messages."""
prose_wrappers = (
"A background fan-out of 3 subagent(s) you dispatched earlier has finished.",
"A background subagent you dispatched earlier has finished. You may have moved on.",
)
for wrapper in prose_wrappers:
provider = HonchoMemoryProvider()
manager_calls = []
class Manager:
def get_or_create(self, session_key):
manager_calls.append(session_key)
return SimpleNamespace()
provider._config = _configured_tools_config(init_on_session_start=True)
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.sync_turn(wrapper, "assistant reply")
assert provider._sync_thread is None, f"prose wrapper not suppressed: {wrapper[:60]!r}"
assert manager_calls == [], f"prose wrapper not suppressed: {wrapper[:60]!r}"
def test_honcho_sync_turn_does_not_suppress_genuine_user_messages():
"""Genuine user messages that mention gateway terms must still be stored."""
genuine = (
"A background process I ran has finished — can you check the output?",
"A background subagent you dispatched earlier has finished? no wait, I was asking about the report",
"the async delegation batch complete marker disappeared from my log",
"CONTEXT COMPACTION happened mid-message and I want to see it",
"When you see PRIOR CONTEXT, treat it carefully",
"I want to know about your task list",
"IMPORTANT: Background process — can you explain what that means?",
"[IMPORTANT: Background process — what does that mean?]",
)
for msg in genuine:
provider = HonchoMemoryProvider()
manager_calls = []
class Manager:
def get_or_create(self, session_key):
manager_calls.append(session_key)
return SimpleNamespace()
def resolve_author_peer_id(self, session_key, author_id, author_name=None):
return None
provider._config = _configured_tools_config(init_on_session_start=True)
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.sync_turn(msg, "assistant reply")
assert provider._sync_thread is not None, f"genuine message suppressed: {msg[:60]!r}"
assert manager_calls != [], f"genuine message suppressed: {msg[:60]!r}"
def test_honcho_sync_turn_skips_empty_content():
"""Empty or whitespace-only turns must not be stored."""
provider = HonchoMemoryProvider()
manager_calls = []
class Manager:
def get_or_create(self, session_key):
manager_calls.append(session_key)
return SimpleNamespace()
provider._config = _configured_tools_config(init_on_session_start=True)
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.sync_turn(" ", " ")
assert provider._sync_thread is None
assert manager_calls == []
def test_honcho_sync_turn_same_instance_config_flip_gates_writes():
"""The cached-provider regression: flipping save_messages on the SAME
configured instance must stop writes without re-initialization."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
cfg.save_messages = True
manager_calls = []
write_done = threading.Event()
class Manager:
def get_or_create(self, session_key):
manager_calls.append(session_key)
return SimpleNamespace(add_message=lambda role, content, **kwargs: None)
def resolve_author_peer_id(self, session_key, author_id, author_name=None):
return None
def save(self, session):
write_done.set()
provider._config = cfg
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
# enabled -> write happens
provider.sync_turn("user turn", "assistant reply")
assert write_done.wait(timeout=5), "first write never completed"
# operator flips containment on the same cached config object
cfg.save_messages = False
manager_calls.clear()
provider.sync_turn("user turn two", "assistant reply two")
# no new write may occur; the stale _sync_thread from the enabled write is fine
assert manager_calls == []
def test_honcho_on_memory_write_honors_save_messages_false():
"""The memory-tool mirror is an automatic write path and must respect the
write-disable switch; otherwise containment only covers conversation turns."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
cfg.save_messages = False
conclusion_calls = []
class Manager:
def create_conclusion(self, session_key, content):
conclusion_calls.append((session_key, content))
provider._config = cfg
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.on_memory_write("add", "user", "prefers fail-open memory")
assert conclusion_calls == []
def test_honcho_on_memory_write_still_writes_when_enabled():
"""With save_messages enabled, the memory-tool mirror still writes."""
provider = HonchoMemoryProvider()
cfg = _configured_tools_config(init_on_session_start=True)
cfg.save_messages = True
conclusion_calls = []
write_done = threading.Event()
class Manager:
def create_conclusion(self, session_key, content):
conclusion_calls.append((session_key, content))
write_done.set()
provider._config = cfg
provider._manager = Manager()
provider._session_key = "test-session"
provider._session_initialized = True
provider.on_memory_write("add", "user", "prefers fail-open memory")
assert write_done.wait(timeout=5), "memory mirror write never completed"
assert conclusion_calls != []
@@ -1,134 +0,0 @@
"""Honcho session summaries must not inject model reasoning (#97639)."""
from types import SimpleNamespace
from plugins.memory.honcho.session import HonchoSession, HonchoSessionManager
from plugins.memory.honcho.session_context import usable_honcho_summary
CONTAMINATED = """I need to create a thorough, comprehensive summary of this conversation.
The instruction says to focus on capturing key facts...
First, let me review what I have in the previous summary...
</think>
Alice prefers dark roast coffee.
"""
PLANNING_ONLY = """I need to create a thorough, comprehensive summary of this conversation.
The instruction says to focus on capturing key facts...
First, let me review what I have in the previous summary...
"""
def test_usable_summary_keeps_text_after_think_close() -> None:
out = usable_honcho_summary(CONTAMINATED)
assert out is not None
assert "Alice prefers dark roast coffee." in out
assert "</think>" not in out.lower()
assert "I need to create a thorough" not in out
def test_usable_summary_drops_planning_preamble_without_body() -> None:
assert usable_honcho_summary(PLANNING_ONLY) is None
def test_usable_summary_drops_closed_think_block() -> None:
raw = "<think>plan the summary</think>\nBob uses vim."
out = usable_honcho_summary(raw)
assert out == "Bob uses vim."
def test_usable_summary_keeps_clean_text() -> None:
assert usable_honcho_summary("Uses Python 3.11.") == "Uses Python 3.11."
class _FakeSummary:
def __init__(self, content: str) -> None:
self.content = content
class _FakeContext:
def __init__(self, content: str) -> None:
self.summary = _FakeSummary(content)
self.peer_representation = "representation"
self.peer_card = ["fact"]
self.messages = []
class _RecordingHonchoSession:
def __init__(self, content: str) -> None:
self.content = content
def context(self, **kwargs):
return _FakeContext(self.content)
def _manager_with_summary(content: str):
cfg = SimpleNamespace(
write_frequency="turn",
dialectic_reasoning_level="low",
dialectic_dynamic=True,
dialectic_max_chars=600,
observation_mode="directional",
user_observe_me=True,
user_observe_others=True,
ai_observe_me=True,
ai_observe_others=True,
message_max_chars=25000,
dialectic_max_input_chars=10000,
)
mgr = HonchoSessionManager(honcho=SimpleNamespace(), config=cfg)
session = HonchoSession(
key="test-session",
user_peer_id="chris",
assistant_peer_id="hermes",
honcho_session_id="test-session",
)
fake = _RecordingHonchoSession(content)
mgr._cache[session.key] = session
mgr._sessions_cache[session.honcho_session_id] = fake
return mgr
def test_prefetch_omits_planning_only_summary() -> None:
mgr = _manager_with_summary(PLANNING_ONLY)
mgr._fetch_peer_context = lambda *a, **k: {
"representation": "representation",
"card": ["fact"],
}
mgr._resolve_observer_target = lambda *a, **k: ("hermes", "chris")
result = mgr.get_prefetch_context("test-session")
assert "summary" not in result
assert result.get("representation") == "representation"
def test_prefetch_keeps_body_after_think_close() -> None:
mgr = _manager_with_summary(CONTAMINATED)
mgr._fetch_peer_context = lambda *a, **k: {
"representation": "representation",
"card": ["fact"],
}
mgr._resolve_observer_target = lambda *a, **k: ("hermes", "chris")
result = mgr.get_prefetch_context("test-session")
assert result["summary"] == "Alice prefers dark roast coffee."
def test_session_context_omits_planning_only_summary() -> None:
mgr = _manager_with_summary(PLANNING_ONLY)
result = mgr.get_session_context("test-session")
assert "summary" not in result
assert result.get("representation") == "representation"
def test_format_first_turn_omits_contaminated_summary() -> None:
from plugins.memory.honcho import HonchoMemoryProvider
plugin = HonchoMemoryProvider()
out = plugin._format_first_turn_context(
{
"summary": PLANNING_ONLY,
"card": "Likes espresso.",
}
)
assert "Likes espresso." in out
assert "Session Summary" not in out
assert "I need to create a thorough" not in out
-49
View File
@@ -1741,55 +1741,6 @@ class TestAtexitFinalization(TestTurnTraceIsolation):
assert mod._get_langfuse() is not None
assert mod._finalize_all_traces in registered
def test_finalize_flushes_every_profile_without_an_ambient_scope(self, monkeypatch, tmp_path):
"""Multiplex gateway: every turn ran inside a profile scope (home override + secret scope),
so only the per-home slots hold clients and the launch slot stays empty. atexit has no
scope, and a credential read there raises UnscopedSecretError. The finalizer must not read
credentials at all — it ends the open roots and flushes each settled client, so neither
profile loses its pending traces."""
from agent import secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
mod = self._fresh_plugin()
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
monkeypatch.setattr(mod, "_end_observation", lambda obs, **k: None)
mod._LANGFUSE_CLIENT = None
mod._TRACE_STATE.clear()
mod._LANGFUSE_CLIENT_BY_HOME.clear()
flushed: list = []
fake_client = self._fake_client
def _sdk(**kw):
client = fake_client([])
client.flush = lambda pk=kw["public_key"]: flushed.append(pk)
return client
monkeypatch.setattr(mod, "Langfuse", _sdk)
for profile in ("alpha", "beta"):
home = tmp_path / profile
home.mkdir()
home_token = set_hermes_home_override(home)
scope_token = secret_scope.set_secret_scope({
"HERMES_LANGFUSE_PUBLIC_KEY": f"pk-lf-{profile}-0123456789",
"HERMES_LANGFUSE_SECRET_KEY": f"sk-lf-{profile}-0123456789",
})
try:
self._run_turn(mod, session=f"{profile}-turn", turn_n=0, finalize=False)
finally:
secret_scope.reset_secret_scope(scope_token)
reset_hermes_home_override(home_token)
assert len(mod._TRACE_STATE) == 2 and len(mod._LANGFUSE_CLIENT_BY_HOME) == 2
assert mod._LANGFUSE_CLIENT is None and secret_scope.current_secret_scope() is None
mod._finalize_all_traces() # no scope: must not raise, must not build a client
assert sorted(flushed) == ["pk-lf-alpha-0123456789", "pk-lf-beta-0123456789"]
assert mod._TRACE_STATE == {} and mod._LANGFUSE_CLIENT is None
class TestSystemPromptInGenerationInput:
"""The generation input must carry the system prompt even for providers
that move it out of ``messages``: Anthropic Messages (``system`` kwarg)
@@ -1,30 +0,0 @@
"""One mode-000 / ACL-denied child under a plugin root must not abort the listing (#111804)."""
from __future__ import annotations
from pathlib import Path
from plugins import plugin_loader
def _deny(monkeypatch, denied: Path) -> None:
"""chmod 000 does not bite as root, so fail the stat of the denied child's ``__init__.py``."""
real_stat = Path.stat
def stat(self, *args, **kwargs):
if self.parent == denied:
raise PermissionError(13, "Permission denied", str(self))
return real_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", stat)
def test_iter_plugin_dirs_skips_unreadable_child(tmp_path, monkeypatch, caplog):
for name in ("denied", "good"):
(tmp_path / name).mkdir()
(tmp_path / name / "__init__.py").write_text("", encoding="utf-8")
_deny(monkeypatch, tmp_path / "denied")
with caplog.at_level("WARNING", logger="plugins.plugin_loader"):
assert plugin_loader.iter_plugin_dirs(tmp_path) == [tmp_path / "good"]
assert "Skipping unreadable plugin directory" in caplog.text
@@ -1,49 +0,0 @@
"""Plugin data paths follow the active profile's HERMES_HOME, including the ContextVar override.
Several plugins carried a ``~/.hermes`` fallback (guarding an ImportError of ``hermes_constants``
that cannot happen for a bundled plugin) or resolved the home at import time. Both are wrong on
Windows and under multiplex profile overrides. Every resolver below must land inside the override.
"""
from __future__ import annotations
import pytest
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
def _a2a_conversation(home):
from plugins.platforms.a2a import protocol
return protocol._conv_path("peer-x")
def _photon_auth(home):
from plugins.platforms.photon import auth
return auth._auth_json_path()
def _mem0_qdrant(home):
from plugins.memory.mem0._oss_providers import vector_default_config
return vector_default_config("qdrant")["path"]
def _openviking_log(home):
import plugins.memory.openviking as ov
return ov.get_hermes_home() / ov._OPENVIKING_SERVER_LOG_RELATIVE_PATH
_RESOLVERS = {"a2a": _a2a_conversation, "photon": _photon_auth, "mem0-qdrant": _mem0_qdrant,
"openviking": _openviking_log}
@pytest.mark.parametrize("name", sorted(_RESOLVERS))
def test_plugin_path_follows_profile_override(name, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))
monkeypatch.setenv("HOME", str(tmp_path / "user-home"))
profile = tmp_path / "profiles" / "b"
profile.mkdir(parents=True)
token = set_hermes_home_override(profile)
try:
resolved = str(_RESOLVERS[name](profile))
finally:
reset_hermes_home_override(token)
assert resolved.startswith(str(profile)), f"{name} resolved {resolved!r} outside the active profile"
-69
View File
@@ -1,69 +0,0 @@
"""Tests for the per-plugin durable storage convention (plugins/plugin_storage).
The contract under test: data lives under ``<hermes home>/plugin-data/<name>/``
(NOT the ``plugins/<name>/`` install tree), names that could escape the root
are rejected, and the sqlite helper opens a WAL-mode connection inside the
data dir.
"""
from __future__ import annotations
import pytest
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from plugins.plugin_storage import plugin_data_dir, plugin_db
@pytest.fixture
def hermes_home(tmp_path):
token = set_hermes_home_override(str(tmp_path))
try:
yield tmp_path
finally:
reset_hermes_home_override(token)
def test_data_dir_lives_outside_the_install_tree(hermes_home):
root = plugin_data_dir("my-plugin")
assert root == hermes_home / "plugin-data" / "my-plugin"
assert root.is_dir()
# The invariant that motivated the module: data must not live under the
# install tree that `hermes plugins remove` deletes.
assert (hermes_home / "plugins") not in root.parents
def test_data_dir_is_stable_across_calls(hermes_home):
assert plugin_data_dir("p") == plugin_data_dir("p")
@pytest.mark.parametrize("bad", ["", ".", "..", "../escape", "a/b", "a\\b", "x" * 65])
def test_hostile_names_are_rejected(hermes_home, bad):
with pytest.raises(ValueError):
plugin_data_dir(bad)
def test_plugin_db_journal_mode_is_the_shared_fallback_verdict(hermes_home):
"""Plugin DBs take the journal mode the core WAL helper decides for this SQLite build and
filesystem (WAL normally; DELETE on WAL-reset-bug builds or network FS) — never a raw PRAGMA."""
from hermes_state_wal import is_sqlite_wal_reset_vulnerable
conn = plugin_db("board")
try:
conn.execute("CREATE TABLE t (x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode == ("delete" if is_sqlite_wal_reset_vulnerable() else "wal")
finally:
conn.close()
assert (hermes_home / "plugin-data" / "board" / "data.db").exists()
def test_plugin_db_rejects_path_shaped_filenames(hermes_home):
with pytest.raises(ValueError):
plugin_db("board", filename="../outside.db")
with pytest.raises(ValueError):
plugin_db("board", filename="")
-139
View File
@@ -1,139 +0,0 @@
"""Tests for plugins/plugin_utils.py — thread-safe lazy singleton helpers.
These exercise the actual concurrency guarantee with real threads (not mocks):
a barrier releases N threads simultaneously into the accessor, and we assert
the factory ran exactly once.
"""
import threading
import pytest
from plugins.plugin_utils import SingletonSlot, lazy_singleton
# --- lazy_singleton -------------------------------------------------------
def test_lazy_singleton_builds_once_and_returns_same_instance():
calls = []
@lazy_singleton
def get():
calls.append(1)
return object()
a = get()
b = get()
assert a is b
assert len(calls) == 1
def test_lazy_singleton_reset_rebuilds():
counter = {"n": 0}
@lazy_singleton
def get():
counter["n"] += 1
return counter["n"]
assert get() == 1
assert get() == 1
get.reset()
assert get() == 2
def test_lazy_singleton_concurrent_first_call_builds_once():
build_count = {"n": 0}
build_lock = threading.Lock()
barrier = threading.Barrier(16)
results = []
results_lock = threading.Lock()
@lazy_singleton
def get():
# Count builds under a lock so the assertion is exact even if the
# double-checked lock had a bug and let two through.
with build_lock:
build_count["n"] += 1
# Simulate an expensive build so threads genuinely overlap.
import time
time.sleep(0.01)
return object()
def worker():
barrier.wait() # release all threads at once
obj = get()
with results_lock:
results.append(obj)
threads = [threading.Thread(target=worker) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
assert build_count["n"] == 1, "factory must run exactly once under race"
assert len(results) == 16
assert all(r is results[0] for r in results), "all callers share one instance"
# --- SingletonSlot --------------------------------------------------------
def test_slot_caches_first_value():
slot: SingletonSlot = SingletonSlot()
assert slot.peek() is None
v1 = slot.get(lambda: "first")
assert slot.peek() == "first"
# Subsequent factory is ignored — first value wins.
v2 = slot.get(lambda: "second")
assert v1 == v2 == "first"
def test_slot_factory_exception_not_cached():
slot: SingletonSlot = SingletonSlot()
def boom():
raise ValueError("nope")
with pytest.raises(ValueError):
slot.get(boom)
assert slot.peek() is None
assert slot.get(lambda: "recovered") == "recovered"
def test_slot_concurrent_first_call_builds_once():
build_count = {"n": 0}
build_lock = threading.Lock()
barrier = threading.Barrier(16)
slot: SingletonSlot = SingletonSlot()
results = []
results_lock = threading.Lock()
def factory():
with build_lock:
build_count["n"] += 1
import time
time.sleep(0.01)
return object()
def worker():
barrier.wait()
obj = slot.get(factory)
with results_lock:
results.append(obj)
threads = [threading.Thread(target=worker) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
assert build_count["n"] == 1
assert len(results) == 16
assert all(r is results[0] for r in results)
@@ -1,74 +0,0 @@
"""plugins.manage optional ``profile`` param — per-profile plugins scoping.
Mirrors ``cron.manage`` / ``mcp.servers.*``: when a ``profile`` is passed the
handler resolves ``get_profile_dir(profile)`` and wraps the action dispatch in
``set_hermes_home_override`` / ``reset_hermes_home_override``. Because
``_plugins_dir()`` keys off ``get_hermes_home()``, the list action must then
scan THAT profile's ``plugins/`` dir, not the launch profile's.
"""
from tui_gateway import server
def test_plugins_manage_profile_reads_that_profiles_dir(tmp_path, monkeypatch):
# A temp profile home with one user plugin in its plugins dir.
profile_home = tmp_path / "profiles" / "botA"
plugin_dir = profile_home / "plugins" / "bota-only-plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(
"name: bota-only-plugin\nversion: '1.0'\ndescription: BotA-only plugin\n",
encoding="utf-8",
)
# Route the profile name the handler resolves to our temp home.
import hermes_cli.profiles as profiles
monkeypatch.setattr(profiles, "get_profile_dir", lambda name: profile_home)
resp = server.handle_request(
{
"id": "1",
"method": "plugins.manage",
"params": {"action": "list", "profile": "botA"},
}
)
assert "result" in resp, resp
user_rows = [
p for p in resp["result"]["plugins"] if p.get("source") == "user"
]
assert "bota-only-plugin" in [p["name"] for p in user_rows]
# The override must not leak: an unscoped call after this one resolves the
# launch profile again.
from hermes_constants import get_hermes_home_override
assert get_hermes_home_override() is None
def test_plugins_manage_unknown_profile_errors(tmp_path, monkeypatch):
import hermes_cli.profiles as profiles
missing = tmp_path / "profiles" / "ghost"
monkeypatch.setattr(profiles, "get_profile_dir", lambda name: missing)
resp = server.handle_request(
{
"id": "2",
"method": "plugins.manage",
"params": {"action": "list", "profile": "ghost"},
}
)
assert "error" in resp, resp
assert resp["error"]["code"] == 4064
def test_plugins_manage_unscoped_still_lists(monkeypatch):
# No profile param — the pre-existing contract is unchanged.
resp = server.handle_request(
{"id": "3", "method": "plugins.manage", "params": {"action": "list"}}
)
assert "result" in resp, resp
assert "plugins" in resp["result"]
@@ -1,49 +0,0 @@
"""Credential shims outside the memory plugins honour the secret-scope contract.
``langfuse._secret`` and ``azure_identity_adapter._scoped_env`` used to catch ``UnscopedSecretError``
and fall back to ``os.environ`` / ``""``. Under multiplex ``os.environ`` is the DEFAULT profile's
``.env``, so that fallback either shipped another profile's credentials or hid the spawn-site bug the
exception exists to surface. Contract: scope wins over environ; no scope while multiplexing raises.
"""
from __future__ import annotations
import pytest
from agent import secret_scope
from agent.azure_identity_adapter import _scoped_env as azure_scoped_env
from plugins.observability.langfuse import _secret as langfuse_secret
_READERS = {"langfuse": (langfuse_secret, "LANGFUSE_SECRET_KEY"),
"azure": (azure_scoped_env, "AZURE_CLIENT_SECRET")}
@pytest.fixture
def multiplex(monkeypatch):
secret_scope.set_multiplex_active(True)
try:
yield
finally:
secret_scope.set_multiplex_active(False)
@pytest.mark.parametrize("name", sorted(_READERS))
def test_scoped_read_prefers_profile_scope_over_default_environ(name, monkeypatch, multiplex):
reader, var = _READERS[name]
monkeypatch.setenv(var, "default-profile-value")
token = secret_scope.set_secret_scope({var: " profile-b-value "})
try:
assert reader(var) == "profile-b-value"
finally:
secret_scope.reset_secret_scope(token)
@pytest.mark.parametrize("name", sorted(_READERS))
def test_scopeless_multiplex_read_fails_loud(name, monkeypatch, multiplex):
reader, var = _READERS[name]
monkeypatch.setenv(var, "default-profile-value")
token = secret_scope.set_secret_scope(None)
try:
with pytest.raises(secret_scope.UnscopedSecretError):
reader(var)
finally:
secret_scope.reset_secret_scope(token)
@@ -1,260 +0,0 @@
"""Regression tests: plugin-family credential reads honor the profile secret scope.
Class-closure follow-up to the profile secret-scope cluster (#76462). Memory,
image_gen, and browser plugins, plus a handful of tier-3 tool helpers, read
credentials straight from ``os.environ``. Under a multiplexed gateway the
process environment may hold ANOTHER profile's key (or none), so every
credential read must route through ``agent.secret_scope.get_secret`` and honor
its verdict — a scoped miss under multiplexing returns the default and must
NOT borrow from ``os.environ``.
One representative test pair (scoped-wins / scoped-miss-no-borrow) per plugin
family, plus the two behavioral sites:
* supermemory ``post_setup`` must not write a profile's key into the
process-global environ when multiplexing is active (sibling-profile
pollution).
* google_meet ``process_manager.start`` must resolve OPENAI_API_KEY through
the scope AT SPAWN TIME and pass it explicitly in the child environment —
the detached child inherits the process env, not the contextvar scope.
"""
from __future__ import annotations
import os
from typing import Any, Dict
import pytest
from agent.secret_scope import (
reset_secret_scope,
set_multiplex_active,
set_secret_scope,
)
@pytest.fixture
def multiplex_scope():
"""Install a secret scope with multiplexing ON; restore state after."""
def _install(scope: Dict[str, str]):
set_multiplex_active(True)
token = set_secret_scope(scope)
return token
tokens = []
def install(scope: Dict[str, str]):
tokens.append(_install(scope))
yield install
for token in tokens:
reset_secret_scope(token)
set_multiplex_active(False)
# ---------------------------------------------------------------------------
# Family A — memory plugins
# ---------------------------------------------------------------------------
class TestMemoryFamily:
def test_retaindb_scoped_key_wins(self, multiplex_scope, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "env-other-profile")
multiplex_scope({"RETAINDB_API_KEY": "scoped-key"})
from plugins.memory.retaindb import RetainDBMemoryProvider
assert RetainDBMemoryProvider().is_available() is True
def test_retaindb_scoped_miss_does_not_borrow_environ(
self, multiplex_scope, monkeypatch
):
# Env holds another profile's key; the active profile's scope has none.
monkeypatch.setenv("RETAINDB_API_KEY", "env-other-profile")
multiplex_scope({})
from plugins.memory.retaindb import RetainDBMemoryProvider
assert RetainDBMemoryProvider().is_available() is False
def test_supermemory_scoped_miss_does_not_borrow_environ(
self, multiplex_scope, monkeypatch
):
monkeypatch.setenv("SUPERMEMORY_API_KEY", "env-other-profile")
multiplex_scope({})
from plugins.memory.supermemory import SupermemoryMemoryProvider
assert SupermemoryMemoryProvider().is_available() is False
def test_supermemory_post_setup_no_environ_write_under_multiplex(
self, multiplex_scope, monkeypatch, tmp_path
):
"""post_setup must not pollute process env with a profile's key."""
multiplex_scope({})
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
import hermes_cli.config as cli_config
import hermes_cli.memory_setup as memory_setup
import plugins.memory.supermemory as sm
monkeypatch.setattr(memory_setup, "_prompt", lambda *a, **k: "sm-fresh-key")
monkeypatch.setattr(memory_setup, "_write_env_vars", lambda *a, **k: None)
monkeypatch.setattr(cli_config, "save_config", lambda *a, **k: None)
monkeypatch.setattr(
sm,
"_probe_supermemory_connection",
lambda *a, **k: {"ok": True, "detail": "stub"},
)
monkeypatch.setattr(sm, "_format_connection_summary", lambda s: "stub")
sm.SupermemoryMemoryProvider().post_setup(str(tmp_path), {})
assert "SUPERMEMORY_API_KEY" not in os.environ
def test_supermemory_post_setup_environ_write_kept_single_profile(
self, monkeypatch, tmp_path
):
"""Single-profile (multiplex off): the convenience write still happens."""
set_multiplex_active(False)
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
import hermes_cli.config as cli_config
import hermes_cli.memory_setup as memory_setup
import plugins.memory.supermemory as sm
monkeypatch.setattr(memory_setup, "_prompt", lambda *a, **k: "sm-fresh-key")
monkeypatch.setattr(memory_setup, "_write_env_vars", lambda *a, **k: None)
monkeypatch.setattr(cli_config, "save_config", lambda *a, **k: None)
monkeypatch.setattr(
sm,
"_probe_supermemory_connection",
lambda *a, **k: {"ok": True, "detail": "stub"},
)
monkeypatch.setattr(sm, "_format_connection_summary", lambda s: "stub")
try:
sm.SupermemoryMemoryProvider().post_setup(str(tmp_path), {})
assert os.environ.get("SUPERMEMORY_API_KEY") == "sm-fresh-key"
finally:
os.environ.pop("SUPERMEMORY_API_KEY", None)
# ---------------------------------------------------------------------------
# Family B — image_gen plugins
# ---------------------------------------------------------------------------
class TestImageGenFamily:
def test_deepinfra_scoped_key_wins(self, multiplex_scope, monkeypatch):
monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False)
multiplex_scope({"DEEPINFRA_API_KEY": "scoped-key"})
from plugins.image_gen.deepinfra import DeepInfraImageGenProvider
assert DeepInfraImageGenProvider().is_available() is True
def test_deepinfra_scoped_miss_does_not_borrow_environ(
self, multiplex_scope, monkeypatch
):
monkeypatch.setenv("DEEPINFRA_API_KEY", "env-other-profile")
multiplex_scope({})
from plugins.image_gen.deepinfra import DeepInfraImageGenProvider
assert DeepInfraImageGenProvider().is_available() is False
# ---------------------------------------------------------------------------
# Family C — browser/web plugins
# ---------------------------------------------------------------------------
class TestBrowserFamily:
def test_firecrawl_scoped_key_wins(self, multiplex_scope, monkeypatch):
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
multiplex_scope({"FIRECRAWL_API_KEY": "scoped-key"})
from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
provider = FirecrawlBrowserProvider()
assert provider.is_available() is True
assert provider._headers()["Authorization"] == "Bearer scoped-key"
def test_firecrawl_scoped_miss_does_not_borrow_environ(
self, multiplex_scope, monkeypatch
):
monkeypatch.setenv("FIRECRAWL_API_KEY", "env-other-profile")
multiplex_scope({})
from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
assert FirecrawlBrowserProvider().is_available() is False
# ---------------------------------------------------------------------------
# Family E — google_meet spawn-wrap
# ---------------------------------------------------------------------------
class TestGoogleMeetSpawn:
def test_child_env_carries_scoped_openai_key(
self, multiplex_scope, monkeypatch, tmp_path
):
"""start() resolves the key from the scope and injects it explicitly."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("HERMES_MEET_REALTIME_KEY", raising=False)
multiplex_scope({"OPENAI_API_KEY": "scoped-openai-key"})
import plugins.google_meet.process_manager as pm
monkeypatch.setattr(pm, "_root", lambda: tmp_path)
captured: Dict[str, Any] = {}
class _FakeProc:
pid = 4242
def fake_popen(cmd, **kwargs):
captured["env"] = kwargs.get("env")
return _FakeProc()
monkeypatch.setattr(pm.subprocess, "Popen", fake_popen)
result = pm.start(
"https://meet.google.com/abc-defg-hij",
out_dir=tmp_path / "meeting",
mode="realtime",
)
assert result["ok"] is True
child_env = captured["env"]
# The scoped key crosses the process boundary explicitly, not via
# inherited os.environ (which had no key at all).
assert child_env["HERMES_MEET_REALTIME_KEY"] == "scoped-openai-key"
def test_explicit_key_argument_still_wins(
self, multiplex_scope, monkeypatch, tmp_path
):
multiplex_scope({"OPENAI_API_KEY": "scoped-openai-key"})
import plugins.google_meet.process_manager as pm
monkeypatch.setattr(pm, "_root", lambda: tmp_path)
captured: Dict[str, Any] = {}
class _FakeProc:
pid = 4243
monkeypatch.setattr(
pm.subprocess,
"Popen",
lambda cmd, **kw: (captured.update(env=kw.get("env")), _FakeProc())[1],
)
pm.start(
"https://meet.google.com/abc-defg-hij",
out_dir=tmp_path / "meeting2",
realtime_api_key="explicit-key",
)
assert captured["env"]["HERMES_MEET_REALTIME_KEY"] == "explicit-key"
@@ -1,304 +0,0 @@
"""Integration coverage for polling progress against the installed PTB runtime."""
import asyncio
import pytest
pytest.importorskip("telegram", reason="python-telegram-bot not installed")
from telegram.error import Conflict, TelegramError
from telegram.request import BaseRequest
from gateway.config import PlatformConfig
from plugins.platforms.telegram import adapter as tg_adapter
from plugins.platforms.telegram.adapter import TelegramAdapter
class _GeneralRequest(BaseRequest):
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
if url.endswith("/getMe"):
return (
200,
b'{"ok":true,"result":{"id":1,"is_bot":true,'
b'"first_name":"Test","username":"test_bot"}}',
)
return 200, b'{"ok":true,"result":true}'
class _GetUpdatesRequest(BaseRequest):
def __init__(self):
self.initial_conflict_sent = False
self.replacement_enabled = False
self.replacement_progress_sent = False
self.cleanup_calls = 0
self.block = asyncio.Event()
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
parameters = request_data.parameters if request_data is not None else {}
timeout = parameters.get("timeout")
timeout_seconds = (
timeout.total_seconds() if hasattr(timeout, "total_seconds") else timeout
)
if timeout_seconds == 0:
self.cleanup_calls += 1
return 200, b'{"ok":true,"result":[]}'
if not self.initial_conflict_sent:
self.initial_conflict_sent = True
return (
409,
b'{"ok":false,"error_code":409,'
b'"description":"Conflict: another getUpdates request"}',
)
if self.replacement_enabled and not self.replacement_progress_sent:
self.replacement_progress_sent = True
return 200, b'{"ok":true,"result":[]}'
await self.block.wait()
return 200, b'{"ok":true,"result":[]}'
class _EnvelopeRequest(BaseRequest):
def __init__(self, payload):
self.payload = payload
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
return 200, self.payload
class _SlottedEnvelopeRequest(BaseRequest):
"""A getUpdates request with no instance ``__dict__``.
Reproduces PTB's real HTTPXRequest shape on Python 3.13, where every
class in the MRO defines ``__slots__`` and instances therefore reject an
instance-attribute ``do_request`` monkey-patch as "read-only" (#64482).
``__slots__`` names the payload so the double needs no ``__dict__``.
"""
__slots__ = ("_payload",)
def __init__(self, payload):
self._payload = payload
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
return 200, self._payload
async def _cancel_task(task):
if task is None or task.done():
return
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
async def test_real_base_request_bom_rejected_by_ptb_cannot_record_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'\xef\xbb\xbf{"ok":true,"result":[]}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
with pytest.raises(TelegramError, match="Invalid server response"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_ptb_replacement_decode_records_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'{"ok":true,"result":[],"note":"\xff"}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
result = await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert result == []
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("payload", "missing_result"),
[
(b'{"ok":false,"result":[]}', False),
(b'{"ok":true}', True),
],
)
async def test_real_base_request_unsuccessful_200_envelope_cannot_record_progress(
payload, missing_result
):
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(_EnvelopeRequest(payload))
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
if missing_result:
with pytest.raises(KeyError, match="result"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
else:
assert await request.post(
"https://api.telegram.org/bot-token/getUpdates"
) == []
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_valid_success_envelope_records_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'{"ok":true,"result":[]}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
result = await request.post(
"https://api.telegram.org/bot-token/getUpdates"
)
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert result == []
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
async def test_real_ptb_stop_cleanup_cannot_heal_recovery_generation():
assert tg_adapter.TELEGRAM_AVAILABLE is True
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
polling_request = _GetUpdatesRequest()
app = (
tg_adapter.Application.builder()
.token("123456:test-token")
.request(_GeneralRequest())
.get_updates_request(adapter._instrument_polling_request(polling_request))
.build()
)
adapter._app = app
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
callback_called = asyncio.Event()
recovery_task = None
async def stop_for_recovery():
await app.updater.stop()
def schedule_recovery(error):
nonlocal recovery_task
assert isinstance(error, Conflict)
recovery_task = asyncio.create_task(stop_for_recovery())
callback_called.set()
await app.initialize()
try:
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=schedule_recovery,
)
generation = adapter._polling_generation
progress = adapter._polling_progress_event
await asyncio.wait_for(callback_called.wait(), timeout=2)
await asyncio.wait_for(recovery_task, timeout=3)
assert polling_request.cleanup_calls == 1
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
polling_request.replacement_enabled = True
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=schedule_recovery,
)
replacement_generation = adapter._polling_generation
replacement_progress = adapter._polling_progress_event
await asyncio.wait_for(replacement_progress.wait(), timeout=2)
assert replacement_generation == generation + 1
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
finally:
polling_request.block.set()
if app.updater.running:
await app.updater.stop()
await _cancel_task(adapter._polling_progress_verifier_task)
await app.shutdown()
@@ -1,216 +0,0 @@
"""Tests for the ``transform_api_error_classification`` plugin hook.
Covers the seam in ``agent.error_classifier.classify_api_error`` (step 0,
consulted before the built-in pipeline) and the sanitization contract of
``hermes_cli.plugins.get_plugin_error_classification``.
The fixture error is deliberately synthetic (fake provider, made-up
message, no status code) so no present or future built-in rule can claim
it — the earlier OpenRouter tool-use-404 fixture went stale the moment
core learned that exact phrase.
Mirrors the ``transform_tool_result`` hook tests: patch the symbol the
call site actually imports (``hermes_cli.plugins.*``) rather than the
consuming module, because the import happens at call time.
"""
import importlib.util
import logging
import hermes_cli.plugins as plugins_mod
from agent.error_classifier import FailoverReason, classify_api_error
class _FakeAPIError(Exception):
def __init__(self, message, status_code=None, body=None):
super().__init__(message)
if status_code is not None:
self.status_code = status_code
self.body = body or {}
_UNCLAIMED_MESSAGE = "flux capacitor drift detected in shard seven"
def _classify_unclaimed_error(**kwargs):
return classify_api_error(
# No status code on purpose: the built-in pipeline claims whole
# status classes (4xx -> format_error, 429 -> rate_limit, ...), so
# a status-less neutral message is the only shape guaranteed to
# reach the unknown/retryable fall-through.
_FakeAPIError(_UNCLAIMED_MESSAGE),
provider="acmecloud",
model="acme/large-1",
**kwargs,
)
# ── Baseline: no plugins ────────────────────────────────────────────────
def test_no_hook_falls_through_to_builtin(monkeypatch):
# Fresh manager so no stale plugin hooks pollute state.
monkeypatch.setattr(plugins_mod, "_plugin_manager", plugins_mod.PluginManager())
result = _classify_unclaimed_error()
# The synthetic error matches no built-in rule: unknown/retryable is
# the pipeline's fall-through, which is exactly the class of error
# this hook lets provider plugins claim.
assert result.reason == FailoverReason.unknown
assert result.retryable is True
# ── Plugin classification wins over built-ins ───────────────────────────
def test_plugin_classification_wins(monkeypatch):
monkeypatch.setattr(
plugins_mod, "invoke_hook",
lambda name, **kw: [
{"reason": "model_not_found", "retryable": False, "should_fallback": True}
],
)
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.model_not_found
assert result.retryable is False
assert result.should_fallback is True
# Extracted context is preserved on the ClassifiedError.
assert result.provider == "acmecloud"
assert result.status_code is None
# ── Invalid returns are ignored, first valid wins ───────────────────────
def test_invalid_reason_falls_through_to_builtin(monkeypatch):
monkeypatch.setattr(
plugins_mod, "invoke_hook",
lambda name, **kw: [{"reason": "not_a_real_reason"}],
)
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.unknown
def test_first_valid_result_wins(monkeypatch):
monkeypatch.setattr(
plugins_mod, "invoke_hook",
lambda name, **kw: [
{"reason": "bogus"},
{"reason": "billing"},
{"reason": "rate_limit"},
],
)
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.billing
def test_skipped_valid_results_log_runtime_warning(monkeypatch, caplog):
# The #64714 skipped-transform rule: a valid-but-losing classification
# must surface in logs, never be silently shadowed. Invalid results
# (here "bogus") are not "skipped valid" and must not count.
monkeypatch.setattr(
plugins_mod, "invoke_hook",
lambda name, **kw: [
{"reason": "bogus"},
{"reason": "billing"},
{"reason": "rate_limit"},
],
)
with caplog.at_level(logging.WARNING, logger=plugins_mod.logger.name):
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.billing
warnings = [r.getMessage() for r in caplog.records if "skipped" in r.getMessage()]
assert len(warnings) == 1
assert "skipped 1 valid" in warnings[0]
# A lone winner is not a conflict: no warning.
caplog.clear()
monkeypatch.setattr(
plugins_mod, "invoke_hook",
lambda name, **kw: [{"reason": "billing"}],
)
with caplog.at_level(logging.WARNING, logger=plugins_mod.logger.name):
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.billing
assert not [r for r in caplog.records if "skipped" in r.getMessage()]
def test_helper_exception_never_breaks_classification(monkeypatch):
def _boom(**kwargs):
raise RuntimeError("plugin infrastructure exploded")
monkeypatch.setattr(plugins_mod, "get_plugin_error_classification", _boom)
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.unknown
assert result.retryable is True
# ── Hook kwargs contract ────────────────────────────────────────────────
def test_message_override_and_error_context_sanitized(monkeypatch):
monkeypatch.setattr(
plugins_mod, "invoke_hook",
lambda name, **kw: [{
"reason": "model_not_found",
"message": " custom guidance ",
"error_context": {"upstream_provider": "AcmeCloud"},
}],
)
result = _classify_unclaimed_error()
assert result.message == "custom guidance"
assert result.error_context == {"upstream_provider": "AcmeCloud"}
# ── Plugin register() end-to-end (synthetic, written at test time) ──────
_SYNTHETIC_PLUGIN = '''
def classify(provider=None, error_message=None, **kwargs):
"""Self-scoped classifier for acmecloud's flux-drift errors."""
if provider != "acmecloud":
return None
if "flux capacitor drift" not in (error_message or ""):
return None
return {"reason": "overloaded", "retryable": True, "should_fallback": True}
def register(ctx):
ctx.register_hook("transform_api_error_classification", classify)
'''
def _load_synthetic_plugin(tmp_path):
plugin_init = tmp_path / "acmecloud_classifier.py"
plugin_init.write_text(_SYNTHETIC_PLUGIN, encoding="utf-8")
spec = importlib.util.spec_from_file_location("acmecloud_classifier", plugin_init)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_synthetic_plugin_end_to_end(tmp_path, monkeypatch):
"""register() + real invoke_hook + classify_api_error, no mocks."""
demo = _load_synthetic_plugin(tmp_path)
manager = plugins_mod.PluginManager()
monkeypatch.setattr(plugins_mod, "_plugin_manager", manager)
class _Ctx:
def register_hook(self, name, cb):
manager._hooks.setdefault(name, []).append(cb)
demo.register(_Ctx())
result = _classify_unclaimed_error()
assert result.reason == FailoverReason.overloaded
assert result.retryable is True
assert result.should_fallback is True
# And the built-in pipeline is untouched for everything the plugin
# doesn't claim.
other = classify_api_error(
_FakeAPIError("rate limit exceeded", status_code=429),
provider="acmecloud",
)
assert other.reason == FailoverReason.rate_limit
@@ -1,135 +0,0 @@
"""Tests for the ``transform_llm_output`` plugin hook.
The hook fires inside ``AIAgent.run_conversation`` once the tool-calling
loop has produced a final response. Driving the full agent loop from a
unit test would be prohibitively heavy, so these tests exercise the
invoke_hook dispatch semantics that the wiring in ``run_agent.py``
depends on:
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
final_response = _hook_result
break # First non-empty string wins
Mirrors ``test_transform_tool_result_hook.py`` which tests the equivalent
contract for the generic tool-result seam.
"""
from pathlib import Path
import yaml
import hermes_cli.plugins as plugins_mod
from hermes_cli.plugins import PluginManager, VALID_HOOKS
def _make_enabled_plugin(hermes_home: Path, name: str, register_body: str) -> Path:
"""Create a plugin under <hermes_home>/plugins/<name> and opt it in."""
plugin_dir = hermes_home / "plugins" / name
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(
yaml.safe_dump({"name": name, "version": "0.1.0"}), encoding="utf-8",
)
(plugin_dir / "__init__.py").write_text(
"def register(ctx):\n"
f" {register_body}\n",
encoding="utf-8",
)
cfg_path = hermes_home / "config.yaml"
cfg = {}
if cfg_path.exists():
cfg = yaml.safe_load(cfg_path.read_text()) or {}
cfg.setdefault("plugins", {}).setdefault("enabled", []).append(name)
cfg_path.write_text(yaml.safe_dump(cfg), encoding="utf-8")
return plugin_dir
def test_transform_llm_output_in_valid_hooks():
assert "transform_llm_output" in VALID_HOOKS
def test_hook_receives_expected_kwargs(tmp_path, monkeypatch):
"""Hook callback should see response_text + session_id + model + platform."""
hermes_home = tmp_path / "hermes_test"
hermes_home.mkdir(exist_ok=True)
_make_enabled_plugin(
hermes_home, "capture_hook",
register_body=(
'ctx.register_hook("transform_llm_output", '
'lambda **kw: f"{kw[\'response_text\']}|{kw[\'session_id\']}|'
'{kw[\'model\']}|{kw[\'platform\']}")'
),
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mgr = PluginManager()
mgr.discover_and_load()
results = mgr.invoke_hook(
"transform_llm_output",
response_text="hello world",
session_id="s1",
model="anthropic/claude-sonnet-4.6",
platform="cli",
)
assert results == ["hello world|s1|anthropic/claude-sonnet-4.6|cli"]
def test_hook_exception_does_not_replace_response(tmp_path, monkeypatch):
"""A plugin raising an exception must not break hook dispatch.
PluginManager.invoke_hook catches per-callback exceptions, logs a
warning, and continues — so a raising plugin contributes no entry
to the results list, and the walk in run_agent.py finds nothing to
replace with.
"""
hermes_home = tmp_path / "hermes_test"
hermes_home.mkdir(exist_ok=True)
_make_enabled_plugin(
hermes_home, "raising_hook",
register_body=(
'def _boom(**kw):\n'
' raise RuntimeError("boom")\n'
' ctx.register_hook("transform_llm_output", _boom)'
),
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mgr = PluginManager()
mgr.discover_and_load()
results = mgr.invoke_hook(
"transform_llm_output",
response_text="keep me",
session_id="s1",
model="m",
platform="cli",
)
final_response = "keep me"
for _hook_result in results:
if isinstance(_hook_result, str) and _hook_result:
final_response = _hook_result
break
assert final_response == "keep me"
def test_no_plugins_returns_empty_results(tmp_path, monkeypatch):
"""With no plugins loaded, invoke_hook returns [] and the response is unchanged."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_empty"))
plugins_mod._plugin_manager = PluginManager()
mgr = plugins_mod._plugin_manager
results = mgr.invoke_hook(
"transform_llm_output",
response_text="unchanged",
session_id="",
model="m",
platform="",
)
assert results == []
@@ -1,166 +0,0 @@
"""Tests for the ``transform_tool_result`` plugin hook wired into
``model_tools.handle_function_call``.
Mirrors the ``transform_terminal_output`` hook tests from Phase 1 but
targets the generic tool-result seam that runs for every tool dispatch.
"""
import os
from pathlib import Path
import hermes_cli.plugins as plugins_mod
import model_tools
_UNSET = object()
def _run_handle_function_call(
monkeypatch,
*,
tool_name="dummy_tool",
tool_args=None,
dispatch_result='{"output": "original"}',
invoke_hook=_UNSET,
):
"""Drive ``handle_function_call`` with a mocked registry dispatch."""
from tools.registry import registry
monkeypatch.setattr(
registry, "dispatch",
lambda name, args, **kw: dispatch_result,
)
# Skip unrelated side effects (read-loop tracker).
monkeypatch.setattr(model_tools, "_READ_SEARCH_TOOLS", frozenset())
if invoke_hook is not _UNSET:
# Patch the symbol actually imported inside handle_function_call.
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
# Supplying a custom invoke_hook means the test expects hooks to
# fire — make has_hook agree so the has_hook gate doesn't skip the
# post_tool_call / transform_tool_result emit paths.
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
return model_tools.handle_function_call(
tool_name,
tool_args or {},
task_id="t1",
session_id="s1",
tool_call_id="tc1",
skip_pre_tool_call_hook=True,
)
def test_first_valid_string_return_replaces_result(monkeypatch):
out = _run_handle_function_call(
monkeypatch,
invoke_hook=lambda hook_name, **kw: [None, {"x": 1}, "first", "second"],
)
assert out == "first"
def test_hook_receives_expected_kwargs(monkeypatch):
captured = {}
def _hook(hook_name, **kwargs):
if hook_name == "transform_tool_result":
captured.update(kwargs)
return []
out = _run_handle_function_call(
monkeypatch,
tool_name="my_tool",
tool_args={"a": 1, "b": "x"},
dispatch_result='{"ok": true}',
invoke_hook=_hook,
)
assert out == '{"ok": true}'
assert captured["tool_name"] == "my_tool"
assert captured["args"] == {"a": 1, "b": "x"}
assert captured["result"] == '{"ok": true}'
assert captured["task_id"] == "t1"
assert captured["session_id"] == "s1"
assert captured["tool_call_id"] == "tc1"
def test_post_tool_call_remains_observational(monkeypatch):
"""post_tool_call return values must NOT replace the result."""
def _hook(hook_name, **kw):
if hook_name == "post_tool_call":
# Observers returning a string must be ignored.
return ["observer return should be ignored"]
return []
out = _run_handle_function_call(
monkeypatch,
invoke_hook=_hook,
)
assert out == '{"output": "original"}'
def test_transform_tool_result_runs_after_post_tool_call(monkeypatch):
"""post_tool_call sees ORIGINAL result; transform_tool_result sees same and may replace."""
observed = []
def _hook(hook_name, **kw):
if hook_name == "post_tool_call":
observed.append(("post_tool_call", kw["result"]))
return []
if hook_name == "transform_tool_result":
observed.append(("transform_tool_result", kw["result"]))
return ["rewritten"]
return []
out = _run_handle_function_call(
monkeypatch,
dispatch_result='{"raw": "value"}',
invoke_hook=_hook,
)
assert out == "rewritten"
# Both hooks saw the ORIGINAL (untransformed) result.
assert observed == [
("post_tool_call", '{"raw": "value"}'),
("transform_tool_result", '{"raw": "value"}'),
]
def test_transform_tool_result_integration_with_real_plugin(monkeypatch, tmp_path):
"""End-to-end: load a real plugin from HERMES_HOME and verify it rewrites results."""
import yaml
hermes_home = Path(os.environ["HERMES_HOME"])
plugins_dir = hermes_home / "plugins"
plugin_dir = plugins_dir / "transform_result_canon"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text("name: transform_result_canon\n", encoding="utf-8")
(plugin_dir / "__init__.py").write_text(
"def register(ctx):\n"
' ctx.register_hook("transform_tool_result", '
'lambda **kw: f\'CANON[{kw["tool_name"]}]\' + kw["result"])\n',
encoding="utf-8",
)
# Plugins are opt-in — must be listed in plugins.enabled to load.
cfg_path = hermes_home / "config.yaml"
cfg_path.write_text(
yaml.safe_dump({"plugins": {"enabled": ["transform_result_canon"]}}),
encoding="utf-8",
)
# Force a fresh plugin manager so the new config is picked up.
plugins_mod._plugin_manager = plugins_mod.PluginManager()
plugins_mod.discover_plugins()
out = _run_handle_function_call(
monkeypatch,
tool_name="some_tool",
dispatch_result='{"payload": 42}',
)
assert out == 'CANON[some_tool]{"payload": 42}'
+13 -225
View File
@@ -29,55 +29,6 @@ def test_fal_provider_registers():
assert DEFAULT_MODEL in {"pixverse-v6", "ltx-2.3"}
def test_kling_v3_standard_and_pro_payload_shape():
"""Kling 3.0 (v3 standard/pro): start_image_url on i2v, aspect_ratio
dropped on i2v (schema derives it from the image), no seed/resolution
keys, string duration 3-15, generate_audio + negative_prompt real."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
for fid in ("kling-v3", "kling-v3-pro"):
meta = FAL_FAMILIES[fid]
assert meta.get("image_param_key") == "start_image_url"
# text-to-video route
p = _build_payload(
meta,
prompt="a mecha lands",
image_url=None,
duration=7,
aspect_ratio="16:9",
resolution="1080p",
negative_prompt="blurry",
audio=True,
seed=3,
)
assert p == {
"prompt": "a mecha lands",
"aspect_ratio": "16:9",
"duration": "7",
"generate_audio": True,
"negative_prompt": "blurry",
}, fid
# image-to-video route: start_image_url in, aspect_ratio dropped
p = _build_payload(
meta,
prompt="animate it",
image_url="https://example.com/i.png",
duration=20, # clamps to 15
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=False,
seed=None,
)
assert p.get("start_image_url") == "https://example.com/i.png", fid
assert "image_url" not in p, fid
assert "aspect_ratio" not in p, fid
assert p["duration"] == "15", fid
assert p["generate_audio"] is False, fid
def test_kling_4k_uses_start_image_url():
"""Kling v3 4K's image-to-video endpoint expects start_image_url,
not image_url. The family must declare image_param_key='start_image_url'."""
@@ -130,30 +81,6 @@ def test_minimax_h3_int_duration_and_resolution_alias():
assert hi["resolution"] == "2K"
def test_h3_max_turbo_static_key_and_1080p_alias():
"""H3 Max Turbo requires prompt_expansion_mode on both endpoints, adds a real
1080P tier (unlike Max, which caps at 768P), and its i2v drops aspect_ratio."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["minimax-h3-max-turbo"]
t2v = _build_payload(
meta, prompt="x", image_url=None, duration=7, aspect_ratio="16:9",
resolution="1080p", negative_prompt=None, audio=None, seed=11,
)
assert t2v["prompt_expansion_mode"] == "balanced"
assert t2v["resolution"] == "1080P"
assert t2v["duration"] == 7 and isinstance(t2v["duration"], int)
assert t2v["seed"] == 11
i2v = _build_payload(
meta, prompt="x", image_url="https://example.com/i.png", duration=5,
aspect_ratio="16:9", resolution="480p", negative_prompt=None, audio=None, seed=None,
)
assert i2v["prompt_expansion_mode"] == "balanced"
assert "aspect_ratio" not in i2v
assert i2v["image_url"] == "https://example.com/i.png"
def test_image_drop_keys_strips_aspect_ratio_on_i2v():
"""Seedance 2.5 / MiniMax H3 / Grok 1.5 i2v endpoints derive the
aspect ratio from the input image; sending the key is rejected."""
@@ -190,44 +117,14 @@ def test_seedance_25_string_duration_up_to_30():
assert payload["generate_audio"] is True
def test_wan_30_audio_toggle_uses_family_key_and_start_image_url():
"""Wan 3.0's schema names the audio toggle `audio` (not `generate_audio`), takes
`start_image_url` on i2v and an integer duration; veo3.1 keeps `generate_audio`."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
kw = dict(prompt="x", duration=7, aspect_ratio="16:9", resolution="720p", negative_prompt=None, audio=True, seed=None)
p = _build_payload(FAL_FAMILIES["wan-3.0"], image_url="https://i.png", **kw)
assert p["audio"] is True and "generate_audio" not in p
assert p["start_image_url"] == "https://i.png" and p["duration"] == 7
assert _build_payload(FAL_FAMILIES["veo3.1"], image_url=None, **kw)["generate_audio"] is True
def test_gemini_omni_flash_v11_is_dual_modality():
"""v1.1 (Aug 2026) added a text-to-video endpoint; both modalities
must route to the versioned v1.1 endpoints."""
def test_gemini_omni_flash_is_image_only():
"""Gemini Omni Flash has no t2v endpoint on FAL — text jobs must
error cleanly instead of submitting to a None endpoint."""
from plugins.video_gen.fal import FAL_FAMILIES
meta = FAL_FAMILIES["gemini-omni-flash"]
assert meta["text_endpoint"] == "google/gemini-omni-flash/v1.1/text-to-video"
assert meta["image_endpoint"] == "google/gemini-omni-flash/v1.1/image-to-video"
def test_text_only_job_errors_cleanly_for_i2v_only_family(monkeypatch):
"""Catalog-shape guard: a family without a text endpoint must error cleanly
instead of submitting to a None endpoint. Every cataloged family is now
dual-modality, so the guard is exercised with a synthetic family."""
from plugins.video_gen import fal as fal_plugin
from plugins.video_gen.fal import FALVideoGenProvider, _family
synthetic = _family("Synthetic i2v", "~1s", "cheap", "test", None, "example/i2v-only/image-to-video", durations=(3, 10), duration_int=True)
monkeypatch.setattr(fal_plugin, "_fal_video_available", lambda: True)
monkeypatch.setattr(fal_plugin, "_load_fal_client", lambda: object())
monkeypatch.setattr(fal_plugin, "_resolve_family", lambda explicit: ("synthetic", synthetic))
monkeypatch.setattr(fal_plugin, "_submit_fal_video_request", lambda *a, **k: pytest.fail("submitted to a None endpoint"))
result = FALVideoGenProvider().generate("a dog running")
assert result["success"] is False
assert result["error_type"] == "modality_unsupported"
assert meta.get("text_endpoint") is None
assert meta.get("image_endpoint")
def test_every_family_has_required_metadata():
@@ -532,14 +429,13 @@ class TestPayloadBuilder:
assert p["duration"] == expected
assert type(p["duration"]) is type(expected)
def test_every_family_declares_both_endpoints(self):
"""Catalog invariant: since Gemini Omni Flash 1.1 every family is
dual-modality — both endpoints must be non-empty strings."""
def test_i2v_only_families_declare_no_text_endpoint(self):
"""Catalog invariant: Gemini Omni Flash animates an existing image only."""
from plugins.video_gen.fal import FAL_FAMILIES
for fid, meta in FAL_FAMILIES.items():
assert meta.get("text_endpoint"), fid
assert meta.get("image_endpoint"), fid
meta = FAL_FAMILIES["gemini-omni-flash"]
assert meta.get("text_endpoint") is None
assert meta["image_endpoint"]
def test_ltx_omits_duration_aspect_resolution(self):
"""LTX 2.3 doesn't declare duration/aspect/resolution enums —
@@ -589,7 +485,7 @@ class TestPayloadBuilder:
)
def test_happy_horse_minimal_payload(self):
"""Happy Horse 1.1 has a full published schema — verified fields only."""
"""Happy Horse has sparse docs — payload should be minimal."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["happy-horse"]
@@ -604,116 +500,8 @@ class TestPayloadBuilder:
audio=True,
seed=None,
)
# v1.1 declares aspect_ratio/resolution/duration (integer). Audio is
# native (no generate_audio key) and negative_prompt is unsupported.
assert p == {
"prompt": "a horse galloping",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 8,
}
assert isinstance(p["duration"], int)
def test_happy_horse_i2v_drops_aspect_ratio(self):
"""Happy Horse 1.1 i2v derives aspect ratio from the input image."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["happy-horse"]
p = _build_payload(
meta,
prompt="animate this",
image_url="https://example.com/frame.png",
duration=5,
aspect_ratio="16:9",
resolution="1080p",
negative_prompt=None,
audio=None,
seed=123,
)
assert "aspect_ratio" not in p
assert p["image_url"] == "https://example.com/frame.png"
assert p["seed"] == 123
def test_ltx_25_payload(self):
"""LTX 2.5: integer duration enum, 4K alias, no seed key."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["ltx-2.5"]
p = _build_payload(
meta,
prompt="a drone shot",
image_url=None,
duration=7,
aspect_ratio="16:9",
resolution="4k",
negative_prompt="blurry",
audio=True,
seed=42,
)
# duration snaps to the nearest enum value as a JSON integer; the
# tool's "4k" maps to the endpoint's "2160p"; seed/negative dropped.
assert p == {
"prompt": "a drone shot",
"aspect_ratio": "16:9",
"resolution": "2160p",
"duration": 6,
"generate_audio": True,
}
assert isinstance(p["duration"], int)
# i2v: the fast i2v endpoint takes the same even enum (19 snaps to 18 — a tie picks the lower entry, like veo 7→6),
# no seed key.
p = _build_payload(meta, prompt="animate", image_url="https://example.com/f.png", duration=19, aspect_ratio="9:16",
resolution="720p", negative_prompt=None, audio=None, seed=7)
assert p == {"prompt": "animate", "image_url": "https://example.com/f.png", "aspect_ratio": "9:16", "resolution": "720p", "duration": 18}
# fal caps 1440p/2160p at 10s regardless of frame rate: 18 at 720p stays 18, at 4k it is capped to 10; and an
# unspecified duration is omitted so the endpoint's own default ("auto") applies instead of the enum minimum.
kw = dict(prompt="x", image_url=None, aspect_ratio="16:9", negative_prompt=None, audio=None, seed=None)
assert _build_payload(meta, duration=18, resolution="4k", **kw)["duration"] == 10
assert _build_payload(meta, duration=18, resolution="2k", **kw)["duration"] == 10
assert _build_payload(meta, duration=18, resolution="1080p", **kw)["duration"] == 18
assert "duration" not in _build_payload(meta, duration=None, resolution="4k", **kw)
def test_kling_o3_payload(self):
"""Kling O3: string duration, i2v drops aspect_ratio, no seed."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["kling-o3"]
p = _build_payload(
meta,
prompt="a mecha lands",
image_url=None,
duration=5,
aspect_ratio="16:9",
resolution="1080p",
negative_prompt=None,
audio=True,
seed=3,
)
# No resolution/seed keys in the O3 schema; duration is the usual
# stringified queue-API form.
assert p == {
"prompt": "a mecha lands",
"aspect_ratio": "16:9",
"duration": "5",
"generate_audio": True,
}
p = _build_payload(
meta,
prompt="animate",
image_url="https://example.com/f.png",
duration=2,
aspect_ratio="16:9",
resolution="1080p",
negative_prompt=None,
audio=False,
seed=None,
)
assert "aspect_ratio" not in p
assert p["duration"] == "3" # clamped to the 3-15 range
assert p["generate_audio"] is False
# Only prompt — no payload bloat for fields we can't verify
assert p == {"prompt": "a horse galloping"}
class TestUpscalePass: