From 3cb2301bb9e72b190de45ffa30670814b399234b Mon Sep 17 00:00:00 2001 From: Pavel-Tk <110731619+Pavel-Tk@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:22:09 +0000 Subject: [PATCH] Send live session completion and agent display names Signed-off-by: Pavel-Tk <110731619+Pavel-Tk@users.noreply.github.com> --- docs/extraction-manifest.json | 22 ++-- plugins/substrate/README.md | 6 +- plugins/substrate/onboarding.py | 16 ++- plugins/substrate/plugin.py | 126 ++++++++++++++++++++++ plugins/substrate/plugin.yaml | 5 +- plugins/substrate/setup.py | 3 + scripts/verify_public_plugin_candidate.py | 19 ++-- tests/test_retrieval_onboarding.py | 41 +++++++ tests/test_retrieval_plugin.py | 2 +- tests/test_retrieval_session_capture.py | 88 +++++++++++++++ 10 files changed, 306 insertions(+), 22 deletions(-) create mode 100644 tests/test_retrieval_session_capture.py diff --git a/docs/extraction-manifest.json b/docs/extraction-manifest.json index 8adec59..e8abf53 100644 --- a/docs/extraction-manifest.json +++ b/docs/extraction-manifest.json @@ -125,7 +125,7 @@ "class": "standalone_repository_policy_or_test", "path": "plugins/substrate/README.md", "reason": "Usage documentation for the independent Substrate retrieval plugin.", - "sha256": "dff165ab8024d2788d52e4653e57c425cf98a8f24d1c13a5b3b0bead38287c40" + "sha256": "547e6b106337dd2fed2148fc4273fd90360822652dad4941533d85bef951838d" }, { "class": "standalone_plugin_implementation", @@ -173,25 +173,25 @@ "class": "standalone_plugin_implementation", "path": "plugins/substrate/onboarding.py", "reason": "Automatic RFC 8628 device onboarding for the Substrate retrieval plugin.", - "sha256": "19708eb1ce0cb4ddc5a64523cad351d0794ce4c8397079add2098c3905a66629" + "sha256": "5cb19fd1bcdac20ef61091329e20c47c381212bac07578c6da4d480583978a31" }, { "class": "standalone_plugin_implementation", "path": "plugins/substrate/plugin.py", "reason": "Hermes hooks and tools for Substrate retrieval.", - "sha256": "b9004cde31173ad2037c7b21b5975b05b7360efed5ae4f07a78594ed037ff3c7" + "sha256": "7289109843f5325713e7a02e311078d4dd9ce1fc18d481124b343c50b952abdf" }, { "class": "standalone_plugin_implementation", "path": "plugins/substrate/plugin.yaml", "reason": "Native Hermes manifest for Substrate retrieval.", - "sha256": "dcc3a7ce11e16f474deb1887c92bc628ade01d57ebbe2ad455aca8357e9099c9" + "sha256": "5c52cf65a93525d3569a0935ca56a6398ffd8f413af289b19bcfe5902a10a03b" }, { "class": "standalone_plugin_implementation", "path": "plugins/substrate/setup.py", "reason": "Passwordless active-profile setup and authenticated preflight.", - "sha256": "caec7593ba38e6849ec65d4ba1e1951a35e07fdcc68dd5f32df335b75554f202" + "sha256": "488a0c51bf0ea534aa75c968416c40baec09f8dc8eef10fd9f112010b089ac4a" }, { "class": "standalone_repository_policy_or_test", @@ -245,13 +245,19 @@ "class": "standalone_repository_policy_or_test", "path": "tests/test_retrieval_onboarding.py", "reason": "Onboarding automation tests for the independent Substrate retrieval plugin.", - "sha256": "866b7637fcdb8120b22fe2c822b18e7b211d4dc447e702af47cb2bd89b343127" + "sha256": "87a4b15e34f809160b33d4c94362a94a82fa93bd46e2b6ba6aa68aea10ef07b0" }, { "class": "standalone_repository_policy_or_test", "path": "tests/test_retrieval_plugin.py", "reason": "Behavior and transport tests for the independent Substrate retrieval plugin.", - "sha256": "768cbaefad2efcf49eaf24772410ddbf03373b54714113bc392e375626ea5b49" + "sha256": "206fa1b00515372ccd9827b1bd2ce46c1bb08bb3e53637104970c216e09d17ea" + }, + { + "class": "standalone_repository_policy_or_test", + "path": "tests/test_retrieval_session_capture.py", + "reason": "Updated by PR #33: live session capture and agent display names.", + "sha256": "674b25023225ba72bb34aff83b971f5f70c1ce9c0739463fd8077e7077e0b70e" }, { "class": "standalone_repository_policy_or_test", @@ -455,7 +461,7 @@ { "class": "build-and-install", "destination": "scripts/verify_public_plugin_candidate.py", - "destination_sha256": "677d119930eadc663cc8ef64587890add8a0c7db4ddd81692d50a061953d2829", + "destination_sha256": "5f53e191cab7045b83e38645ca81a24cf04f0610f84f155126b2d17e89f9cc6e", "source": "scripts/verify_public_plugin_candidate.py", "source_sha256": "4130935d530075fce1758e2e89bd5d973a722e2293b5b1058cfe0d17f326172b", "transformation": "modified_for_standalone" diff --git a/plugins/substrate/README.md b/plugins/substrate/README.md index f4b5214..d501e97 100644 --- a/plugins/substrate/README.md +++ b/plugins/substrate/README.md @@ -39,8 +39,10 @@ The plugin registers: - `post_llm_call` for nonblocking full completed-turn capture; - `memory_search`, `memory_expand`, and `memory_evidence`. -Optional overrides are `SUBSTRATE_API_URL`, `SUBSTRATE_API_KEY`, and -`SUBSTRATE_WIKI_ORIGIN`. The active profile's secure plugin state is used when these are +Optional overrides are `SUBSTRATE_API_URL`, `SUBSTRATE_API_KEY`, +`SUBSTRATE_WIKI_ORIGIN`, and `SUBSTRATE_AGENT_NAME`. The agent name is what +you and your agents see in the Substrate agent pane; the approval page lets +you edit it before approving, and it can be renamed there later. The active profile's secure plugin state is used when these are unset. The bundled CA files are unmodified public ISRG roots, loaded in addition to system trust: diff --git a/plugins/substrate/onboarding.py b/plugins/substrate/onboarding.py index 4231dea..f1216e1 100644 --- a/plugins/substrate/onboarding.py +++ b/plugins/substrate/onboarding.py @@ -47,6 +47,7 @@ CLIENT_ID = "substrate-hermes" SCOPES = "capture retrieve" +MAX_AGENT_NAME_BYTES = 64 DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" ENV_KEY = "SUBSTRATE_API_KEY" DEFAULT_ORIGIN = "https://vm-substrate-ar-01.taile961d2.ts.net:10000" @@ -87,6 +88,13 @@ def active_home() -> Path: return (homes[0] if homes else Path.home() / ".hermes").resolve() +def resolve_agent_name() -> str: + """A human-readable name for this agent, shown in the approval page.""" + candidate = os.environ.get("SUBSTRATE_AGENT_NAME") or os.environ.get("HERMES_PROFILE") or "" + candidate = _clip(" ".join(str(candidate).split()), MAX_AGENT_NAME_BYTES) + return candidate + + def resolve_origin() -> str: return ( os.environ.get("SUBSTRATE_API_URL") @@ -327,6 +335,7 @@ def describe(self, state: dict[str, Any]) -> dict[str, Any]: ), "user_code": _clip(str(state.get("user_code", "")), 16), "expires_in": max(0, int(float(state.get("expires_at", now)) - now)), + "agent_name": _clip(str(state.get("agent_name", "")), MAX_AGENT_NAME_BYTES), } if phase in {"failed", "declined", "invalid"}: return { @@ -362,7 +371,11 @@ def _begin(self) -> dict[str, Any]: status, value = request_json( self.origin, "/oauth/device_authorization", - form={"client_id": CLIENT_ID, "scope": SCOPES}, + form={ + "client_id": CLIENT_ID, + "scope": SCOPES, + "agent_name": resolve_agent_name(), + }, timeout=BEGIN_TIMEOUT_SECONDS, ) except OnboardingError as exc: @@ -385,6 +398,7 @@ def _begin(self) -> dict[str, Any]: state = { "phase": "pending", "user_code": user_code, + "agent_name": _clip(str(value.get("agent_name") or resolve_agent_name()), MAX_AGENT_NAME_BYTES), "verification_uri_complete": complete, "interval": max(1, min(int(value.get("interval", 5)), 60)), "expires_at": now + max(1, min(int(value.get("expires_in", 900)), 3600)), diff --git a/plugins/substrate/plugin.py b/plugins/substrate/plugin.py index 124e8f4..152d418 100644 --- a/plugins/substrate/plugin.py +++ b/plugins/substrate/plugin.py @@ -208,6 +208,10 @@ def _onboarding_notice(status: dict[str, Any]) -> str: link = str(status.get("verification_uri_complete", "")) code = str(status.get("user_code", "")) expires = int(status.get("expires_in", 0)) + name = str(status.get("agent_name", "")) + name_line = ( + f"Agent name (editable on the approval page): {name}\n" if name else "" + ) return ( "\n" "Substrate memory needs one-time browser approval. Show this link to the " @@ -217,6 +221,7 @@ def _onboarding_notice(status: dict[str, Any]) -> str: f"One-time code: {code} (valid for {expires} seconds). After approval the " "key is stored privately for this profile and memory works automatically. " "Never ask the user to paste a key into chat.\n" + f"{name_line}" "" ) @@ -443,6 +448,101 @@ def _run(self) -> None: _CAPTURE_WORKER = _CaptureWorker() +# Sessions whose end has already been queued for materialization. Hermes fires +# on_session_end per turn, so boundary hooks must deduplicate; the set is +# bounded and process-local, and the server deduplicates by event id anyway. +_SENT_SESSIONS: dict[str, int] = {} +_SENT_SESSIONS_LOCK = threading.Lock() +_MAX_SENT_SESSIONS = 4096 + + +def _session_envelope( + session_id: Any, + boundary: str, + *, + platform: Any = "", + chat_type: Any = "", + next_session_id: Any = "", + parent_session_id: Any = "", + high_water: int = 0, +) -> dict[str, Any] | None: + """Build a validated capture_session envelope for a session boundary.""" + sid = _bounded_text(session_id, contract.MAX_SESSION_ID_BYTES) + if not sid: + return None + event_id = str(uuid.uuid4()) + envelope: dict[str, Any] = { + "schema_version": contract.SCHEMA_VERSION, + "contract_version": contract.CONTRACT_VERSION, + "event_id": event_id, + "kind": "capture_session", + "session_id": sid, + "offset": {"start": 0, "end": max(0, high_water)}, + "capture_origin": "live", + "batch_id": "", + "speaker": {"id": "user", "role": "owner", "display": ""}, + "created_at": _utc_now(), + "payload": { + "boundary": boundary, + "session_complete": True, + "message_high_water": max(0, high_water), + "platform": _bounded_text(platform, 32) or "cli", + "chat_type": _bounded_text(chat_type, 32) or "direct", + }, + } + if next_session_id: + envelope["payload"]["next_session_id"] = _bounded_text(next_session_id, contract.MAX_SESSION_ID_BYTES) + if parent_session_id: + envelope["payload"]["parent_session_id"] = _bounded_text(parent_session_id, contract.MAX_SESSION_ID_BYTES) + try: + contract.validate_envelope(envelope, idempotency_key=event_id) + except contract.ContractError: + return None + return envelope + + +def end_session( + session_id: Any = "", + *, + boundary: str = "end", + platform: Any = "", + chat_type: Any = "", + next_session_id: Any = "", + parent_session_id: Any = "", + high_water: int = 0, + **kwargs: Any, +) -> None: + """Queue a live session-complete marker for server-side materialization. + + Idempotent per session: repeated boundary callbacks for one session are + ignored. Every failure is swallowed; session capture never blocks the host. + """ + try: + sid = _bounded_text(session_id, contract.MAX_SESSION_ID_BYTES) + if not sid or boundary not in contract.BOUNDARIES: + return + with _SENT_SESSIONS_LOCK: + if sid in _SENT_SESSIONS: + return + if len(_SENT_SESSIONS) >= _MAX_SENT_SESSIONS: + _SENT_SESSIONS.clear() + _SENT_SESSIONS[sid] = 1 + envelope = _session_envelope( + sid, + boundary, + platform=platform or kwargs.get("platform", ""), + chat_type=chat_type or kwargs.get("chat_type", ""), + next_session_id=next_session_id or kwargs.get("new_session_id", "") + or kwargs.get("next_session_id", ""), + parent_session_id=parent_session_id or kwargs.get("old_session_id", "") + or kwargs.get("parent_session_id", ""), + high_water=high_water or 0, + ) + if envelope is not None: + _CAPTURE_WORKER.enqueue(envelope) + except Exception: + pass + def sync_turn( user_content: Any = "", @@ -484,6 +584,26 @@ def post_llm_call( ) +def on_session_reset(**kwargs: Any) -> None: + """Hermes /new boundary: mark the previous session complete.""" + end_session( + kwargs.get("old_session_id") or kwargs.get("session_id") or "", + boundary="reset", + platform=kwargs.get("platform", ""), + next_session_id=kwargs.get("new_session_id", ""), + parent_session_id=kwargs.get("old_session_id", ""), + ) + + +def on_session_finalize(**kwargs: Any) -> None: + """CLI shutdown or gateway session-expiry boundary.""" + end_session( + kwargs.get("session_id") or "", + boundary="end", + platform=kwargs.get("platform", ""), + ) + + # --------------------------------------------------------------------------- # Retrieval tools # --------------------------------------------------------------------------- @@ -562,6 +682,7 @@ def _onboarding_result(status: dict[str, Any]) -> str: link = str(status.get("verification_uri_complete", "")) code = str(status.get("user_code", "")) expires = int(status.get("expires_in", 0)) + name = str(status.get("agent_name", "")) return json.dumps( { "status": "authorization_required", @@ -572,10 +693,12 @@ def _onboarding_result(status: dict[str, Any]) -> str: "stored privately in this profile and never needs to be pasted " "into chat. After approving, call memory_search again; " "authorization completes automatically." + + (f" This agent will be named '{name}' (editable on the approval page)." if name else "") ), "verification_uri_complete": link, "user_code": code, "expires_in": expires, + "agent_name": name, }, sort_keys=True, ) @@ -739,6 +862,9 @@ def register(ctx: Any) -> None: # Current Hermes post_llm_call includes the finalized full conversation in # conversation_history, plus user_message and assistant_response. ctx.register_hook("post_llm_call", post_llm_call) + # Session boundaries feed live-session materialization on the server. + ctx.register_hook("on_session_reset", on_session_reset) + ctx.register_hook("on_session_finalize", on_session_finalize) ctx.register_tool( name="memory_search", toolset=TOOLSET, schema=MEMORY_SEARCH_SCHEMA, handler=memory_search ) diff --git a/plugins/substrate/plugin.yaml b/plugins/substrate/plugin.yaml index 46cf04c..6f80361 100644 --- a/plugins/substrate/plugin.yaml +++ b/plugins/substrate/plugin.yaml @@ -1,5 +1,5 @@ name: substrate -version: 0.2.0 +version: 0.3.0 description: Substrate retrieval and completed-turn capture for Hermes 0.21.x provides_tools: - memory_search @@ -8,7 +8,10 @@ provides_tools: provides_hooks: - pre_llm_call - post_llm_call + - on_session_reset + - on_session_finalize optional_env: - SUBSTRATE_API_URL - SUBSTRATE_API_KEY - SUBSTRATE_WIKI_ORIGIN + - SUBSTRATE_AGENT_NAME diff --git a/plugins/substrate/setup.py b/plugins/substrate/setup.py index 3223ba5..f685430 100755 --- a/plugins/substrate/setup.py +++ b/plugins/substrate/setup.py @@ -219,6 +219,7 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Connect the active Hermes profile to Substrate") parser.add_argument("--hermes-home", type=Path) parser.add_argument("--origin") + parser.add_argument("--name", help="display name shown for this agent in Substrate") args = parser.parse_args(argv) homes = _profile_homes() active_home = (homes[0] if homes else Path.home() / ".hermes").resolve() @@ -226,6 +227,8 @@ def main(argv: list[str] | None = None) -> int: if home != active_home: print(json.dumps({"status": "failed", "error": "active_profile_mismatch"}), flush=True) return 1 + if args.name: + os.environ["SUBSTRATE_AGENT_NAME"] = str(args.name)[:64] origin = _origin( args.origin or os.environ.get("SUBSTRATE_API_URL") diff --git a/scripts/verify_public_plugin_candidate.py b/scripts/verify_public_plugin_candidate.py index 4d7b543..b5dff17 100644 --- a/scripts/verify_public_plugin_candidate.py +++ b/scripts/verify_public_plugin_candidate.py @@ -138,7 +138,7 @@ "plugins/substrate/README.md": frozenset({"35b846e7abdb4faad9382c015f6668d4c4bcbef1e5bb49b27b51368634cfd07c"}), "plugins/substrate/client.py": frozenset({"a1b0718e6e9435d0b5530bd0baa8bdb0944c47cbdd4b74c5058267ac8eed33f8"}), "plugins/substrate/contract.py": frozenset({"f8dabf7d1e8f0c8ce5b6f1dc3529ef5d751b6b98dec1f59705392ff099e24346"}), - "plugins/substrate/plugin.yaml": frozenset({"86e28bce3ab3f2c1d25f09be111776c21f88c2e9f03037cfd6b3cd1f2ad9b0f4"}), + "plugins/substrate/plugin.yaml": frozenset({"86e28bce3ab3f2c1d25f09be111776c21f88c2e9f03037cfd6b3cd1f2ad9b0f4", "5c52cf65a93525d3569a0935ca56a6398ffd8f413af289b19bcfe5902a10a03b"}), "tests/test_publication_scanner.py": frozenset( { "c2352cc593ab08d452479f9ed59651e95acdf0cd5511a94f3830e7910bf65a21", @@ -286,14 +286,15 @@ 'f5f87125f1edd37bff1d44301d6bb0f44cc7faf3ba6122bdbe7569f349fea7a3'}), 'tests/test_memory_provider.py': frozenset({'2c4517847dfad341063a69afcc737316a74574471fc98a45eaff21cfe4e271fd', 'cc967199b8e877a8088937a0c951e80f188ad4c7f29a98067bf649a2f8500b72'}), - 'plugins/substrate/onboarding.py': frozenset({'19708eb1ce0cb4ddc5a64523cad351d0794ce4c8397079add2098c3905a66629'}), - 'tests/test_retrieval_onboarding.py': frozenset({'866b7637fcdb8120b22fe2c822b18e7b211d4dc447e702af47cb2bd89b343127'}), - 'plugins/substrate/plugin.py': frozenset({'b9004cde31173ad2037c7b21b5975b05b7360efed5ae4f07a78594ed037ff3c7'}), - 'plugins/substrate/README.md': frozenset({'dff165ab8024d2788d52e4653e57c425cf98a8f24d1c13a5b3b0bead38287c40'}), + 'plugins/substrate/onboarding.py': frozenset({'19708eb1ce0cb4ddc5a64523cad351d0794ce4c8397079add2098c3905a66629', '5cb19fd1bcdac20ef61091329e20c47c381212bac07578c6da4d480583978a31'}), + 'tests/test_retrieval_onboarding.py': frozenset({'866b7637fcdb8120b22fe2c822b18e7b211d4dc447e702af47cb2bd89b343127', '87a4b15e34f809160b33d4c94362a94a82fa93bd46e2b6ba6aa68aea10ef07b0'}), + 'plugins/substrate/plugin.py': frozenset({'7289109843f5325713e7a02e311078d4dd9ce1fc18d481124b343c50b952abdf', 'b9004cde31173ad2037c7b21b5975b05b7360efed5ae4f07a78594ed037ff3c7'}), + 'plugins/substrate/README.md': frozenset({'547e6b106337dd2fed2148fc4273fd90360822652dad4941533d85bef951838d', 'dff165ab8024d2788d52e4653e57c425cf98a8f24d1c13a5b3b0bead38287c40'}), 'plugins/substrate/client.py': frozenset({'61ece2e3c21517d34341a1df13fb6782e844100c25c1020c679b62e28c22885b'}), - 'plugins/substrate/setup.py': frozenset({'caec7593ba38e6849ec65d4ba1e1951a35e07fdcc68dd5f32df335b75554f202'}), - 'tests/test_retrieval_plugin.py': frozenset({'768cbaefad2efcf49eaf24772410ddbf03373b54714113bc392e375626ea5b49'}), + 'plugins/substrate/setup.py': frozenset({'488a0c51bf0ea534aa75c968416c40baec09f8dc8eef10fd9f112010b089ac4a', 'caec7593ba38e6849ec65d4ba1e1951a35e07fdcc68dd5f32df335b75554f202'}), + 'tests/test_retrieval_plugin.py': frozenset({'206fa1b00515372ccd9827b1bd2ce46c1bb08bb3e53637104970c216e09d17ea', '768cbaefad2efcf49eaf24772410ddbf03373b54714113bc392e375626ea5b49'}), 'tests/test_retrieval_setup.py': frozenset({'109b1721f12223493aec3528703c7c65db1b58355efa6c06f04821ddb9c0fe33'}), + 'tests/test_retrieval_session_capture.py': frozenset({'674b25023225ba72bb34aff83b971f5f70c1ce9c0739463fd8077e7077e0b70e'}), } for _path, _digests in _HOSTED_ONBOARDING_EXACT_ALLOWLIST.items(): SYNTHETIC_FILE_SHA256_ALLOWLIST[_path] = ( @@ -305,10 +306,10 @@ # only that sorted policy projection, so ordinary byte changes do not change # policy. Adding, moving, or reclassifying a file requires explicit review. TRUSTED_INVENTORY_POLICY_SHA256 = ( - "a36a7a604d07d2478936c8f614d13adf41a34b41a8873e792338a03579a12884" + "c850a1780d6de7b73d071780bfda56ac1acebb2359dfdde69679220a8ef7713c" ) TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = ( - "1af56d541392836ea5fe0f5b38271b9d391e70a2193573bc419716e4fc1fa1b2" + "6f7546ccc968b1e18a9b74f48ed895185f0f2c6068ecdcae531a0fd2c9e11732" ) SCANNER_PATH = "scripts/verify_public_plugin_candidate.py" DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json" diff --git a/tests/test_retrieval_onboarding.py b/tests/test_retrieval_onboarding.py index 58514be..9f34976 100644 --- a/tests/test_retrieval_onboarding.py +++ b/tests/test_retrieval_onboarding.py @@ -219,3 +219,44 @@ def test_pre_llm_call_without_key_returns_connect_notice(monkeypatch, tmp_path): result = plugin.pre_llm_call("s", "hello", []) assert result and result["context"].startswith("") assert "https://memory.example/oauth/device?user_code=ABCD-EFGH" in result["context"] + + +def test_agent_name_is_sent_with_the_grant_and_surfaced(monkeypatch, tmp_path): + home = tmp_path + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("SUBSTRATE_AGENT_NAME", "Henry's Hermes") + monkeypatch.setattr(onboarding, "active_home", lambda: home.resolve()) + seen = {} + + def fake_request_json(origin, path, *, form=None, **kwargs): + if path == "/oauth/device_authorization": + seen["form"] = form + return (200, { + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri_complete": "https://memory.example/oauth/device?user_code=ABCD-EFGH", + "expires_in": 300, + "interval": 1, + "agent_name": "Henry's Hermes", + }) + raise AssertionError("unexpected request") + + monkeypatch.setattr(onboarding, "request_json", fake_request_json) + manager = onboarding.OnboardingManager(home.resolve(), "https://memory.example") + status = manager.ensure(force=True) + assert seen["form"]["agent_name"] == "Henry's Hermes" + assert status["status"] == "authorization_pending" + assert status["agent_name"] == "Henry's Hermes" + state = json.loads((home / "substrate" / "onboarding.json").read_text()) + assert state["agent_name"] == "Henry's Hermes" + # device codes stay private; names are not credentials and may be in state + assert "device_code" not in state + + +def test_agent_name_falls_back_to_hermes_profile(monkeypatch, tmp_path): + monkeypatch.delenv("SUBSTRATE_AGENT_NAME", raising=False) + monkeypatch.setenv("HERMES_PROFILE", "telegram-henry") + assert onboarding.resolve_agent_name() == "telegram-henry" + monkeypatch.delenv("HERMES_PROFILE", raising=False) + monkeypatch.delenv("SUBSTRATE_AGENT_NAME", raising=False) + assert onboarding.resolve_agent_name() == "" diff --git a/tests/test_retrieval_plugin.py b/tests/test_retrieval_plugin.py index c1270c8..48cb3ee 100644 --- a/tests/test_retrieval_plugin.py +++ b/tests/test_retrieval_plugin.py @@ -299,7 +299,7 @@ def register_system_prompt_section(self, id, content, *, position, max_chars): ctx = Context() thread_before = plugin._CAPTURE_WORKER._thread plugin.register(ctx) - assert set(ctx.hooks) == {"pre_llm_call", "post_llm_call"} + assert set(ctx.hooks) == {"pre_llm_call", "post_llm_call", "on_session_reset", "on_session_finalize"} assert set(ctx.tools) == {"memory_search", "memory_expand", "memory_evidence"} assert ctx.prompts == { "substrate.memory": (plugin.STATIC_MEMORY_PROMPT, "after_memory", 2000) diff --git a/tests/test_retrieval_session_capture.py b/tests/test_retrieval_session_capture.py new file mode 100644 index 0000000..fb586e4 --- /dev/null +++ b/tests/test_retrieval_session_capture.py @@ -0,0 +1,88 @@ +"""Live session-completion capture tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugins")) + +from substrate import contract +from substrate import plugin + + +@pytest.fixture(autouse=True) +def _reset_sent_sessions(): + with plugin._SENT_SESSIONS_LOCK: + plugin._SENT_SESSIONS.clear() + yield + with plugin._SENT_SESSIONS_LOCK: + plugin._SENT_SESSIONS.clear() + + +def test_session_envelope_is_contract_valid(): + envelope = plugin._session_envelope("session-1", "reset", platform="telegram", chat_type="direct", next_session_id="session-2") + assert envelope is not None + assert envelope["kind"] == "capture_session" + assert envelope["payload"]["boundary"] == "reset" + assert envelope["payload"]["session_complete"] is True + assert envelope["payload"]["next_session_id"] == "session-2" + contract.validate_envelope(envelope, idempotency_key=envelope["event_id"]) + + +def test_session_envelope_rejects_invalid_boundary(): + assert plugin._session_envelope("s", "not-a-boundary") is None + + +def test_end_session_enqueues_once_and_ignores_repeats(monkeypatch): + queued = [] + monkeypatch.setattr(plugin._CAPTURE_WORKER, "enqueue", queued.append) + plugin.end_session("session-1", boundary="end") + plugin.end_session("session-1", boundary="reset") + plugin.end_session("session-1", boundary="end") + assert len(queued) == 1 + assert queued[0]["kind"] == "capture_session" + assert queued[0]["payload"]["session_complete"] is True + + +def test_end_session_swallows_all_failures(monkeypatch): + def boom(*args, **kwargs): + raise RuntimeError("nope") + monkeypatch.setattr(plugin._CAPTURE_WORKER, "enqueue", boom) + plugin.end_session("session-1", boundary="end") # must not raise + + +def test_on_session_reset_uses_old_session_id(monkeypatch): + queued = [] + monkeypatch.setattr(plugin._CAPTURE_WORKER, "enqueue", queued.append) + plugin.on_session_reset(old_session_id="old-1", new_session_id="new-1", platform="telegram") + assert len(queued) == 1 + envelope = queued[0] + assert envelope["session_id"] == "old-1" + assert envelope["payload"]["boundary"] == "reset" + assert envelope["payload"]["next_session_id"] == "new-1" + + +def test_on_session_finalize_uses_session_id(monkeypatch): + queued = [] + monkeypatch.setattr(plugin._CAPTURE_WORKER, "enqueue", queued.append) + plugin.on_session_finalize(session_id="s-final", platform="cli") + assert queued[0]["session_id"] == "s-final" + assert queued[0]["payload"]["boundary"] == "end" + + +def test_register_includes_session_hooks(): + registered = {} + class Ctx: + def register_hook(self, name, cb): + registered[name] = cb + def register_tool(self, **kwargs): + pass + def register_system_prompt_section(self, *a, **k): + pass + plugin.register(Ctx()) + assert "on_session_reset" in registered + assert "on_session_finalize" in registered + assert registered["on_session_reset"] is plugin.on_session_reset