Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
"""Tests for the Photon auth module (device login + dashboard API)."""
from __future__ import annotations
import json
import os
import stat
import threading
import time
from base64 import b64encode
from pathlib import Path
from typing import Any, Dict
from unittest import mock
import pytest
from plugins.platforms.photon import auth as photon_auth
# ---------------------------------------------------------------------------
# Fake httpx — we don't want to hit the real Photon API in unit tests.
class _FakeResponse:
def __init__(
self,
*,
status: int = 200,
json_body: Any = None,
headers: Dict[str, str] | None = None,
text: str = "",
) -> None:
self.status_code = status
self._json = json_body if json_body is not None else {}
self.headers = headers or {}
self.text = text
def json(self) -> Any:
return self._json
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
_PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_SPECTRUM_HOST",
"PHOTON_ALLOWED_USERS",
"PHOTON_HOME_CHANNEL",
)
@pytest.fixture
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for key in _PHOTON_ENV:
monkeypatch.delenv(key, raising=False)
yield home
# save_env_value() mutates os.environ directly, so scrub any leakage.
for key in _PHOTON_ENV:
os.environ.pop(key, None)
# ---------------------------------------------------------------------------
# Credential storage
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
photon_auth.store_photon_token("abc123def456")
assert photon_auth.load_photon_token() == "abc123def456"
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
@pytest.mark.skipif(os.name != "posix", reason="POSIX mode bits only")
def test_save_auth_never_world_readable(tmp_hermes_home: Path) -> None:
"""auth.json must be created 0o600 — no window at process umask."""
photon_auth.store_photon_token("secret-token")
mode = (tmp_hermes_home / "auth.json").stat().st_mode & 0o777
assert mode == 0o600
def test_store_project_credentials_round_trip(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
# Don't touch .env / os.environ here — exercise the auth.json path.
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
photon_auth.store_project_credentials(
spectrum_project_id="sp-123",
project_secret="secret-key",
dashboard_project_id="dash-456",
name="Hermes Agent",
)
for key in _PHOTON_ENV:
monkeypatch.delenv(key, raising=False)
sid, secret = photon_auth.load_project_credentials()
assert sid == "sp-123"
assert secret == "secret-key"
# Post-unification the management id resolves to the Spectrum id, not the
# stored dashboard id — so a pre-backfill diverged install (whose old
# dashboard id was rewritten and now 404s) still reaches the live row.
assert photon_auth.load_dashboard_project_id() == "sp-123"
def test_load_user_numbers_falls_back_to_home_channel(
tmp_hermes_home: Path,
) -> None:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned is None
def test_refresh_user_numbers_reads_existing_assignment(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
photon_auth.store_user_numbers(phone_number="+15551234567")
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
assert kwargs.get("headers", {}).get("Authorization") == (
"Basic " + b64encode(b"sp:secret").decode("ascii")
)
assert url.endswith("/projects/sp/users/")
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
"id": "user-uuid",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
assert phone == "+15551234567"
assert assigned == "+16282679185"
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
def test_load_project_credentials_env_override(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
photon_auth.store_project_credentials(
spectrum_project_id="from-file", project_secret="secret-file",
)
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
sid, secret = photon_auth.load_project_credentials()
assert sid == "from-env"
assert secret == "secret-env"
# ---------------------------------------------------------------------------
# Cross-process auth.json lock (issue: photon wrote auth.json without the
# cross-process lock hermes_cli/auth.py's ~15 other writers all use, so a
# concurrent refresh from elsewhere could silently lose photon's update or
# vice versa).
def _hold_auth_lock_then_release(hold_event: threading.Event, release_event: threading.Event) -> None:
from hermes_cli.auth import _auth_store_lock
with _auth_store_lock():
hold_event.set()
release_event.wait(timeout=5)
# ---------------------------------------------------------------------------
# Device login flow
def test_request_device_code_uses_photon_cli(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["body"] = kwargs.get("json")
return _FakeResponse(json_body={
"device_code": "dev-code-xyz",
"user_code": "ABCD-1234",
"verification_uri": "https://app.photon.codes/device",
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
"expires_in": 600,
"interval": 5,
})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
code = photon_auth.request_device_code()
assert code.device_code == "dev-code-xyz"
assert code.user_code == "ABCD-1234"
assert "/api/auth/device/code" in captured["url"]
# Hosted Photon allowlists registered device clients — an unregistered
# client_id is rejected with 400 invalid_client. We use Photon's published
# CLI device client and send the standard scope.
assert captured["body"]["client_id"] == "photon-cli"
assert captured["body"]["scope"] == "openid profile email"
def _device_code() -> "photon_auth.DeviceCode":
return photon_auth.DeviceCode(
device_code="d", user_code="u",
verification_uri="https://x", verification_uri_complete=None,
expires_in=10, interval=0,
)
def test_poll_for_token_body_access_token(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(status=200, json_body={"access_token": "tok-body"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-body"
# ---------------------------------------------------------------------------
# Projects
def test_list_projects_unwraps_list(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body=[{"id": "p1", "name": "Hermes Agent"}])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
projects = photon_auth.list_projects("tok")
assert projects[0]["id"] == "p1"
def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"data": [
{"id": "p1", "name": "Other"},
{"id": "p2", "name": "hermes agent"},
]})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
proj = photon_auth.find_project_by_name("tok", "Hermes Agent")
assert proj is not None and proj["id"] == "p2"
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["body"] = kwargs.get("json")
captured["headers"] = kwargs.get("headers")
return _FakeResponse(json_body={"success": True, "id": "new-proj"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
data = photon_auth.create_project("tok", name="Hermes Agent")
assert data["id"] == "new-proj"
# Spectrum is always provisioned at create-time; the field was dropped
# from the API schema, so we must not send it.
assert "spectrum" not in captured["body"]
assert captured["body"]["name"] == "Hermes Agent"
assert captured["headers"]["Authorization"] == "Bearer tok"
assert captured["url"].endswith("/api/projects")
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
assert url.endswith("/regenerate-secret")
return _FakeResponse(json_body={"success": True, "projectSecret": "rotated"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.regenerate_project_secret("tok", "p") == "rotated"
# ---------------------------------------------------------------------------
# Users
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
posted = {"n": 0}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
"id": "u1",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
posted["n"] += 1
return _FakeResponse(json_body={"success": True, "user": {}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
# Same number, different formatting — should match and NOT create.
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
)
assert created is False
assert user["id"] == "u1"
assert posted["n"] == 0
# The reused user carries the assigned iMessage line ("TEXTS ON").
assert photon_auth.user_assigned_line(user) == "+16282679185"
def test_user_assigned_line() -> None:
assert (
photon_auth.user_assigned_line({"assignedPhoneNumber": "+16282679185"})
== "+16282679185"
)
# Own number present but no assignment yet (e.g. freshly created user).
assert photon_auth.user_assigned_line({"phoneNumber": "+15551234567"}) is None
assert photon_auth.user_assigned_line({"assignedPhoneNumber": ""}) is None
assert photon_auth.user_assigned_line({}) is None
assert photon_auth.user_assigned_line(None) is None
# ---------------------------------------------------------------------------
# Lines (assigned number)
def test_get_imessage_line_returns_existing(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body=[
{"id": "l1", "platform": "imessage", "phoneNumber": "+15559999999", "status": "active"},
])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
line = photon_auth.get_imessage_line("tok", "proj")
assert line is not None and line["phoneNumber"] == "+15559999999"
# ---------------------------------------------------------------------------
# Credential summary (no secret leakage)
def test_credential_summary_no_secret_leak(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
photon_auth.store_project_credentials(
spectrum_project_id="sp-uuid",
project_secret="secret-bbbbbbbbbbb",
dashboard_project_id="dash-uuid",
)
summary = photon_auth.credential_summary()
blob = "\n".join(summary.values())
assert "token-aaaa" not in blob
assert "secret-bbbb" not in blob
assert summary["device_token"].startswith("")
assert summary["project_key"].startswith("")
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
assert summary["project_id"] == "sp-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
# ---------------------------------------------------------------------------
# Device-token candidate extraction + dashboard validation.
def test_device_response_candidates_covers_known_shapes() -> None:
candidates = photon_auth._device_response_token_candidates(
{
"access_token": "tok-snake",
"accessToken": "tok-camel",
"data": {"access_token": "tok-data"},
},
headers={"set-auth-token": "Bearer tok-header"},
)
by_source = {c.source: c.token for c in candidates}
assert by_source["access_token"] == "tok-snake"
assert by_source["accessToken"] == "tok-camel"
assert by_source["data.access_token"] == "tok-data"
# "Bearer " prefix is stripped from the header value.
assert by_source["set-auth-token"] == "tok-header"
def test_validate_photon_token_rejects_unrecognized_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/get-session"):
return _FakeResponse(json_body={}) # no "user" key
return _FakeResponse(json_body=[])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
with pytest.raises(photon_auth.PhotonDashboardAuthError):
photon_auth.validate_photon_token("some-token")
def test_login_device_flow_validates_before_persisting(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/device/code"):
return _FakeResponse(json_body={
"device_code": "dev", "user_code": "AAAA",
"verification_uri": "https://app.photon.codes/device",
"verification_uri_complete": None,
"expires_in": 600, "interval": 0,
})
# device/token approval
return _FakeResponse(json_body={"access_token": "good-token"})
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/get-session"):
return _FakeResponse(json_body={"user": {"id": "u1"}})
return _FakeResponse(json_body=[]) # projects OK
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
# interval=0 falls back to DEFAULT_POLL_INTERVAL inside the poll loop
# ("sleep first, then poll") — stub the sleep so the test doesn't idle 5s.
monkeypatch.setattr(photon_auth.time, "sleep", lambda _s: None)
token = photon_auth.login_device_flow(open_browser=False)
assert token == "good-token"
assert photon_auth.load_photon_token() == "good-token"
@@ -0,0 +1,120 @@
"""Tests for check_requirements() diagnostic logging (fix) and remaining risks.
Fixed in this file (tests PASS with fix, FAIL without):
- check_requirements() now emits a specific logger.warning for each False
condition so gateway logs pinpoint the exact failure reason.
Remaining risks documented here (still open — separate issues):
Risk 2 node_modules dir exists but EMPTY (partial/aborted npm install)
→ check_requirements() returns True (false positive)
Risk 3 _install_sidecar() subprocess.run calls carry no capture_output /
stdout / stderr — npm error output is unrecoverable after the run
"""
from __future__ import annotations
import logging
import shutil
import types
from pathlib import Path
import pytest
from plugins.platforms.photon import adapter as adapter_mod
from plugins.platforms.photon import cli as cli_mod
# ---------------------------------------------------------------------------
# Helpers / shared marks
# ---------------------------------------------------------------------------
_NODE_ON_PATH = shutil.which("node") is not None
_requires_node = pytest.mark.skipif(
not _NODE_ON_PATH,
reason="requires node on PATH to isolate the node_modules check",
)
_requires_node_for_false_positive = pytest.mark.skipif(
not _NODE_ON_PATH,
reason="requires node on PATH so the false-positive path is reachable",
)
# ---------------------------------------------------------------------------
# Fix verification — each False branch now emits a specific warning
# ---------------------------------------------------------------------------
def test_fix_logs_warning_when_httpx_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""When httpx is not installed, check_requirements() must log a warning
that names the missing package so the operator knows what to install."""
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", False)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
(tmp_path / "node_modules").mkdir()
with caplog.at_level(logging.WARNING, logger="plugins.platforms.photon.adapter"):
result = adapter_mod.check_requirements()
assert result is False
messages = [r.message for r in caplog.records]
assert any("httpx" in m for m in messages), (
f"Expected a warning mentioning 'httpx', got: {messages}"
)
# ---------------------------------------------------------------------------
# Risk 2 (open) — empty node_modules directory is a false positive
# ---------------------------------------------------------------------------
@_requires_node_for_false_positive
def test_risk2_fix_empty_node_modules_no_longer_passes_guard(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""npm may create node_modules/ before aborting (network timeout, ENOSPC,
EACCES). Previously an empty directory passed the only filesystem guard in
check_requirements() — returning True with a broken sidecar installation.
Fixed: check_requirements() now verifies node_modules/spectrum-ts exists,
so a partial/empty node_modules/ correctly returns False."""
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
# NS-606: disable the connect-time self-heal branch so the guard itself
# (empty node_modules must not read as installed) is what's under test.
monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False)
(tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent
# Fix verified: False instead of the old false-positive True.
assert adapter_mod.check_requirements() is False
# ---------------------------------------------------------------------------
# Risk 3 fix — npm stderr is captured, persisted, and surfaced by check_requirements
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Shared predicate — status / _start_sidecar / check_requirements must agree
# ---------------------------------------------------------------------------
def test_cli_status_shares_adapter_sidecar_deps_check(tmp_path: Path) -> None:
"""`hermes photon status` must use the exact same spectrum-ts check as
check_requirements() / _start_sidecar() — not a separate node_modules-only
existence check that would disagree on a partial/empty install."""
assert cli_mod.sidecar_deps_installed is adapter_mod.sidecar_deps_installed
def test_sidecar_deps_installed_false_on_empty_node_modules(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
(tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent
assert adapter_mod.sidecar_deps_installed() is False
@@ -0,0 +1,150 @@
"""Photon's fatal-error notification must not be cancelled by its own teardown.
`_monitor_sidecar_health` and `_supervise_sidecar` run as long-lived tasks on
the adapter. When one of them detects a fatal condition, the gateway's handler
tears the adapter down, and `disconnect()` cancels `_sidecar_health_task` and
awaits it. If the notification is awaited inline on the health task's own call
stack, `disconnect()` ends up cancelling its own ultimate caller: the
`task is not asyncio.current_task()` guard in `disconnect()` compares against
the wrapper task the gateway created around `disconnect()`, not the health task
several plain-await frames further up, so the guard passes and the cancel lands.
`CancelledError` stopped subclassing `Exception` in Python 3.8, so the
`except Exception` that used to wrap the inline notify call never saw it. The
health task died silently mid-handoff -- no log line, no retry -- and the
platform stayed stranded until someone restarted the gateway by hand.
PR #69112 hardened the shared dispatch path in `gateway/run.py` against a
cancelled *caller*. These tests cover the Photon-specific self-cancellation one
layer down, which that PR's scope could not reach.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
return PhotonAdapter(PlatformConfig(enabled=True, token="", extra={}))
class TestFatalNotifyIsDetached:
"""The notification must outlive cancellation of the task that raised it."""
@pytest.mark.asyncio
async def test_health_task_cancellation_does_not_kill_notification(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A fatal handler that cancels the health task (exactly what
``disconnect()`` does) must still see the notification delivered."""
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
adapter._sidecar_health_interval = 0
delivered = asyncio.Event()
async def fake_notify() -> None:
# Mirror the real handler: tear the adapter down, cancelling the
# health task, then finish the handoff.
await adapter.disconnect()
delivered.set()
monkeypatch.setattr(adapter, "_notify_fatal_error", fake_notify)
monkeypatch.setattr(adapter, "_stop_sidecar", lambda: _noop())
async def degraded(_path: str, _payload: Dict[str, Any]) -> Dict[str, Any]:
return {"stream": {"ok": False, "state": "degraded", "degradedForMs": 4000,
"lastIssue": "stream persistently failing"}}
monkeypatch.setattr(adapter, "_sidecar_call", degraded)
health = asyncio.create_task(adapter._monitor_sidecar_health())
adapter._sidecar_health_task = health
await asyncio.wait_for(delivered.wait(), timeout=5.0)
assert adapter.has_fatal_error
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
assert adapter.fatal_error_retryable
# With the dispatch detached, the health task reaches its own `break`
# and returns cleanly instead of being cancelled out from under the
# handoff. Either way it must not die with an unhandled exception --
# that was the silent failure that stranded the platform.
await asyncio.wait_for(asyncio.shield(health), timeout=2.0)
assert health.done()
if not health.cancelled():
assert health.exception() is None
@pytest.mark.asyncio
async def test_dispatch_does_not_await_on_caller_stack(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``_dispatch_fatal_notification`` must return without awaiting, so a
cancel aimed at the calling task cannot reach the handoff."""
adapter = _make_adapter(monkeypatch)
started = asyncio.Event()
finished = asyncio.Event()
async def slow_notify() -> None:
started.set()
await asyncio.sleep(0.05)
finished.set()
monkeypatch.setattr(adapter, "_notify_fatal_error", slow_notify)
async def caller() -> None:
adapter._dispatch_fatal_notification() # must not block
task = asyncio.create_task(caller())
await task # returns immediately even though notify sleeps
await asyncio.wait_for(started.wait(), timeout=2.0)
task.cancel() # cancelling the caller must not touch the notification
await asyncio.wait_for(finished.wait(), timeout=2.0)
@pytest.mark.asyncio
async def test_notification_failure_is_logged_not_raised(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A failing notification must warn rather than surface as an
unretrieved task exception."""
adapter = _make_adapter(monkeypatch)
async def boom() -> None:
raise RuntimeError("gateway unreachable")
monkeypatch.setattr(adapter, "_notify_fatal_error", boom)
with caplog.at_level("WARNING"):
await adapter._notify_fatal_error_logged()
assert "fatal-error notification failed" in caplog.text
class TestBothCallSitesDetached:
"""Neither fatal path may await the notification inline."""
def test_no_inline_notify_awaits_remain(self) -> None:
"""Guard against a future edit reintroducing the inline await."""
import inspect
from plugins.platforms.photon import adapter as photon_adapter
for name in ("_monitor_sidecar_health", "_supervise_sidecar"):
src = inspect.getsource(getattr(photon_adapter.PhotonAdapter, name))
assert "await self._notify_fatal_error()" not in src, (
f"{name} awaits _notify_fatal_error inline; use "
f"_dispatch_fatal_notification() so disconnect() cannot cancel "
f"its own caller"
)
assert "_dispatch_fatal_notification()" in src
async def _noop() -> None:
return None
@@ -0,0 +1,260 @@
"""Inbound dispatch + dedup tests for PhotonAdapter.
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
import asyncio
import base64
import json
from pathlib import Path
from typing import Any, Dict, List
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _dm_event(text: str, msg_id: str = "spc-msg-abc") -> Dict[str, Any]:
return {
"messageId": msg_id,
"platform": "iMessage",
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": text},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_event("hello world"))
assert len(captured) == 1
event = captured[0]
assert event.text == "hello world"
assert event.message_type == MessageType.TEXT
assert event.message_id == "spc-msg-abc"
src = event.source
assert src is not None
assert src.platform == Platform("photon")
assert src.chat_id == "+15551234567"
assert src.chat_type == "dm"
assert src.user_id == "+15551234567"
@pytest.mark.asyncio
async def test_dispatch_read_receipt_does_not_wake_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
receipt = _dm_event("", msg_id="spc-read-1")
receipt["content"] = {
"type": "read",
"targetMessageId": "bot-msg-1",
"targetDirection": "outbound",
}
await adapter._dispatch_inbound(receipt)
assert captured == []
@pytest.mark.asyncio
async def test_dispatch_read_receipt_alias_does_not_wake_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Some spectrum-ts streams label receipts ``read_receipt`` — same drop."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
receipt = _dm_event("", msg_id="spc-read-2")
receipt["content"] = {
"type": "read_receipt",
"targetMessageId": "bot-msg-2",
"targetDirection": "outbound",
}
await adapter._dispatch_inbound(receipt)
assert captured == []
# A real 1x1 transparent PNG (passes base.py's _looks_like_image magic check).
_PNG_1X1_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def _attachment_event(
content: Dict[str, Any], msg_id: str = "spc-msg-att"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "attachment", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
def _voice_event(
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "voice", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_on_inbound_line_dispatches_and_dedups(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
line = json.dumps(_dm_event("ping", msg_id="dup-1"))
await adapter._on_inbound_line(line)
await adapter._on_inbound_line(line) # same messageId -> deduped
assert len(captured) == 1
assert captured[0].text == "ping"
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
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:
# If no node binary on PATH the adapter should refuse to start.
from plugins.platforms.photon import adapter as adapter_mod
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
assert adapter_mod.check_requirements() is False
# ---------------------------------------------------------------------------
# CAF attachment promotion + U+FFFC placeholder tests
# ---------------------------------------------------------------------------
_CAF_BYTES = b"caff" + b"\x00" * 60 # Minimal CAF header magic
def _caf_attachment_event(
content: Dict[str, Any], msg_id: str = "spc-msg-caf"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
"sender": {"id": "+155****4567"},
"content": {"type": "attachment", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_caf_attachment_named_promoted_to_voice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A named .caf attachment is promoted to VOICE for STT routing."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = _CAF_BYTES
event = _caf_attachment_event(
{
"name": "voice_note.caf",
"mimeType": "audio/x-caf",
"size": len(raw),
"data": base64.b64encode(raw).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.VOICE
assert ev.media_types == ["audio/x-caf"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
assert ev.text == "(voice)"
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_fffc_placeholder_no_dispatch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A U+FFFC placeholder text does not trigger a message dispatch."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = _dm_event("\ufffc", msg_id="spc-msg-fffc")
chat_key = event["space"]["id"]
await adapter._dispatch_inbound(event)
assert len(captured) == 0
assert chat_key in adapter._pending_fffc
@pytest.mark.asyncio
async def test_disconnect_cancels_pending_fffc_tasks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""disconnect() cancels any pending U+FFFC placeholder tasks."""
adapter = _make_adapter(monkeypatch)
_capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_event("\ufffc", msg_id="spc-msg-fffc"))
assert len(adapter._pending_fffc) == 1
async def _noop_stop_sidecar():
pass
monkeypatch.setattr(adapter, "_stop_sidecar", _noop_stop_sidecar)
monkeypatch.setattr(adapter, "_inbound_running", False)
monkeypatch.setattr(adapter, "_inbound_task", None)
monkeypatch.setattr(adapter, "_sidecar_health_task", None)
monkeypatch.setattr(adapter, "_http_client", None)
await adapter.disconnect()
assert len(adapter._pending_fffc) == 0
@@ -0,0 +1,105 @@
"""Markdown handling tests for PhotonAdapter.
Markdown is on by default (the sidecar sends it via spectrum-ts'
``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false``
reverts to the stripped-plain-text path.
"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
_MD = "**bold** and `code`"
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
def test_format_message_passthrough_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
assert adapter.format_message(_MD) == _MD
def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
assert _make_adapter(monkeypatch).supports_code_blocks is True
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
assert _make_adapter(monkeypatch).supports_code_blocks is False
@pytest.mark.asyncio
async def test_sidecar_send_includes_markdown_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+15551234567", _MD)
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
assert body["text"] == _MD # passed through unstripped
@pytest.mark.asyncio
async def test_standalone_send_includes_markdown_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
posted: List[Tuple[str, Dict[str, Any]]] = []
class _Resp:
status_code = 200
@staticmethod
def json() -> Dict[str, Any]:
return {"ok": True, "messageId": "m-9"}
class _FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url: str, json: Dict[str, Any], headers=None):
posted.append((url, json))
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
cfg = PlatformConfig(enabled=True, token="", extra={})
result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD)
assert result.get("success") is True
assert posted[0][1]["format"] == "markdown"
@@ -0,0 +1,117 @@
"""Group-chat mention-gating tests for PhotonAdapter.
Parity with the BlueBubbles iMessage channel: when ``require_mention`` is
enabled, group messages are dropped unless they hit a wake-word pattern,
and the leading wake word is stripped from the ones that pass. DMs are
never gated.
These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert
on what reaches ``handle_message``.
"""
from __future__ import annotations
from typing import List
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra=extra or {})
return PhotonAdapter(cfg)
def _group_payload(text: str) -> dict:
return {
"messageId": f"grp-{abs(hash(text))}",
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": text},
"timestamp": "2026-05-14T19:06:32.000Z",
}
def _dm_payload(text: str) -> dict:
return {
"messageId": f"dm-{abs(hash(text))}",
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": text},
"timestamp": "2026-05-14T19:06:32.000Z",
}
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
assert adapter.require_mention is False
# Defaults compile to the two Hermes wake-word patterns.
assert len(adapter._mention_patterns) == 2
@pytest.mark.asyncio
async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("just chatting, no wake word"))
assert captured == []
@pytest.mark.asyncio
async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_payload("no wake word here"))
assert len(captured) == 1
assert captured[0].text == "no wake word here"
def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": [r"(?<![\w@])@?amos\b[,:\-]?"]},
)
assert adapter.require_mention is True
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("amos help me") is True
assert adapter._message_matches_mention_patterns("hermes help me") is False
def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b")
cfg = PlatformConfig(enabled=True, token="", extra={})
adapter = PhotonAdapter(cfg)
assert adapter.require_mention is True
assert len(adapter._mention_patterns) == 2
assert adapter._message_matches_mention_patterns("hey bot") is True
def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]},
)
# Bad regex dropped, good one kept.
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("a good thing") is True
@@ -0,0 +1,122 @@
"""Multiplex secondary-profile scope tests for the Photon adapter + auth module.
__init__'s project_id, check_requirements'/validate_config's node_bin/
project_id, _env_enablement's home_channel, _reactions_enabled's
PHOTON_REACTIONS, __init__'s require_mention, and _standalone_send's
sidecar_port, plus auth.py's load_project_credentials/
load_dashboard_project_id, all previously read raw os.getenv
unconditionally (only PHOTON_PROJECT_SECRET/PHOTON_SIDECAR_TOKEN were
already scoped via _get_scoped_secret). Under gateway.multiplex_profiles,
os.environ holds the DEFAULT profile's YAML-to-env bridge output -- a
secondary profile with its own (different or absent) Photon config could
silently authenticate against the default profile's Spectrum project, or
have its mention-gating/reaction behavior driven by the default profile's
settings.
Notably project_id was a stronger variant of the bug (like the IRC fix in
this series): __init__'s original
`os.getenv("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id`
ordering let a raw env read override even an explicitly configured
config.yaml extra.
Mirrors the LINE/DingTalk/IRC/Mattermost fix for #98738.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import auth as photon_auth
from plugins.platforms.photon.adapter import PhotonAdapter
_PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_REQUIRE_MENTION",
"PHOTON_REACTIONS",
"PHOTON_HOME_CHANNEL",
"PHOTON_HOME_CHANNEL_NAME",
"PHOTON_SIDECAR_PORT",
)
@pytest.fixture
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Isolate from the real ~/.hermes/auth.json fallback in load_project_credentials()."""
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for key in _PHOTON_ENV:
monkeypatch.delenv(key, raising=False)
yield home
for key in _PHOTON_ENV:
os.environ.pop(key, None)
@pytest.fixture
def multiplex_scope():
"""Install multiplex + a secondary-profile secret scope; restore after."""
tokens = []
def install(scope=None):
from agent.secret_scope import set_multiplex_active, set_secret_scope
set_multiplex_active(True)
tokens.append(set_secret_scope(scope or {}))
return tokens[-1]
yield install
from agent.secret_scope import reset_secret_scope, set_multiplex_active
for token in reversed(tokens):
reset_secret_scope(token)
set_multiplex_active(False)
@pytest.fixture
def default_profile_env(monkeypatch):
"""The default profile's YAML-to-env bridge output in os.environ."""
monkeypatch.setenv("PHOTON_PROJECT_ID", "default-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "default-project-secret")
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
monkeypatch.setenv("PHOTON_REACTIONS", "true")
class TestAuthMultiplexProfileScope:
"""load_project_credentials / load_dashboard_project_id (auth.py)."""
def test_scoped_miss_does_not_leak_default_project_id(
self, tmp_hermes_home, multiplex_scope, default_profile_env
):
multiplex_scope({"SOMETHING_ELSE": "x"})
sid, secret = photon_auth.load_project_credentials()
assert sid is None
assert secret is None
adapter = PhotonAdapter(PlatformConfig(enabled=True, extra={}))
assert adapter._project_id == ""
assert adapter.require_mention is False
assert adapter._reactions_enabled() is False
class TestAdapterMultiplexProfileScope:
"""PhotonAdapter.__init__ / _env_enablement / _reactions_enabled (adapter.py)."""
def test_secondary_extra_wins_over_default_profile_env(
self, tmp_hermes_home, multiplex_scope, default_profile_env
):
"""A secondary profile's own config.yaml extra project_id must be
authoritative -- not the default profile's bridged env value. The
pre-fix ordering (raw os.getenv checked BEFORE extra) meant even an
explicit extra config was silently overridden."""
multiplex_scope({"PHOTON_PROJECT_SECRET": "profile-secret"})
cfg = PlatformConfig(
enabled=True,
extra={"project_id": "profile-project-id"},
)
adapter = PhotonAdapter(cfg)
assert adapter._project_id == "profile-project-id"
@@ -0,0 +1,238 @@
"""Regression tests for the npm stderr capture + error log persistence fix.
Each test covers a specific failure vector introduced by the Risk 3 solution:
1. _install_sidecar() return code unchanged — still 0 on success, non-zero on failure
2. _install_sidecar() with no npm on PATH — still returns 1, no OSError on log write
3. _NPM_ERROR_LOG write fails (OSError / read-only fs) — silently handled, no exception
4. _NPM_ERROR_LOG read fails in check_requirements() — silently handled, returns False
5. _NPM_ERROR_LOG is empty string — not written, check_requirements() falls back gracefully
6. _NPM_ERROR_LOG from prior failed run exists when next run succeeds — cleared
7. check_requirements() with no _NPM_ERROR_LOG — debug log still emitted without error detail
8. proc.stderr is None (edge case on some platforms) — no AttributeError, no crash
"""
from __future__ import annotations
import logging
import types
from pathlib import Path
import pytest
from plugins.platforms.photon import adapter as adapter_mod
from plugins.platforms.photon import cli as cli_mod
_NODE_ON_PATH = __import__("shutil").which("node") is not None
_requires_node = pytest.mark.skipif(
not _NODE_ON_PATH, reason="requires node on PATH"
)
# ---------------------------------------------------------------------------
# 1. Return code contract unchanged
# ---------------------------------------------------------------------------
def test_regression_return_code_zero_on_success(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""_install_sidecar() must still return 0 on npm success."""
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
assert cli_mod._install_sidecar() == 0
# ---------------------------------------------------------------------------
# 2. OSError on log write — silently swallowed, no crash
# ---------------------------------------------------------------------------
def test_regression_oserror_on_log_write_does_not_propagate(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""If writing _NPM_ERROR_LOG raises OSError (read-only fs, permission denied),
_install_sidecar() must NOT propagate the exception — it still returns the
npm exit code."""
def _bad_log_write(*args, **kwargs):
raise OSError("read-only file system")
error_log = tmp_path / ".photon-npm-error.log"
# Monkey-patch write_text on the Path object via a subclass
class _UnwritablePath(type(error_log)):
def write_text(self, *a, **kw):
raise OSError("read-only file system")
def unlink(self, *a, **kw):
raise OSError("read-only file system")
def exists(self):
return False
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr="npm ERR!"),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", _UnwritablePath(error_log))
rc = cli_mod._install_sidecar()
assert rc == 1 # still returns the npm exit code
# ---------------------------------------------------------------------------
# 3. OSError on log read in check_requirements() — silently swallowed
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 4. Empty stderr — log file NOT written
# ---------------------------------------------------------------------------
def test_regression_empty_stderr_does_not_write_log(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""If npm fails but stderr is empty (some npm versions), _NPM_ERROR_LOG must
NOT be written — an empty file would mislead check_requirements()."""
error_log = tmp_path / ".photon-npm-error.log"
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
cli_mod._install_sidecar()
assert not error_log.exists(), (
"_NPM_ERROR_LOG must not be created when stderr is empty"
)
# ---------------------------------------------------------------------------
# 5. proc.stderr is None — no AttributeError
# ---------------------------------------------------------------------------
def test_regression_permissionerror_on_success_unlink_does_not_propagate(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A successful npm install must still return 0 even if deleting the
stale _NPM_ERROR_LOG raises something other than FileNotFoundError
(e.g. PermissionError on a locked file) — the unlink is best-effort."""
error_log = tmp_path / ".photon-npm-error.log"
class _UnremovablePath(type(error_log)):
def unlink(self, *a, **kw):
raise PermissionError("access denied")
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", _UnremovablePath(error_log))
rc = cli_mod._install_sidecar()
assert rc == 0 # PermissionError on cleanup must not fail the install
def test_regression_long_stderr_truncated_before_write(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A huge npm stderr must be bounded before it hits disk, not just when
read back later — otherwise a verbose npm failure writes an unbounded
file to the sidecar directory on every retry."""
error_log = tmp_path / ".photon-npm-error.log"
huge_stderr = "npm ERR! " + ("x" * 10_000)
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=huge_stderr),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
cli_mod._install_sidecar()
written = error_log.read_text(encoding="utf-8")
assert len(written) <= cli_mod._NPM_ERROR_LOG_MAX_CHARS
def test_regression_none_stderr_does_not_crash(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""On some platforms/configurations proc.stderr can be None even with
stderr=PIPE (e.g. encoding errors). _install_sidecar() must handle this."""
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=None),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
rc = cli_mod._install_sidecar()
assert rc == 1 # must not raise AttributeError
# ---------------------------------------------------------------------------
# 6. Stale log cleared on success — no phantom errors after reinstall
# ---------------------------------------------------------------------------
def test_regression_stale_log_not_surfaced_after_successful_reinstall(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""If npm install succeeds on a retry but a stale _NPM_ERROR_LOG from the
prior failed run still exists, check_requirements() must NOT surface the
stale error after the successful reinstall clears it."""
error_log = tmp_path / ".photon-npm-error.log"
error_log.write_text("stale: npm ERR! old failure", encoding="utf-8")
# Successful reinstall clears the log
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
cli_mod._install_sidecar()
assert not error_log.exists(), "Success must clear the stale error log"
# Now check_requirements() must not mention the old error
# Create spectrum-ts inside node_modules/ — the content check requires it.
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", error_log)
with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"):
result = adapter_mod.check_requirements()
assert result is True
assert not any("stale" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# 7. check_requirements() without error log — debug log still emitted
# ---------------------------------------------------------------------------
@_requires_node
def test_regression_debug_log_emitted_even_without_error_log(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""When node_modules is missing and no _NPM_ERROR_LOG exists (first-time
setup, not a failed install), check_requirements() must still emit a DEBUG
line pointing to the sidecar path."""
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
# NS-606: disable self-heal so the debug-log branch is reached.
monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False)
# node_modules NOT created, error log NOT created
with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"):
result = adapter_mod.check_requirements()
assert result is False
debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
assert any(str(tmp_path) in m for m in debug_messages), (
f"Expected DEBUG with sidecar path even without error log, got: {debug_messages}"
)
@@ -0,0 +1,130 @@
"""Outbound-media tests for PhotonAdapter.
Photon ships outbound attachments via spectrum-ts' ``attachment()`` /
``voice()`` content builders, reached through the Node sidecar's
``/send-attachment`` endpoint. These tests stub ``_sidecar_call`` so we
can assert the endpoint + body shape each ``send_*`` override produces
without spawning Node or binding ports.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
"""Replace ``_sidecar_call`` with a recorder that returns a fixed id."""
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
@pytest.fixture()
def real_file(tmp_path) -> str:
p = tmp_path / "photo.jpg"
p.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
return str(p)
def _patch_safe_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make path validation a passthrough so tmp files outside the cache pass."""
monkeypatch.setattr(
PhotonAdapter,
"validate_media_delivery_path",
staticmethod(lambda p: p if os.path.exists(p) else None),
)
@pytest.mark.asyncio
async def test_send_image_file_hits_attachment_endpoint(
monkeypatch: pytest.MonkeyPatch, real_file: str
) -> None:
_patch_safe_path(monkeypatch)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
result = await adapter.send_image_file(
"any;-;+15551234567", real_file, caption="look"
)
assert result.success is True
assert result.message_id == "msg-123"
assert len(calls) == 1
path, body = calls[0]
assert path == "/send-attachment"
assert body["spaceId"] == "any;-;+15551234567"
assert body["path"] == real_file
assert body["kind"] == "attachment"
assert body["caption"] == "look"
assert body["mimeType"] == "image/jpeg" # inferred from .jpg
@pytest.mark.asyncio
async def test_standalone_send_text_then_attachments(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
_patch_safe_path(monkeypatch)
img = tmp_path / "a.png"
img.write_bytes(b"\x89PNG fake")
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
posted: List[Tuple[str, Dict[str, Any]]] = []
class _Resp:
status_code = 200
@staticmethod
def json() -> Dict[str, Any]:
return {"ok": True, "messageId": "m-9"}
class _FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url: str, json: Dict[str, Any], headers=None):
posted.append((url, json))
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
cfg = PlatformConfig(enabled=True, token="", extra={})
result = await photon_adapter._standalone_send(
cfg,
"any;-;+1",
"hello",
media_files=[(str(img), False)],
)
assert result.get("success") is True
# First call is the text /send, second is /send-attachment.
assert posted[0][0].endswith("/send")
assert posted[0][1]["text"] == "hello"
assert posted[1][0].endswith("/send-attachment")
assert posted[1][1]["path"] == str(img)
assert posted[1][1]["kind"] == "attachment"
assert posted[1][1]["mimeType"] == "image/png"
@@ -0,0 +1,495 @@
"""Photon adapter resilience to transient Spectrum/Envoy upstream overflow.
Covers the three behaviors that let the adapter ride through a Photon
"reset reason: overflow" event instead of degrading delivery and silently
dying (issue #50185):
1. ``_is_retryable_error`` classifies the Envoy/sidecar overflow strings as
retryable so ``_send_with_retry`` actually engages its backoff loop.
2. ``send_typing`` is rate-gated per chat, and ``stop_typing`` resets the
gate so the next turn's typing indicator fires immediately.
3. ``_supervise_sidecar`` detects an unexpected sidecar exit and raises a
``retryable=True`` fatal so the gateway reconnect watcher revives the
platform — instead of returning silently and leaving ``_inbound_loop``
spinning against a dead port.
4. ``_monitor_sidecar_health`` promotes degraded upstream stream health
reported by ``/healthz`` into the same retryable reconnect path.
No Node sidecar is spawned and no ports are bound.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import SendResult
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
# -- Gap 1: retryable classification of overflow errors ---------------------
@pytest.mark.parametrize(
"error",
[
"UNAVAILABLE: internal sidecar error",
"upstream connect error or disconnect/reset before headers",
"reset reason: overflow",
# Case-insensitive: real strings arrive with mixed case.
"Internal Sidecar Error",
],
)
def test_overflow_strings_classified_retryable(error: str) -> None:
assert PhotonAdapter._is_retryable_error(error) is True
def test_unrelated_error_not_retryable() -> None:
# A genuine permanent failure must NOT be retried.
assert PhotonAdapter._is_retryable_error("400 bad request: invalid spaceId") is False
assert PhotonAdapter._is_retryable_error(None) is False
def test_base_network_patterns_still_match() -> None:
# The override delegates to the base classifier first, so generic
# network strings keep working.
assert PhotonAdapter._is_retryable_error("ConnectError: connection refused") is True
def test_structured_non_retryable_sidecar_error_not_legacy_retried() -> None:
error = str(
photon_adapter.PhotonSidecarError(
path="/send",
status_code=500,
error="internal sidecar error",
error_class="auth_or_config",
retryable=False,
)
)
assert PhotonAdapter._is_retryable_error(error) is False
@pytest.mark.asyncio
async def test_send_with_retry_uses_structured_retryable_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
calls = 0
sleeps: list[float] = []
async def _fake_sleep(delay: float) -> None:
sleeps.append(delay)
async def _fake_sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
nonlocal calls
calls += 1
if calls == 1:
raise photon_adapter.PhotonSidecarError(
path=path,
status_code=500,
error="temporary upstream failure",
error_class="upstream_transient",
retryable=True,
)
return {"ok": True, "messageId": "m-2"}
monkeypatch.setattr(photon_adapter.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(adapter, "_sidecar_call", _fake_sidecar_call)
result = await adapter._send_with_retry(
"space-1", "hello", max_retries=1, base_delay=0.25
)
assert result.success is True
assert result.message_id == "m-2"
assert calls == 2
assert sleeps == [0.25]
# -- Gap 2: typing-indicator cooldown ---------------------------------------
@pytest.mark.asyncio
async def test_typing_cooldown_suppresses_rapid_repeats(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
calls: list[Dict[str, Any]] = []
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
calls.append(payload)
return {"ok": True}
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
# First call fires; immediate repeats are suppressed by the cooldown.
await adapter.send_typing("chat-1")
await adapter.send_typing("chat-1")
await adapter.send_typing("chat-1")
assert len(calls) == 1
@pytest.mark.asyncio
async def test_stop_typing_resets_cooldown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
starts = 0
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
nonlocal starts
if payload.get("state") == "start":
starts += 1
return {"ok": True}
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
# A start, then a stop (end of turn), then a start for the next turn must
# fire immediately — the cooldown only suppresses rapid consecutive starts
# without an intervening stop.
await adapter.send_typing("chat-1")
await adapter.stop_typing("chat-1")
await adapter.send_typing("chat-1")
assert starts == 2
# -- Gap 3: sidecar crash detection -----------------------------------------
class _EofStdout:
"""A proc.stdout whose readline() reports immediate EOF (dead sidecar)."""
def readline(self) -> bytes:
return b""
class _DeadProc:
"""Minimal subprocess.Popen stand-in for a sidecar that has exited."""
def __init__(self, exit_code: int = 1) -> None:
self.stdout = _EofStdout()
self.stdin = None
self._exit_code = exit_code
def poll(self) -> int:
return self._exit_code
@pytest.mark.asyncio
async def test_unexpected_sidecar_exit_raises_retryable_fatal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
# Simulate a live session whose sidecar then dies underneath it.
adapter._inbound_running = True
notified: list[bool] = []
async def _fake_notify() -> None:
notified.append(True)
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
await adapter._supervise_sidecar(_DeadProc(exit_code=137)) # type: ignore[arg-type]
assert adapter.has_fatal_error is True
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
# retryable=True routes the platform into the reconnect watcher rather
# than crashing the whole gateway.
assert adapter.fatal_error_retryable is True
assert adapter._running is False
# The notification is dispatched onto its own task rather than awaited on
# the supervisor's stack, so that disconnect() cancelling the supervisor
# cannot kill the handoff. Let that task run before asserting delivery.
await _drain_pending_tasks()
assert notified == [True]
@pytest.mark.asyncio
async def test_clean_shutdown_does_not_raise_fatal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
# disconnect() sets _inbound_running = False before stopping the sidecar,
# so the detection block must NOT fire on a clean shutdown.
adapter._inbound_running = False
notified: list[bool] = []
async def _fake_notify() -> None:
notified.append(True)
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
await adapter._supervise_sidecar(_DeadProc(exit_code=0)) # type: ignore[arg-type]
assert adapter.has_fatal_error is False
assert notified == []
@pytest.mark.asyncio
async def test_degraded_stream_health_raises_retryable_fatal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
adapter._sidecar_health_interval = 0.0
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
assert path == "/healthz"
return {
"ok": True,
"stream": {
"ok": False,
"state": "degraded",
"degradedForMs": 120000,
"lastIssue": "[spectrum.stream] stream interrupted; reconnecting",
},
}
notified: list[bool] = []
async def _fake_notify() -> None:
notified.append(True)
adapter._inbound_running = False
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
await adapter._monitor_sidecar_health()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
assert adapter.fatal_error_retryable is True
# Dispatched detached (see _dispatch_fatal_notification) so the health
# task's own teardown cannot cancel the handoff; drain before asserting.
await _drain_pending_tasks()
assert notified == [True]
async def _drain_pending_tasks(limit: int = 50) -> None:
"""Let detached fatal-notification tasks finish before asserting on them.
``_dispatch_fatal_notification`` deliberately does not await the
notification (that is what kept ``disconnect()`` from cancelling its own
caller), so a test that drives ``_monitor_sidecar_health`` /
``_supervise_sidecar`` directly returns before the notification has run.
"""
for _ in range(limit):
pending = [
t for t in asyncio.all_tasks()
if t is not asyncio.current_task() and not t.done()
]
if not pending:
return
await asyncio.wait(pending, timeout=1.0)
# -- Gap 5: self-cancellation race in _stop_sidecar() (issue #73159) --------
#
# The tests above mock out _notify_fatal_error() entirely, so the real
# integration chain (supervisor task -> _notify_fatal_error() ->
# disconnect() -> _stop_sidecar() -> cancel the supervisor task) is never
# exercised end to end. That chain is exactly where the bug lives: when
# _notify_fatal_error() is a real callback that calls adapter.disconnect(),
# _stop_sidecar() is invoked FROM WITHIN the currently-running supervisor
# task, and cancelling self._sidecar_supervisor_task there cancels the very
# task executing the fatal-error handler -- aborting it (via CancelledError,
# a BaseException the handler's `except Exception` guards don't catch)
# before the Gateway's reconnect-queue step ever runs.
class _FakeStoppedProc:
"""Minimal proc stand-in so _stop_sidecar() reaches its finally block
(it early-returns entirely when self._sidecar_proc is None) without
spawning a real subprocess or doing any real I/O."""
def __init__(self) -> None:
self.stdin = None
def wait(self, timeout: float | None = None) -> int:
return 0
@pytest.mark.asyncio
async def test_supervisor_task_survives_self_triggered_disconnect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The real chain: _supervise_sidecar() (running as the actual
self._sidecar_supervisor_task) detects the crash and calls a REAL
_notify_fatal_error() that calls adapter.disconnect() -- which reaches
_stop_sidecar() from inside the task it's about to try to cancel.
Before the fix: this raises CancelledError out of _supervise_sidecar(),
so the task ends up in the "cancelled" state and reconnect_queued below
is never set (mirroring how the real Gateway's fatal-error handler,
which runs the reconnect-queue logic AFTER disconnect() returns, never
gets there either).
After the fix: disconnect() completes normally, _supervise_sidecar()
returns normally, and the task is NOT cancelled.
"""
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
# A minimal fake proc so _stop_sidecar() reaches its finally block
# (the real process-management behavior is covered separately by
# test_sidecar_lifecycle.py) -- this test is purely about the
# self-cancellation race.
adapter._sidecar_proc = _FakeStoppedProc()
reconnect_queued: list[bool] = []
async def _real_notify_fatal_error() -> None:
# Stand-in for the Gateway's actual fatal-error handler: it calls
# adapter.disconnect() (real chain: disconnect -> _stop_sidecar,
# which used to self-cancel), then -- only if that completes
# without the CancelledError escaping -- proceeds to queue the
# platform for background reconnection.
await adapter.disconnect()
reconnect_queued.append(True)
monkeypatch.setattr(adapter, "_notify_fatal_error", _real_notify_fatal_error)
async def _run_supervisor():
await adapter._supervise_sidecar(_DeadProc(exit_code=75))
task = asyncio.ensure_future(_run_supervisor())
adapter._sidecar_supervisor_task = task
# Must complete cleanly -- must NOT raise CancelledError out to us.
await task
assert task.cancelled() is False, (
"The supervisor task must not end up cancelled by its own "
"fatal-error handling chain"
)
assert adapter.has_fatal_error is True
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
assert reconnect_queued == [True], (
"The reconnect-queue step (everything after disconnect() returns "
"in the real Gateway handler) must actually run -- this is the "
"exact step issue #73159 reports as silently skipped"
)
# _stop_sidecar() must have cleared the task reference either way.
assert adapter._sidecar_supervisor_task is None
@pytest.mark.asyncio
async def test_external_disconnect_still_cancels_supervisor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The OTHER call path -- external cleanup (Gateway shutdown, an
explicit /platform disconnect) -- calls _stop_sidecar() from a
DIFFERENT task than the supervisor. That legitimate case must still
cancel a still-running supervisor task exactly as before."""
adapter = _make_adapter(monkeypatch)
adapter._sidecar_proc = _FakeStoppedProc()
supervisor_ran_forever = asyncio.Event()
async def _hangs_forever():
try:
supervisor_ran_forever.set()
await asyncio.sleep(3600)
except asyncio.CancelledError:
raise
task = asyncio.ensure_future(_hangs_forever())
adapter._sidecar_supervisor_task = task
await supervisor_ran_forever.wait()
# Called from THIS (different) task -- the external-cleanup case.
await adapter._stop_sidecar()
# cancel() only schedules the CancelledError; await it so the task
# actually settles into the cancelled state before asserting.
try:
await task
except asyncio.CancelledError:
pass
assert task.cancelled() is True, (
"External cleanup must still cancel a running supervisor task"
)
assert adapter._sidecar_supervisor_task is None
# -- target_not_allowed: shared/free-tier outbound-send restriction ----------
#
# Spectrum throws AuthenticationError("Target not allowed for this project")
# from space.send when a shared/free-tier line initiates an outbound send to
# a new target. The sidecar classifies it as the structured code
# `target_not_allowed`; the adapter must treat it as permanent in BOTH
# _send_with_retry and _standalone_send, surfacing the canonical user-facing
# message instead of raw upstream error text (issues #50971 / #51897).
def test_target_not_allowed_maps_to_canonical_message() -> None:
err = photon_adapter._sidecar_error_from_response(
"/send",
500,
'{"ok":false,"error":"internal sidecar error",'
'"error_class":"target_not_allowed","retryable":false}',
)
assert err.error_class == "target_not_allowed"
assert err.retryable is False
assert err.error == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
# No raw upstream text may leak through the structured code path.
assert "Target not allowed for this project" not in str(err)
@pytest.mark.asyncio
async def test_standalone_send_classifies_target_not_allowed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "token")
class _Resp:
status_code = 500
text = (
'{"ok":false,"error":"internal sidecar error",'
'"error_class":"target_not_allowed","retryable":false}'
)
@staticmethod
def json() -> Dict[str, Any]:
return {
"ok": False,
"error": "internal sidecar error",
"error_class": "target_not_allowed",
"retryable": False,
}
class _FakeClient:
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_FakeClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, *a: Any, **k: Any) -> _Resp:
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
result = await photon_adapter._standalone_send(
PlatformConfig(enabled=True, extra={}), "space-1", "hello",
)
assert result.get("error") == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
assert result.get("error_class") == "target_not_allowed"
assert result.get("retryable") is False
assert "Target not allowed for this project" not in str(result)
@@ -0,0 +1,149 @@
"""Native-poll clarify tests for PhotonAdapter.
iMessage has a native poll bubble (spectrum-ts `poll()` builder). A
multiple-choice ``clarify`` renders as that poll; the user taps a choice and
the vote streams back inbound as a ``poll_option`` event. These tests cover
both directions without spawning the Node sidecar or binding ports:
* outbound — ``send_clarify`` with choices POSTs ``/send-poll`` and flips the
clarify into text-capture mode; with no choices it stays plain text;
* inbound — a ``poll_option`` selection is dispatched as a plain-text message
carrying the chosen option (so the gateway clarify-intercept resolves it),
a deselection is dropped, and an empty-title vote is dropped.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, SendResult
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _poll_option_event(
*, title: str, selected: bool = True, msg_id: str = "spc-msg-vote"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"platform": "iMessage",
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
"sender": {"id": "+155****4567"},
"content": {
"type": "poll_option",
"title": title,
"selected": selected,
"pollTitle": "Pick one",
},
"timestamp": "2026-05-14T19:06:32.000Z",
}
# ---------------------------------------------------------------------------
# Inbound: a poll vote becomes the clarify answer.
@pytest.mark.asyncio
async def test_poll_vote_dispatched_as_choice_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A poll selection is forwarded as a plain-text message carrying the
chosen option, so the gateway clarify text-intercept can resolve it."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(
_poll_option_event(title="Yes — native tappable buttons")
)
assert len(captured) == 1
ev = captured[0]
assert ev.text == "Yes — native tappable buttons"
assert ev.message_type == MessageType.TEXT
assert ev.source.chat_id == "+155****4567"
# ---------------------------------------------------------------------------
# Outbound: send_clarify renders a native poll for choices.
def _stub_sidecar_poll(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, *, ok: bool = True
) -> List[Tuple[str, str, list]]:
calls: List[Tuple[str, str, list]] = []
async def fake_send_poll(space_id: str, title: str, options: list):
calls.append((space_id, title, list(options)))
return SendResult(
success=ok,
message_id="spc-msg-poll" if ok else None,
error=None if ok else "boom",
)
monkeypatch.setattr(adapter, "_sidecar_send_poll", fake_send_poll)
return calls
def _stub_sidecar_text(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
) -> List[Tuple[str, str]]:
sends: List[Tuple[str, str]] = []
async def fake_send(space_id: str, text: str):
sends.append((space_id, text))
return SendResult(success=True, message_id="spc-msg-text")
monkeypatch.setattr(adapter, "_sidecar_send", fake_send)
return sends
@pytest.mark.asyncio
async def test_send_clarify_with_choices_sends_native_poll(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
poll_calls = _stub_sidecar_poll(adapter, monkeypatch)
marked: List[str] = []
import tools.clarify_gateway as cg
monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: marked.append(cid))
result = await adapter.send_clarify(
chat_id="+155****4567",
question="Pick one",
choices=["A", "B", "C"],
clarify_id="clar-1",
session_key="sess-1",
)
assert result.success
assert len(poll_calls) == 1
space_id, title, options = poll_calls[0]
assert space_id == "+155****4567"
assert title == "Pick one"
assert options == ["A", "B", "C"]
# The vote returns as text, so text-capture must be enabled.
assert marked == ["clar-1"]
@@ -0,0 +1,108 @@
"""Presence-watchdog tests.
spectrum-ts only reconnects when its inbound iterator throws or ends; a
half-open ("zombie") gRPC socket makes the iterator hang forever (no error, no
end), so inbound silently dies until the sidecar is restarted. The adapter's
presence watchdog probes the upstream channel via the sidecar's ``/probe``
endpoint and respawns the sidecar after repeated probe failures.
These tests exercise the watchdog's decision logic (probe -> count failures ->
respawn; success resets; recent inbound traffic skips the probe) without
spawning Node, binding ports, or hitting the network.
"""
from __future__ import annotations
import time
from typing import Any, List
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra: Any) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra=dict(extra))
return PhotonAdapter(cfg)
def test_probe_config_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
a = _make_adapter(monkeypatch)
# Conservative by default: probe only after 10+ minutes of stream silence
# so quiet shared lines never trigger restart storms.
assert a._probe_interval == 600.0
assert a._probe_timeout == 10.0
assert a._probe_max_failures == 3
assert a._probe_enabled is True
def test_note_activity_resets_failures(monkeypatch: pytest.MonkeyPatch) -> None:
a = _make_adapter(monkeypatch)
a._probe_failures = 2
before = a._last_upstream_activity
time.sleep(0.001)
a._note_upstream_activity()
assert a._probe_failures == 0
assert a._last_upstream_activity > before
@pytest.mark.asyncio
async def test_respawn_after_max_failures(monkeypatch: pytest.MonkeyPatch) -> None:
"""The core fix: N consecutive dead probes -> exactly one respawn."""
a = _make_adapter(monkeypatch, probe_max_failures=3)
respawns: List[str] = []
async def _fake_respawn(reason: str) -> None:
respawns.append(reason)
a._note_upstream_activity() # mirror real respawn (clears failures)
async def _hung_probe() -> str:
return "hung"
monkeypatch.setattr(a, "_respawn_sidecar", _fake_respawn)
monkeypatch.setattr(a, "_probe_once", _hung_probe)
# Simulate the watchdog's per-iteration decision logic directly (no sleeps).
a._last_upstream_activity = time.monotonic() - 999 # force a probe each time
for _ in range(3):
verdict = await a._probe_once()
assert verdict == "hung"
a._probe_failures += 1
if a._probe_failures >= a._probe_max_failures:
await a._respawn_sidecar("test")
assert respawns == ["test"]
assert a._probe_failures == 0 # reset by the (faked) respawn
@pytest.mark.asyncio
async def test_success_resets_failure_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A live probe between dead ones prevents a respawn (failures reset)."""
a = _make_adapter(monkeypatch, probe_max_failures=3)
respawns: List[str] = []
async def _fake_respawn(reason: str) -> None:
respawns.append(reason)
monkeypatch.setattr(a, "_respawn_sidecar", _fake_respawn)
# Two failures, then a success, then two more failures: never hits 3 in a row.
sequence = [False, False, True, False, False]
for alive in sequence:
if alive:
a._note_upstream_activity()
else:
a._probe_failures += 1
if a._probe_failures >= a._probe_max_failures:
await a._respawn_sidecar("should-not-fire")
assert respawns == []
assert a._probe_failures == 2
@@ -0,0 +1,195 @@
"""Reaction (tapback) tests for PhotonAdapter.
Outbound reactions go through the sidecar's ``/react`` / ``/unreact``
endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body
shape. Inbound reaction events are fed straight to ``_dispatch_inbound``.
Neither path spawns the Node sidecar or binds ports.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
from plugins.platforms.photon.adapter import PhotonAdapter
_EYES = "\U0001f440"
_THUMBS_UP = "\U0001f44d"
_THUMBS_DOWN = "\U0001f44e"
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
def _capture_handled(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _message_event(adapter: PhotonAdapter) -> MessageEvent:
return MessageEvent(
text="hi",
message_type=MessageType.TEXT,
source=adapter.build_source(
chat_id="+15551234567",
chat_name="+15551234567",
chat_type="dm",
user_id="+15551234567",
user_name=None,
),
message_id="target-msg-1",
timestamp=datetime.now(tz=timezone.utc),
)
def _reaction_event(
emoji: str = "❤️",
target_id: str = "bot-msg-1",
target_direction: Any = "outbound",
space_type: str = "dm",
target_text: Any = "the bot's earlier reply",
) -> Dict[str, Any]:
return {
"messageId": "reaction-evt-1",
"platform": "iMessage",
"space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {
"type": "reaction",
"emoji": emoji,
"targetMessageId": target_id,
"targetDirection": target_direction,
# The sidecar always emits this key (hydrated reaction target);
# null when the reacted-to message carried no text.
"targetText": target_text,
},
"timestamp": "2026-06-11T10:00:00.000Z",
}
# -- Outbound: /react and /unreact body shapes ------------------------------
@pytest.mark.asyncio
async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES)
assert ok is True
assert calls == [
(
"/react",
{
"spaceId": "+15551234567",
"messageId": "target-msg-1",
"emoji": _EYES,
},
)
]
@pytest.mark.asyncio
async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
ok = await adapter._remove_reaction("+15551234567", "target-msg-1")
assert ok is True
assert calls == [
("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"})
]
@pytest.mark.asyncio
async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError("sidecar down")
adapter._sidecar_call = _boom # type: ignore[assignment]
assert await adapter._add_reaction("+1", "m", _EYES) is False
assert await adapter._remove_reaction("+1", "m") is False
# -- Lifecycle hooks ---------------------------------------------------------
@pytest.mark.asyncio
async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHOTON_REACTIONS", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
event = _message_event(adapter)
await adapter.on_processing_start(event)
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
assert calls == []
@pytest.mark.asyncio
async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_REACTIONS", "true")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.on_processing_start(_message_event(adapter))
assert len(calls) == 1
path, body = calls[0]
assert path == "/react"
assert body["emoji"] == _EYES
assert body["messageId"] == "target-msg-1"
# -- Inbound reaction routing ------------------------------------------------
@pytest.mark.asyncio
async def test_inbound_reaction_on_bot_message_routed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reaction_event(emoji="❤️"))
assert len(captured) == 1
event = captured[0]
assert event.text == "reaction:added:❤️"
assert event.message_type == MessageType.TEXT
assert event.source.chat_id == "+15551234567"
# The tapback correlates to the bot message it reacted to, so the gateway
# can inject `[Replying to your previous message: "..."]` for context.
assert event.reply_to_message_id == "bot-msg-1"
assert event.reply_to_text == "the bot's earlier reply"
assert event.reply_to_is_own_message is True
@@ -0,0 +1,239 @@
"""Rich-link handling tests for PhotonAdapter.
Photon's spectrum-ts SDK exposes a ``richlink()`` content builder for native
URL previews. Hermes routes URL-only outbound messages to the sidecar's
rich-link endpoint and preserves inbound rich-link URLs when Spectrum emits
that content type.
"""
from __future__ import annotations
import base64
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
_URL = "https://example.com/article"
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
def _capture_inbound(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _dm_event(content: Dict[str, Any], msg_id: str = "spc-msg-rich") -> Dict[str, Any]:
return {
"messageId": msg_id,
"platform": "iMessage",
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
"sender": {"id": "+155****4567"},
"content": content,
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_url_only_send_routes_to_richlink_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
result = await adapter.send("+155****4567", _URL)
assert result.success is True
assert calls == [("/send-richlink", {"spaceId": "+155****4567", "url": _URL})]
@pytest.mark.asyncio
async def test_mixed_prose_url_stays_on_markdown_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+155****4567", f"Read this: {_URL}")
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
assert body["text"] == f"Read this: {_URL}"
@pytest.mark.asyncio
async def test_malformed_url_like_send_stays_on_markdown_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+155****4567", "http://[::1")
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
assert body["text"] == "http://[::1"
@pytest.mark.asyncio
async def test_markdown_link_stays_on_markdown_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+155****4567", f"[Read this]({_URL})")
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
@pytest.mark.asyncio
async def test_direct_url_only_send_falls_back_to_plain_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
if path == "/send-richlink":
raise RuntimeError("richlink unsupported")
return {"ok": True, "messageId": "plain-msg"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
result = await adapter.send("+155****4567", _URL)
assert result.success is True
assert result.message_id == "plain-msg"
assert calls == [
("/send-richlink", {"spaceId": "+155****4567", "url": _URL}),
("/send", {"spaceId": "+155****4567", "text": _URL}),
]
@pytest.mark.asyncio
async def test_standalone_url_only_send_routes_to_richlink_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
posted: List[Tuple[str, Dict[str, Any]]] = []
class _Resp:
status_code = 200
@staticmethod
def json() -> Dict[str, Any]:
return {"ok": True, "messageId": "m-9"}
class _FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url: str, json: Dict[str, Any], headers=None):
posted.append((url, json))
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
cfg = PlatformConfig(enabled=True, token="", extra={})
result = await photon_adapter._standalone_send(cfg, "+155****4567", _URL)
assert result.get("success") is True
assert posted == [
(
"http://127.0.0.1:8789/send-richlink",
{"spaceId": "+155****4567", "url": _URL},
)
]
@pytest.mark.asyncio
async def test_inbound_richlink_dispatches_url_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture_inbound(adapter, monkeypatch)
event = _dm_event({"type": "richlink", "url": _URL})
await adapter._dispatch_inbound(event)
assert len(captured) == 1
assert captured[0].text == _URL
assert captured[0].message_type == MessageType.TEXT
assert captured[0].raw_message["content"] == {"type": "richlink", "url": _URL}
_PNG_1X1_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def _preview_attachment(
name: str = "preview.pluginPayloadAttachment",
mime_type: str = "image/png",
) -> Dict[str, Any]:
raw = base64.b64decode(_PNG_1X1_B64)
return {
"type": "attachment",
"name": name,
"mimeType": mime_type,
"size": len(raw),
"data": _PNG_1X1_B64,
"encoding": "base64",
}
def _preview_attachment_by_id(
attachment_id: str = "doc_123.pluginPayloadAttachment",
) -> Dict[str, Any]:
payload = _preview_attachment(name="")
payload["id"] = attachment_id
payload["name"] = None
return payload
@@ -0,0 +1,232 @@
"""Sidecar runtime-record persistence tests (issue #69960).
The sidecar token is generated at spawn and used to exist only in the
gateway process memory + sidecar child env — so cron/`hermes send`
standalone sends structurally could not authenticate. The adapter now
persists ``<hermes-home>/runtime/photon-sidecar.json`` after the sidecar
passes its /healthz readiness check, deletes it on stop/failed-start, and
``_standalone_send`` falls back to it when PHOTON_SIDECAR_TOKEN is unset.
No Node, no ports, no network.
"""
from __future__ import annotations
import asyncio
import json
import os
import stat
import sys
from pathlib import Path
from typing import Any
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
@pytest.fixture()
def record_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "runtime" / "photon-sidecar.json"
monkeypatch.setattr(photon_adapter, "_runtime_record_path", lambda: path)
return path
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_SIDECAR_TOKEN", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
# -- record helpers ----------------------------------------------------------
def test_write_read_delete_roundtrip(record_path: Path) -> None:
photon_adapter._write_runtime_record(8789, "tok123", 4242)
assert record_path.exists()
data = json.loads(record_path.read_text(encoding="utf-8"))
assert data == {"port": 8789, "token": "tok123", "pid": 4242}
assert photon_adapter._read_runtime_record() == data
photon_adapter._delete_runtime_record()
assert not record_path.exists()
# Idempotent: deleting a missing record must not raise.
photon_adapter._delete_runtime_record()
assert photon_adapter._read_runtime_record() is None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
def test_record_written_with_0600(record_path: Path) -> None:
photon_adapter._write_runtime_record(8789, "secret", 1)
mode = stat.S_IMODE(record_path.stat().st_mode)
assert mode == 0o600
def test_read_tolerates_corrupt_record(record_path: Path) -> None:
record_path.parent.mkdir(parents=True, exist_ok=True)
record_path.write_text("{not json", encoding="utf-8")
assert photon_adapter._read_runtime_record() is None
# -- lifecycle: written after healthz success, removed on stop/failure -------
class _HealthzClient:
"""Fake httpx.AsyncClient whose /healthz response is injectable."""
status_code = 200
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_HealthzClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, *a: Any, **k: Any) -> Any:
cls = type(self)
class _Resp:
status_code = cls.status_code
return _Resp()
class _FakeProc:
pid = 4242
stdin = None
returncode: int | None = None
def poll(self) -> int | None:
return None
def wait(self, timeout: float | None = None) -> int:
return 0
def terminate(self) -> None:
pass
def kill(self) -> None:
pass
def _patch_spawn(
monkeypatch: pytest.MonkeyPatch, adapter: PhotonAdapter, tmp_path: Path
) -> None:
"""Stub everything _start_sidecar touches before the healthz loop."""
sidecar_dir = tmp_path / "sidecar"
# sidecar_deps_installed() checks the dependency's own directory, not just
# node_modules/ (9cf2046081) — mirror a real completed install.
(sidecar_dir / "node_modules" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar_dir)
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
async def _no_reap(self: PhotonAdapter) -> None:
return None
monkeypatch.setattr(PhotonAdapter, "_reap_stale_sidecar", _no_reap)
monkeypatch.setattr(
photon_adapter.subprocess,
"run",
lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
)
monkeypatch.setattr(
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
)
async def _no_supervise(self: PhotonAdapter, proc: Any) -> None:
return None
monkeypatch.setattr(PhotonAdapter, "_supervise_sidecar", _no_supervise)
@pytest.mark.asyncio
async def test_record_written_after_healthz_success(
monkeypatch: pytest.MonkeyPatch, record_path: Path, tmp_path: Path
) -> None:
adapter = _make_adapter(monkeypatch)
_patch_spawn(monkeypatch, adapter, tmp_path)
_HealthzClient.status_code = 200
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthzClient)
await adapter._start_sidecar()
data = json.loads(record_path.read_text(encoding="utf-8"))
assert data["port"] == adapter._sidecar_port
assert data["token"] == adapter._sidecar_token
assert data["pid"] == 4242
# Cleanup so the fake supervisor task doesn't leak between tests.
if adapter._sidecar_supervisor_task is not None:
adapter._sidecar_supervisor_task.cancel()
@pytest.mark.asyncio
async def test_stop_without_proc_still_clears_record(
monkeypatch: pytest.MonkeyPatch, record_path: Path
) -> None:
adapter = _make_adapter(monkeypatch)
photon_adapter._write_runtime_record(8789, "tok", 4242)
adapter._sidecar_proc = None
await adapter._stop_sidecar()
assert not record_path.exists()
# -- _standalone_send fallback ------------------------------------------------
class _SendClient:
"""Fake httpx.AsyncClient capturing /send calls."""
calls: list = []
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_SendClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, url: str, json: Any = None, headers: Any = None) -> Any:
type(self).calls.append((url, json, headers))
class _Resp:
status_code = 200
text = ""
@staticmethod
def json() -> dict:
return {"ok": True, "messageId": "m1"}
return _Resp()
@pytest.mark.asyncio
async def test_standalone_send_consumes_record_when_env_missing(
monkeypatch: pytest.MonkeyPatch, record_path: Path
) -> None:
monkeypatch.delenv("PHOTON_SIDECAR_TOKEN", raising=False)
monkeypatch.delenv("PHOTON_SIDECAR_PORT", raising=False)
photon_adapter._write_runtime_record(9111, "record-token", os.getpid())
_SendClient.calls = []
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _SendClient)
result = await photon_adapter._standalone_send(
PlatformConfig(enabled=True, token="", extra={}), "+15551234567", "hi"
)
assert result == {"success": True, "message_id": "m1"}
url, _body, headers = _SendClient.calls[0]
assert ":9111/" in url
assert headers["X-Hermes-Sidecar-Token"] == "record-token"
@@ -0,0 +1,194 @@
"""Tests for `hermes photon setup`'s access auto-configuration.
`_autoconfigure_access` allowlists the operator and points the cron home
channel at their DM, writing to the per-test ~/.hermes/.env (the hermetic
HERMES_HOME fixture isolates this). It must fill only unset keys so a re-run
never clobbers a hand-tuned allowlist.
"""
from __future__ import annotations
import argparse
import pytest
from hermes_cli.config import get_env_value, save_env_value
from plugins.platforms.photon.adapter import _env_enablement
from plugins.platforms.photon import cli
def test_autoconfigure_access_fills_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
cli._autoconfigure_access("+15551234567")
assert get_env_value("PHOTON_ALLOWED_USERS") == "+15551234567"
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
def test_env_enablement_seeds_home_channel(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
monkeypatch.setenv("PHOTON_HOME_CHANNEL_NAME", "Primary DM")
seed = _env_enablement()
assert seed is not None
assert seed["home_channel"] == {
"chat_id": "+15551234567",
"name": "Primary DM",
}
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# Token validation (added for #72763) would otherwise hit the network.
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
# longer enables Spectrum or fetches a separate spectrumProjectId — it
# reuses this id directly.
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
# No existing credentials — first-time setup path.
monkeypatch.setattr(
cli.photon_auth, "load_project_credentials", lambda: (None, None),
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
lambda token, dashboard_id: "secret_123",
)
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
monkeypatch.setattr(
cli.photon_auth,
"register_user_if_absent",
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
rc = cli._cmd_setup(
argparse.Namespace(
project_name=None,
phone="+155****4567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
out = capsys.readouterr().out
assert "Start the gateway: hermes gateway start" in out
assert "--platform photon" not in out
assert "new secret saved" in out
assert "restart it so the sidecar" in out
def test_setup_reuses_valid_existing_secret(
monkeypatch: pytest.MonkeyPatch, capsys,
) -> None:
"""Re-running setup with a valid existing secret must NOT regenerate it."""
regenerate_called = False
def _fake_regenerate(token, dashboard_id):
nonlocal regenerate_called
regenerate_called = True
return "new_secret"
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# Token validation (added for #72763) would otherwise hit the network.
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"load_project_credentials",
lambda: ("dashboard", "existing_secret"),
)
# list_users succeeds — existing secret is valid.
monkeypatch.setattr(
cli.photon_auth, "list_users", lambda pid, secret: [],
)
monkeypatch.setattr(cli.photon_auth, "regenerate_project_secret", _fake_regenerate)
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
monkeypatch.setattr(
cli.photon_auth,
"register_user_if_absent",
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
rc = cli._cmd_setup(
argparse.Namespace(
project_name=None,
phone="+155****4567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
assert not regenerate_called, "regenerate_project_secret must not be called when existing creds are valid"
out = capsys.readouterr().out
assert "existing credentials valid" in out
assert "restart" not in out.lower()
def test_setup_regenerates_when_existing_secret_invalid(
monkeypatch: pytest.MonkeyPatch, capsys,
) -> None:
"""When existing credentials are invalid, setup must regenerate."""
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# Token validation (added for #72763) would otherwise hit the network.
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"load_project_credentials",
lambda: ("dashboard", "stale_secret"),
)
# list_users fails — existing secret is invalid.
monkeypatch.setattr(
cli.photon_auth,
"list_users",
lambda pid, secret: (_ for _ in ()).throw(RuntimeError("auth failed")),
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
lambda token, dashboard_id: "new_secret",
)
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
monkeypatch.setattr(
cli.photon_auth,
"register_user_if_absent",
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
rc = cli._cmd_setup(
argparse.Namespace(
project_name=None,
phone="+155****4567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
out = capsys.readouterr().out
assert "new secret saved" in out
assert "restart it so the sidecar" in out
@@ -0,0 +1,51 @@
"""Regression tests for the Photon sidecar stale-dependency self-heal.
A `hermes update` that bumps the spectrum-ts pin rewrites the sidecar's
``package-lock.json`` but never reinstalls ``node_modules``, so the sidecar
spawns against stale deps and dies on every reconnect. ``_sidecar_deps_stale``
detects that skew (lockfile newer than npm's install marker) so
``_start_sidecar`` can reinstall before spawning.
"""
from __future__ import annotations
import os
from pathlib import Path
import plugins.platforms.photon.adapter as photon_adapter
def _seed(sidecar: Path, *, lock_mtime: float, marker_mtime: float | None) -> None:
"""Create a fake sidecar dir with a lockfile and (optionally) npm's marker."""
(sidecar / "node_modules").mkdir(parents=True)
lock = sidecar / "package-lock.json"
lock.write_text("{}", encoding="utf-8")
os.utime(lock, (lock_mtime, lock_mtime))
if marker_mtime is not None:
marker = sidecar / "node_modules" / ".package-lock.json"
marker.write_text("{}", encoding="utf-8")
os.utime(marker, (marker_mtime, marker_mtime))
def test_stale_when_lockfile_newer_than_marker(tmp_path, monkeypatch) -> None:
"""The update-rewrites-lockfile-but-skips-install case must reinstall."""
sidecar = tmp_path / "sidecar"
_seed(sidecar, lock_mtime=2000.0, marker_mtime=1000.0)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
assert photon_adapter._sidecar_deps_stale() is True
def test_fresh_when_marker_newer_than_lockfile(tmp_path, monkeypatch) -> None:
"""A normal install (marker at/after lockfile) must NOT trigger a reinstall."""
sidecar = tmp_path / "sidecar"
_seed(sidecar, lock_mtime=1000.0, marker_mtime=2000.0)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
assert photon_adapter._sidecar_deps_stale() is False
def test_not_stale_when_marker_missing(tmp_path, monkeypatch) -> None:
"""No marker (first run / unreadable) must fail safe to False, never block start."""
sidecar = tmp_path / "sidecar"
_seed(sidecar, lock_mtime=2000.0, marker_mtime=None)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
assert photon_adapter._sidecar_deps_stale() is False
@@ -0,0 +1,211 @@
"""Sidecar lifecycle tests: orphan reaping and parent-death wiring.
A hard gateway exit used to leave the detached Node sidecar squatting the
loopback port with a token the next gateway run doesn't know — every
replacement spawn then died on EADDRINUSE. These tests cover the startup
reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without
spawning Node or binding ports.
"""
from __future__ import annotations
import subprocess
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
class _ProbeClient:
"""Fake httpx.AsyncClient whose /healthz probe behavior is injectable."""
connects = True
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_ProbeClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, *a: Any, **k: Any) -> Any:
if not self.connects:
raise photon_adapter.httpx.ConnectError("connection refused")
class _Resp:
status_code = 401 # orphan with a different token
return _Resp()
def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]:
kills: List[Tuple[int, int]] = []
def _fake_kill(pid: int, sig: int) -> None:
kills.append((pid, sig))
monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill)
return kills
@pytest.mark.asyncio
async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
class _Refused(_ProbeClient):
connects = False
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused)
kills = _capture_kills(monkeypatch)
await adapter._reap_stale_sidecar()
assert kills == []
@pytest.mark.asyncio
async def test_start_sidecar_spawns_with_stdin_pipe(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""The spawn must hold a stdin pipe and enable the sidecar's EOF watch."""
adapter = _make_adapter(monkeypatch)
async def _no_reap() -> None:
pass
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
spawned: Dict[str, Any] = {}
hidden_flags = 0x08000000
monkeypatch.setattr(
"hermes_cli._subprocess_compat.windows_hide_flags",
lambda: hidden_flags,
)
class _PatchResult:
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd: List[str], **kwargs: Any) -> _PatchResult:
spawned["patch_cmd"] = cmd
spawned["patch_kwargs"] = kwargs
return _PatchResult()
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
class _FakeProc:
pid = 999
stdout = None
stdin = None
@staticmethod
def poll() -> None:
return None
def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
spawned["cmd"] = cmd
spawned["kwargs"] = kwargs
return _FakeProc()
monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)
class _HealthyClient(_ProbeClient):
async def post(self, *a: Any, **k: Any) -> Any:
class _Resp:
status_code = 200
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)
await adapter._start_sidecar()
kwargs = spawned["kwargs"]
assert kwargs["stdin"] is subprocess.PIPE
assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
assert spawned["patch_kwargs"]["creationflags"] == hidden_flags
assert kwargs["creationflags"] == hidden_flags
@pytest.mark.asyncio
async def test_spectrum_patch_runs_off_the_event_loop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The node patch run must not block the shared gateway event loop.
``_start_sidecar`` spawns the Spectrum patch script and *waits* for it
(``timeout=10``). Run inline it holds the loop for that whole window, so
every other platform's traffic stalls — and ``_start_sidecar`` runs on
every reconnect (``connect(is_reconnect=True)``), not just startup, so the
stall recurs on a live gateway. The dep reinstall a few lines above already
hops to a thread for exactly this reason; the patch run must too.
"""
import threading
adapter = _make_adapter(monkeypatch)
main_thread = threading.current_thread()
seen: Dict[str, Any] = {}
# node_modules present + deps fresh, so we reach the patch run.
monkeypatch.setattr(photon_adapter.Path, "exists", lambda self: True)
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
async def _no_reap() -> None:
return None
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
def _fake_run(*a: Any, **k: Any) -> Any:
seen["thread"] = threading.current_thread()
class _Done:
returncode = 0
stdout = ""
stderr = ""
return _Done()
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
class _FakeProc:
pid = 4242
stdin = None
stdout = None
def poll(self) -> int:
# Report "exited" so the readiness health-poll loop bails out
# immediately instead of spinning for its full 15s deadline —
# the assertion below only cares where the patch run executed.
return 0
monkeypatch.setattr(
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
)
try:
await adapter._start_sidecar()
except Exception:
# Readiness/handshake past the patch run may fail under the fakes —
# irrelevant here; we only assert where the patch run executed.
pass
assert seen.get("thread") is not None, "patch run never executed"
assert seen["thread"] is not main_thread, (
"Spectrum patch subprocess ran on the event-loop thread; it must be "
"dispatched via asyncio.to_thread so a 10s node spawn can't freeze "
"every other platform on the gateway loop"
)
@@ -0,0 +1,139 @@
"""Tests for the Photon sidecar directory resolver (NS-606).
Hosted/managed images keep the plugin tree under an immutable
``/opt/hermes``; ``resolve_sidecar_dir`` must run in place when the deps are
baked and current, and mirror the sidecar to the writable ``HERMES_HOME``
volume when a runtime install is unavoidable.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import plugins.platforms.photon.sidecar_paths as sidecar_paths
def _seed_source(source: Path, *, with_node_modules: bool = False) -> None:
source.mkdir(parents=True, exist_ok=True)
for name in sidecar_paths._MIRROR_FILES:
(source / name).write_text(f"// {name}\n", encoding="utf-8")
if with_node_modules:
(source / "node_modules").mkdir()
(source / "node_modules" / ".package-lock.json").write_text(
"{}", encoding="utf-8"
)
def _freeze_writability(monkeypatch, *, writable: bool) -> None:
monkeypatch.setattr(sidecar_paths, "_dir_writable", lambda _p: writable)
def test_env_override_wins(tmp_path, monkeypatch) -> None:
override = tmp_path / "custom"
monkeypatch.setenv("PHOTON_SIDECAR_DIR", str(override))
assert sidecar_paths.resolve_sidecar_dir(tmp_path / "src") == override
def test_writable_source_runs_in_place(tmp_path, monkeypatch) -> None:
"""Dev installs: writable tree keeps today's behavior exactly."""
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
source = tmp_path / "src"
_seed_source(source)
_freeze_writability(monkeypatch, writable=True)
assert sidecar_paths.resolve_sidecar_dir(source) == source
def test_readonly_source_with_baked_fresh_deps_runs_in_place(
tmp_path, monkeypatch
) -> None:
"""Managed-image happy path: deps baked at build time, no mirror needed."""
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
source = tmp_path / "src"
_seed_source(source, with_node_modules=True)
# Marker newer than lockfile == fresh install.
lock = source / "package-lock.json"
marker = source / "node_modules" / ".package-lock.json"
os.utime(lock, (1000.0, 1000.0))
os.utime(marker, (2000.0, 2000.0))
_freeze_writability(monkeypatch, writable=False)
assert sidecar_paths.resolve_sidecar_dir(source) == source
def test_mirror_refresh_updates_changed_files_and_keeps_node_modules(
tmp_path, monkeypatch
) -> None:
"""Image update changes index.mjs → re-copied; installed deps survive."""
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
source = tmp_path / "src"
_seed_source(source)
_freeze_writability(monkeypatch, writable=False)
mirror = sidecar_paths.resolve_sidecar_dir(source)
# Simulate a completed npm install in the mirror.
(mirror / "node_modules").mkdir()
(mirror / "node_modules" / "installed.txt").write_text("x", encoding="utf-8")
# Image update rewrites a source file.
(source / "index.mjs").write_text("// index.mjs v2\n", encoding="utf-8")
resolved = sidecar_paths.resolve_sidecar_dir(source)
assert resolved == mirror
assert (mirror / "index.mjs").read_text(encoding="utf-8") == "// index.mjs v2\n"
assert (mirror / "node_modules" / "installed.txt").exists()
def test_dir_writable_probe(tmp_path) -> None:
assert sidecar_paths.dir_writable(tmp_path) is True
ro = tmp_path / "ro"
ro.mkdir()
ro.chmod(0o555)
try:
if os.geteuid() == 0: # pragma: no cover - root ignores perms
pytest.skip("root bypasses directory permissions")
assert sidecar_paths.dir_writable(ro) is False
finally:
ro.chmod(0o755)
def test_adapter_import_does_not_resolve_sidecar_dir(monkeypatch) -> None:
"""Importing the adapter must not probe the filesystem or mirror files.
resolve_sidecar_dir() touch/unlink-probes the source tree and may copy
files to HERMES_HOME; the adapter and CLI resolve lazily on first use so
a bare import (plugin discovery, `hermes --help`, test collection) has
no filesystem side effects.
"""
import importlib
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon import cli as photon_cli
def _boom(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError("resolve_sidecar_dir called at import time")
monkeypatch.setattr(sidecar_paths, "resolve_sidecar_dir", _boom)
try:
importlib.reload(photon_adapter)
importlib.reload(photon_cli)
# Nothing resolved yet.
assert photon_adapter._SIDECAR_DIR is None
assert photon_cli._SIDECAR_DIR is None
# First real use resolves (and would call resolve_sidecar_dir).
with pytest.raises(AssertionError, match="import time"):
photon_adapter._sidecar_dir()
# A monkeypatched _SIDECAR_DIR (the pattern existing tests use) is
# honored without touching the resolver.
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", Path("/tmp/x"))
assert photon_adapter._sidecar_dir() == Path("/tmp/x")
assert photon_adapter._npm_error_log() == Path("/tmp/x/.photon-npm-error.log")
finally:
# Restore real bindings for any later test importing these modules.
monkeypatch.undo()
importlib.reload(photon_adapter)
importlib.reload(photon_cli)
@@ -0,0 +1,277 @@
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import textwrap
import time
import urllib.request
from pathlib import Path
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
def _sidecar_env(port: int) -> dict[str, str]:
return {
**os.environ,
"PHOTON_PROJECT_ID": "test-project",
"PHOTON_PROJECT_SECRET": "test-secret",
"PHOTON_SIDECAR_PORT": str(port),
"PHOTON_SIDECAR_TOKEN": "test-token",
}
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _write_sidecar_fixture(tmp_path: Path, *, sdk_available: bool) -> Path:
sidecar = tmp_path / "sidecar"
sidecar.mkdir()
shutil.copyfile("plugins/platforms/photon/sidecar/index.mjs", sidecar / "index.mjs")
# index.mjs imports sibling helper modules — copy every non-patch .mjs so
# the fixture keeps working as helpers are extracted from index.mjs.
for helper in Path("plugins/platforms/photon/sidecar").glob("*.mjs"):
if helper.name in ("index.mjs", "patch-spectrum-mixed-attachments.mjs"):
continue
shutil.copyfile(helper, sidecar / helper.name)
(sidecar / "patch-spectrum-mixed-attachments.mjs").write_text(
"export function patchSpectrumTs() { throw new Error('forced patch failure'); }\n",
encoding="utf-8",
)
if not sdk_available:
return sidecar
package = sidecar / "node_modules" / "spectrum-ts"
(package / "providers").mkdir(parents=True)
(package / "package.json").write_text(
json.dumps(
{
"name": "spectrum-ts",
"type": "module",
"exports": {
".": "./index.js",
"./providers/imessage": "./providers/imessage.js",
},
}
),
encoding="utf-8",
)
(package / "index.js").write_text(
textwrap.dedent(
"""
export async function Spectrum() {
return {
messages: { [Symbol.asyncIterator]() { return { next: () => new Promise(() => {}) }; } },
stop: async () => undefined,
};
}
export const attachment = value => value;
export const voice = value => value;
export const text = value => value;
export const markdown = value => value;
export const typing = value => value;
"""
).lstrip(),
encoding="utf-8",
)
(package / "providers" / "imessage.js").write_text(
"export function imessage() { return {}; }\nimessage.config = () => ({});\n",
encoding="utf-8",
)
return sidecar
def test_sidecar_patch_failure_still_reaches_health_endpoint(tmp_path: Path) -> None:
"""The compatibility patch is optional when the SDK itself remains usable."""
sidecar = _write_sidecar_fixture(tmp_path, sdk_available=True)
port = _free_port()
proc = subprocess.Popen(
["node", "index.mjs"],
cwd=sidecar,
env=_sidecar_env(port),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
request = urllib.request.Request(
f"http://127.0.0.1:{port}/healthz",
data=b"{}",
headers={"X-Hermes-Sidecar-Token": "test-token"},
method="POST",
)
try:
deadline = time.monotonic() + 5
while True:
try:
with urllib.request.urlopen(request, timeout=0.5) as response:
payload = json.load(response)
break
except OSError:
if proc.poll() is not None or time.monotonic() >= deadline:
raise
time.sleep(0.05)
assert payload["ok"] is True
assert proc.poll() is None
finally:
proc.terminate()
_, stderr = proc.communicate(timeout=5)
assert "forced patch failure" in stderr
def _tabify(src: str) -> str:
"""Convert the fixture's two-space indentation to the tab indentation that
spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors
(which match tabs) apply exactly as they do against a real install."""
out = []
for line in src.split("\n"):
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped)
return "\n".join(out)
# A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper:
# the two functions the patch rewrites (`rebuildFromAppleMessage` for
# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of
# the helpers they close over. Mirrors the published shape — tab-indented (via
# `_tabify`), `const ... = async` declarations, single-line builder calls — so
# the anchors exercise the real code path, and exporting the two functions lets
# the test assert runtime behavior rather than only string shape.
_SPECTRUM_IMESSAGE_FIXTURE = """
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
const asText = (text) => ({ type: "text", text });
const asCustom = (message) => ({ type: "custom" });
const asProviderGroup = (items) => ({ type: "group", items });
const messageAttachments = (message) => message.content.attachments ?? [];
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp });
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex };
if (parentId !== void 0) msg.parentId = parentId;
return msg;
};
const cacheMessage = (cache, message) => { cache.set(message.id, message); };
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
const messageGuidStr = message.guid;
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
const attachments = messageAttachments(message);
if (attachments.length === 1) {
const info = attachments[0];
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
}
if (attachments.length > 1) {
const items = [];
for (let i = 0; i < attachments.length; i++) {
const info = attachments[i];
if (!info) continue;
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
}
return {
...base,
id: messageGuidStr,
content: asProviderGroup(items)
};
}
const text = message.content.text;
return {
...base,
id: messageGuidStr,
content: text ? asText(text) : asCustom(message)
};
};
const toInboundMessages = async (client, cache, event, phone) => {
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
const messageGuidStr = event.message.guid;
const attachments = messageAttachments(event.message);
if (attachments.length === 1) {
const info = attachments[0];
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
cacheMessage(cache, msg);
return [msg];
}
if (attachments.length > 1) {
const items = [];
for (let i = 0; i < attachments.length; i++) {
const info = attachments[i];
if (!info) continue;
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
}
const parent = {
...base,
id: messageGuidStr,
content: asProviderGroup(items)
};
cacheMessage(cache, parent);
return [parent];
}
const text = event.message.content.text;
const msg = {
...base,
id: messageGuidStr,
content: text ? asText(text) : asCustom(event.message)
};
cacheMessage(cache, msg);
return [msg];
};
export { rebuildFromAppleMessage, toInboundMessages };
"""
def _write_fixture(tmp_path: Path) -> Path:
dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist"
dist.mkdir(parents=True)
chunk = dist / "index.js"
chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8")
return chunk
def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None:
"""The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk
and rewrite both inbound mappers to thread text through attachment bubbles."""
chunk = _write_fixture(tmp_path)
result = subprocess.run(
["node", str(_PATCHER), str(tmp_path)],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
patched = chunk.read_text(encoding="utf-8")
assert "Preserve mixed text + attachment iMessage payloads" in patched
# Single-attachment bubbles wrap the text + attachment in a group...
assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild
assert "content: asProviderGroup([textMsg, msg])" in patched # inbound
# ...multi-attachment bubbles keep the group and shift attachment indices.
assert "content: asProviderGroup(items)" in patched
assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched
# The text is captured in both mappers before the attachment branches run.
assert "const text2 = message.content.text;" in patched
assert "const text2 = event.message.content.text;" in patched
# Re-running is a no-op (idempotent self-heal on every sidecar start).
again = subprocess.run(
["node", str(_PATCHER), str(tmp_path)],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert again.returncode == 0, again.stderr
assert chunk.read_text(encoding="utf-8") == patched
@@ -0,0 +1,12 @@
"""Regression tests for Photon adapter streaming behavior."""
from plugins.platforms.photon.adapter import PhotonAdapter
def test_photon_adapter_does_not_support_message_editing() -> None:
"""PhotonAdapter.SUPPORTS_MESSAGE_EDITING must be False.
Photon (iMessage) has no real edit API for already-sent messages.
This attribute signals the gateway to suppress the streaming cursor
instead of leaving a stale tofu square (▉) behind when edit attempts fail.
"""
assert PhotonAdapter.SUPPORTS_MESSAGE_EDITING is False
@@ -0,0 +1,87 @@
"""Behavior tests for Photon raw-URL outbound routing (issue: markdown 500s).
The iMessage markdown builder enables data detection inside spectrum-ts. On
some IMAgentKit sends, that path returns a 500 when the message contains a raw
URL. The sidecar keeps markdown rendering for URL-free messages, but must use
plain text for messages containing URLs so iMessage can auto-link them without
hitting the data-detection failure path.
The routing decision lives in
``plugins/platforms/photon/sidecar/send-format.mjs`` (imported by index.mjs's
``/send`` handler). These tests *execute* that real module under node and
assert the chosen builder for representative payloads — they do not read the
sidecar source.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Dict, Tuple
import pytest
_MODULE = Path("plugins/platforms/photon/sidecar/send-format.mjs").resolve()
_CASES: Dict[str, Tuple[str, str, str]] = {
# name: (format, text, expected builder)
"markdown_without_url_keeps_markdown": (
"markdown", "**bold** and `code`", "markdown",
),
"markdown_with_https_url_falls_back_to_text": (
"markdown", "see **this**: https://example.com/a?b=1", "text",
),
"markdown_with_http_url_falls_back_to_text": (
"markdown", "http://example.com", "text",
),
"markdown_link_syntax_also_falls_back_to_text": (
"markdown", "[docs](https://example.com/docs)", "text",
),
"markdown_with_uppercase_scheme_falls_back_to_text": (
"markdown", "HTTPS://EXAMPLE.COM is loud", "text",
),
"markdown_bare_domain_without_scheme_keeps_markdown": (
# Only scheme'd URLs trip iMessage's data-detection 500; a bare domain
# is ordinary text and must keep markdown rendering.
"markdown", "ask example.com about *this*", "markdown",
),
"text_format_stays_text": ("text", "plain message", "text"),
"text_format_with_url_stays_text": (
"text", "https://example.com", "text",
),
}
@pytest.fixture(scope="module")
def verdicts() -> Dict[str, str]:
"""Run every case through the real send-format module in one node call."""
harness = (
f"import {{ chooseSendFormat }} from {json.dumps(_MODULE.as_uri())};\n"
"const chunks = [];\n"
"process.stdin.on('data', (c) => chunks.push(c));\n"
"process.stdin.on('end', () => {\n"
" const cases = JSON.parse(Buffer.concat(chunks).toString('utf-8'));\n"
" const out = {};\n"
" for (const [name, [format, text]] of Object.entries(cases)) {\n"
" out[name] = chooseSendFormat(format, text);\n"
" }\n"
" process.stdout.write(JSON.stringify(out));\n"
"});\n"
)
payload = {name: [fmt, text] for name, (fmt, text, _) in _CASES.items()}
run = subprocess.run(
["node", "--input-type=module", "-e", harness],
input=json.dumps(payload),
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
@pytest.mark.parametrize("name", sorted(_CASES))
def test_send_builder_selection(name: str, verdicts: Dict[str, str]) -> None:
_, _, expected = _CASES[name]
assert verdicts[name] == expected
@@ -0,0 +1,231 @@
"""Zombie-stream watchdog tests (half-open gRPC stream, issue #54036).
spectrum-ts only reconnects when its inbound iterator throws or ends; a
half-open ("zombie") socket makes the iterator hang forever — no error, no
end — so inbound silently dies while the sidecar process looks healthy.
The salvaged design has two layers:
1. Sidecar (node): ``stream-staleness.mjs`` decision rules + a watchdog in
``index.mjs`` that tracks the iterator's last yield, probes only after a
conservative silence threshold, and classifies degraded ONLY when a probe
proves connectivity while the stream is silent (never on silence alone,
never on an inconclusive probe). Degraded feeds the existing exit-75
restart path and the ``staleness`` block on ``/healthz``.
2. Adapter (python): ``_monitor_sidecar_health`` surfaces the new staleness
fields; ``_probe_once`` has strict tri-state semantics (alive / hung /
inconclusive).
These tests execute the real node decision module and drive the adapter
against mocked ``/healthz`` responses — style follows
test_overflow_recovery.py / test_spectrum_patch.py. No ports are bound and no
gRPC traffic occurs.
"""
from __future__ import annotations
import asyncio
import json
import subprocess
from pathlib import Path
from typing import Any, Dict
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon.adapter import PhotonAdapter
_MODULE = Path("plugins/platforms/photon/sidecar/stream-staleness.mjs").resolve()
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
# -- Sidecar decision rules (execute the real node module) -------------------
def _run_staleness_harness(script: str) -> Dict[str, Any]:
harness = (
"import { classifyProbeRejection, shouldProbe, isZombieSuspect } "
f"from {json.dumps(_MODULE.as_uri())};\n"
+ script
)
run = subprocess.run(
["node", "--input-type=module", "-e", harness],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_probe_rejection_classification_is_strict() -> None:
"""Only not-found-shaped rejections prove liveness; everything else is
inconclusive — a rejected probe is NEVER treated as alive (#45580's
original /probe treated any rejection as alive, which was too loose)."""
out = _run_staleness_harness(
"""
const results = {
notFoundCode: classifyProbeRejection({ code: 5, message: "5 NOT_FOUND: nope" }),
notFoundText: classifyProbeRejection(new Error("message not found")),
sdkNotFound: classifyProbeRejection({ code: "notFound", message: "missing" }),
unavailable: classifyProbeRejection({ code: 14, message: "14 UNAVAILABLE: connect failed" }),
deadline: classifyProbeRejection({ code: 4, message: "4 DEADLINE_EXCEEDED" }),
generic: classifyProbeRejection(new Error("socket hang up")),
weird: classifyProbeRejection("string error"),
};
process.stdout.write(JSON.stringify(results));
"""
)
# Completed round-trips (server said not-found for our synthetic id).
for name in ("notFoundCode", "notFoundText", "sdkNotFound"):
assert out[name]["alive"] is True, name
assert out[name]["inconclusive"] is False, name
# Everything else: not alive AND explicitly inconclusive.
for name in ("unavailable", "deadline", "generic", "weird"):
assert out[name]["alive"] is False, name
assert out[name]["inconclusive"] is True, name
def test_should_probe_requires_silence_past_threshold_and_cooldown() -> None:
out = _run_staleness_harness(
"""
const MIN10 = 10 * 60 * 1000;
const results = {
quietButUnderThreshold: shouldProbe(MIN10 - 1, MIN10, MIN10, 120000),
pastThreshold: shouldProbe(MIN10 + 1, MIN10, MIN10, 120000),
pastThresholdButCoolingDown: shouldProbe(MIN10 + 1, MIN10, 1000, 120000),
watchdogDisabled: shouldProbe(MIN10 * 100, 0, MIN10, 120000),
watchdogDisabledNegative: shouldProbe(MIN10 * 100, -1, MIN10, 120000),
};
process.stdout.write(JSON.stringify(results));
"""
)
assert out["quietButUnderThreshold"] is False
assert out["pastThreshold"] is True
assert out["pastThresholdButCoolingDown"] is False
assert out["watchdogDisabled"] is False
assert out["watchdogDisabledNegative"] is False
def test_zombie_requires_probe_proven_connectivity_never_silence_alone() -> None:
"""The core conservatism rule: shared lines can be quiet for hours, so a
zombie is declared only when the stream is silent past threshold AND a
probe PROVED the wire works (stream dead, channel alive)."""
out = _run_staleness_harness(
"""
const MIN10 = 10 * 60 * 1000;
const alive = { alive: true };
const inconclusive = { alive: false };
const results = {
silentAndProbeAlive: isZombieSuspect(MIN10 * 2, MIN10, alive),
silentButProbeInconclusive: isZombieSuspect(MIN10 * 2, MIN10, inconclusive),
silentNoProbe: isZombieSuspect(MIN10 * 2, MIN10, null),
hoursOfSilenceInconclusive: isZombieSuspect(MIN10 * 36, MIN10, inconclusive),
notSilentEnough: isZombieSuspect(MIN10 - 1, MIN10, alive),
disabled: isZombieSuspect(MIN10 * 2, 0, alive),
};
process.stdout.write(JSON.stringify(results));
"""
)
assert out["silentAndProbeAlive"] is True
# Silence alone — even 6 hours of it — is NEVER a zombie verdict.
assert out["silentButProbeInconclusive"] is False
assert out["silentNoProbe"] is False
assert out["hoursOfSilenceInconclusive"] is False
assert out["notSilentEnough"] is False
assert out["disabled"] is False
# -- Adapter surfacing of the new /healthz staleness fields ------------------
def _healthz_payload(**staleness: Any) -> Dict[str, Any]:
return {
"ok": True,
"stream": {
"ok": True,
"state": "healthy",
"degradedForMs": 0,
"staleness": {
"lastInboundAt": "2026-07-28T00:00:00.000Z",
"silentForMs": 0,
"silenceThresholdMs": 600000,
"lastProbeAt": None,
"lastProbeOutcome": None,
"zombieSuspected": False,
**staleness,
},
},
}
@pytest.mark.asyncio
async def test_monitor_surfaces_zombie_suspected_without_fatal(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""zombieSuspected on /healthz is surfaced as a warning while the stream
is still 'ok' — the fatal path stays owned by the degraded state (the
sidecar escalates to degraded -> exit 75 itself)."""
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
adapter._sidecar_health_interval = 0.0
polls = 0
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
nonlocal polls
assert path == "/healthz"
polls += 1
if polls >= 2:
adapter._inbound_running = False
return _healthz_payload(
silentForMs=1_200_000,
lastProbeOutcome="alive",
zombieSuspected=True,
)
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
with caplog.at_level("WARNING"):
await adapter._monitor_sidecar_health()
assert adapter.has_fatal_error is False
assert any(
"suspected zombie stream" in rec.message for rec in caplog.records
)
# -- Adapter watchdog: inconclusive never counts toward respawn --------------
@pytest.mark.asyncio
async def test_inconclusive_probes_never_accumulate_toward_respawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Strict semantics end-to-end at the adapter: a 503/transport-error probe
(inconclusive) must not increment the failure counter the way the original
#45580 booleans did — only hung probes do."""
adapter = _make_adapter(monkeypatch)
class _Resp503:
status_code = 503
class _Client:
async def post(self, *args: Any, **kwargs: Any) -> Any:
return _Resp503()
adapter._http_client = _Client() # type: ignore[assignment]
# Many inconclusive probes in a row: mirror the watchdog's per-iteration
# bookkeeping (only "hung" increments) and assert no failures accrue.
for _ in range(10):
verdict = await adapter._probe_once()
assert verdict == "inconclusive"
if verdict == "hung":
adapter._probe_failures += 1
assert adapter._probe_failures == 0