chore: anchor fresh-start history to upstream
This commit is contained in:
@@ -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
|
||||
@@ -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')}"
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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")
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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="")
|
||||
@@ -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}'
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user