Files
hermes-webui/tests/test_bg_task_complete_wakeup.py
nesquena-hermes 26e133e3e8
Some checks failed
Release & Docker / release (push) Has been cancelled
[HELD — independent review pending] Release v0.51.340 — bg_task agent wakeup (trio #2968+#2971+#2979) (#3867)
* stage bg_task trio combined (#2979 superset) on master for deep review

* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test

* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix

* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)

* bg_task trio: apply 3 independent-review (greptile) fixes

1. start_session_turn now threads the session PROFILE model defaults
   (_read_profile_model_config) into the wakeup model-resolve, so a brand-new
   session with an empty model falls back to the profile default not global
   DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
   default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
   timestamp map can't grow one permanent entry per session forever.

nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.

* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
2026-06-08 22:36:18 -07:00

324 lines
11 KiB
Python

"""Wakeup tests for the renamed ``bg_task_complete`` SSE event.
This file replaces the legacy ``test_process_complete_wakeup.py`` after the
R2 §Q1 / Q4 contract update with the maintainer:
- Q1: the canonical SSE event is now ``bg_task_complete`` carrying the
minimal ``{session_id, task_id, completed_at, summary?, event_id}``
payload (the legacy ``process_complete`` name is dual-emitted under
PR (a) only as a 1-PR-cycle compatibility shim and is removed in
PR (b)).
- Q4: each emit must carry a fresh server-side ``event_id`` so the WebUI
can build a consumer-side TTL ring buffer for cross-disconnect
dedupe in a follow-up PR.
The tests below cover the wakeup-emit hot path end to end:
1. A completion event flowing through ``_process_one`` produces the
canonical ``bg_task_complete`` SSE emission with the trimmed payload
and a non-empty ``event_id``.
2. The same call also emits the legacy ``process_complete`` shim with the
same payload + same ``event_id``, so a consumer running an old
listener still wakes exactly once.
3. When ``_process_one`` runs while no per-session emit-coalesce window
is pending, the event is emitted immediately (i.e. wakeup is not
dropped by the throttle gate on a single completion).
4. The previous file name ``tests/test_process_complete_wakeup.py`` must
remain absent in the BACKEND-tier slice.
"""
from __future__ import annotations
import os
# The fake process-registry stub + its installer were duplicated verbatim in
# three bg_task_complete suites; they now live once in tests/_wakeup_helpers.py
# (Greptile review on PR #2979). Import under the legacy local names so the
# rest of this module is unchanged.
from tests._wakeup_helpers import FakeProcessRegistry as _FakeProcessRegistry
from tests._wakeup_helpers import install_fake_registry as _install_fake_registry
def _reset_cfg_state():
from api import config as _cfg
from api import background_process as bp
with _cfg.PROCESS_SESSION_INDEX_LOCK:
_cfg.PROCESS_SESSION_INDEX.clear()
_cfg.PENDING_BG_TASK_COMPLETIONS.clear()
_cfg.BG_TASK_COMPLETE_EVENTS_SEEN.clear()
with _cfg.STREAMS_LOCK:
_cfg.STREAMS.clear()
if hasattr(_cfg, "ACTIVE_RUNS"):
with _cfg.ACTIVE_RUNS_LOCK:
_cfg.ACTIVE_RUNS.clear()
if hasattr(bp, "_LAST_EMIT_TS"):
bp._LAST_EMIT_TS.clear()
if hasattr(bp, "_PENDING_EMIT_PAYLOADS"):
bp._PENDING_EMIT_PAYLOADS.clear()
if hasattr(bp, "_PENDING_EMIT_TIMERS"):
bp._PENDING_EMIT_TIMERS.clear()
def _capture_emits(monkeypatch):
"""Replace the per-session emit fan-out with a capturing list."""
from api import background_process as bp
emits: list[tuple[str, dict]] = []
def _capture(session_id: str, event: str, data: dict) -> int:
emits.append((event, data))
return 1
monkeypatch.setattr(bp, "_emit_to_session_streams", _capture)
# Run the coalesce gate in pass-through mode so a single completion
# exercises the immediate-emit branch (the throttle behaviour itself is
# covered exhaustively by tests/test_bg_task_complete_throttle.py).
monkeypatch.setattr(bp, "_EMIT_COALESCE_WINDOW_SECS", 0.0)
return emits
def test_bg_task_complete_wakeup_emits_canonical_event_with_event_id(monkeypatch):
"""``_process_one`` emits the canonical ``bg_task_complete`` SSE event
with the R2 §Q1 trimmed payload and a fresh server-side ``event_id``
(R2 §Q4).
"""
fake = _FakeProcessRegistry()
fake.register("task-wakeup-1", "sess-wakeup-1")
_install_fake_registry(monkeypatch, fake)
_reset_cfg_state()
from api import background_process as bp
bp.register_process_session("sess-wakeup-1", "sess-wakeup-1")
emits = _capture_emits(monkeypatch)
monkeypatch.setattr(bp, "_start_server_side_wakeup_turn", lambda *_args, **_kwargs: None)
evt = {
"type": "completion",
"session_id": "task-wakeup-1",
"session_key": "sess-wakeup-1",
"command": "sleep 1",
"exit_code": 0,
"output": "done",
}
bp._process_one(evt)
names = [e[0] for e in emits]
assert "bg_task_complete" in names, (
f"canonical bg_task_complete emit missing: {names}"
)
canonical_payloads = [d for ev, d in emits if ev == "bg_task_complete"]
assert canonical_payloads, "no canonical bg_task_complete payload captured"
payload = canonical_payloads[0]
expected_required = {"session_id", "task_id", "completed_at", "event_id"}
allowed = expected_required | {"summary"}
assert expected_required <= set(payload), (
f"missing required keys in bg_task_complete payload: {payload}"
)
assert set(payload) <= allowed, (
f"unexpected keys in trimmed bg_task_complete payload: {payload}"
)
# The legacy/dropped keys must NOT survive the T1 trim.
for dropped in (
"command",
"exit_code",
"type",
"stdout_preview",
"wakeup_prompt",
"emitted_at",
"process_id",
):
assert dropped not in payload, (
f"{dropped!r} should be dropped from bg_task_complete payload"
)
# Field-rename invariants.
assert payload["session_id"] == "sess-wakeup-1"
assert payload["task_id"] == "task-wakeup-1"
assert isinstance(payload["completed_at"], float)
# R2 §Q4: ``event_id`` is a non-empty string (uuid4().hex => 32 chars).
assert isinstance(payload["event_id"], str)
assert len(payload["event_id"]) >= 8
class _FakeHandler:
"""Minimal handler stub for exercising ``handle_post`` directly.
Mirrors the pattern in ``tests/test_issue1909_csp_report_only.py`` —
captures status + response headers + body without spinning a real HTTP
server.
"""
def __init__(self, body: bytes = b"{}", headers: dict | None = None):
import io as _io
self.headers = {
"Content-Length": str(len(body)),
"Content-Type": "application/json",
**(headers or {}),
}
self.rfile = _io.BytesIO(body)
self.wfile = _io.BytesIO()
self.client_address = ("127.0.0.1", 12345)
self.status: int | None = None
self.sent_headers: dict[str, str] = {}
def send_response(self, status: int) -> None:
self.status = status
def send_header(self, key: str, value: str) -> None:
self.sent_headers[key] = value
def end_headers(self) -> None:
pass
def test_legacy_process_complete_ack_returns_410_gone_with_x_replaced_by():
"""T1 deprecation alias: the old ``/api/process-complete-ack`` POST path
must return HTTP 410 Gone and an ``X-Replaced-By`` header pointing at
``/api/bg-task-complete-ack`` (V-a-final criterion #6 + D-a-fix item #1).
"""
import json as _json
from urllib.parse import urlparse
import api.routes as routes
handler = _FakeHandler()
parsed = urlparse("/api/process-complete-ack")
result = routes.handle_post(handler, parsed)
assert result is True, "deprecated ack endpoint must claim the request"
assert handler.status == 410, (
f"expected HTTP 410 Gone for deprecated ack path, got {handler.status}"
)
assert handler.sent_headers.get("X-Replaced-By") == "/api/bg-task-complete-ack", (
f"X-Replaced-By header missing or wrong: {handler.sent_headers}"
)
body = handler.wfile.getvalue()
# Body is gzip-wrapped only if Accept-Encoding allowed it; the fake
# handler does not set Accept-Encoding so the body is plain JSON.
payload = _json.loads(body.decode("utf-8"))
assert payload.get("replaced_by") == "/api/bg-task-complete-ack"
assert "gone" in payload.get("error", "").lower()
def test_bg_task_complete_ack_marks_process_id_alias_deprecated(monkeypatch):
"""The diagnostic ack endpoint keeps ``process_id`` as a transitional
request alias, but makes that legacy usage visible via ``Deprecation``.
"""
import json as _json
import types as _types
import api.routes as routes
monkeypatch.setattr(
routes,
"get_session",
lambda sid: _types.SimpleNamespace(session_id=sid),
)
handler = _FakeHandler()
routes._handle_bg_task_complete_ack(
handler,
{"session_id": "sess-legacy-alias", "process_id": "proc-legacy-1"},
)
assert handler.status == 200
assert handler.sent_headers.get("Deprecation") == "true"
payload = _json.loads(handler.wfile.getvalue().decode("utf-8"))
assert payload["task_id"] == "proc-legacy-1"
def test_bg_task_complete_ack_marks_mixed_process_id_presence_deprecated(monkeypatch):
"""If a request still includes ``process_id``, surface the transitional
alias even when the canonical ``task_id`` is also present.
"""
import json as _json
import types as _types
import api.routes as routes
monkeypatch.setattr(
routes,
"get_session",
lambda sid: _types.SimpleNamespace(session_id=sid),
)
handler = _FakeHandler()
routes._handle_bg_task_complete_ack(
handler,
{
"session_id": "sess-mixed-alias",
"task_id": "task-canonical-1",
"process_id": "proc-legacy-1",
},
)
assert handler.status == 200
assert handler.sent_headers.get("Deprecation") == "true"
payload = _json.loads(handler.wfile.getvalue().decode("utf-8"))
assert payload["task_id"] == "task-canonical-1"
def test_bg_task_complete_ack_canonical_task_id_has_no_deprecation_header(monkeypatch):
"""Canonical ``task_id`` requests should not be marked deprecated."""
import types as _types
import api.routes as routes
monkeypatch.setattr(
routes,
"get_session",
lambda sid: _types.SimpleNamespace(session_id=sid),
)
handler = _FakeHandler()
routes._handle_bg_task_complete_ack(
handler,
{"session_id": "sess-canonical", "task_id": "task-canonical-1"},
)
assert handler.status == 200
assert "Deprecation" not in handler.sent_headers
def test_bg_task_complete_ack_empty_process_id_alias_has_no_deprecation_header(monkeypatch):
"""An empty transitional alias key should not signal real legacy usage."""
import types as _types
import api.routes as routes
monkeypatch.setattr(
routes,
"get_session",
lambda sid: _types.SimpleNamespace(session_id=sid),
)
handler = _FakeHandler()
routes._handle_bg_task_complete_ack(
handler,
{
"session_id": "sess-canonical-empty-alias",
"task_id": "task-canonical-1",
"process_id": "",
},
)
assert handler.status == 200
assert "Deprecation" not in handler.sent_headers
def test_old_process_complete_wakeup_test_file_is_absent():
"""The legacy filename ``tests/test_process_complete_wakeup.py`` must
remain absent on this branch — the rename is part of the BACKEND-tier
T1 contract and is required by V-a-final criterion #9.
"""
here = os.path.dirname(os.path.abspath(__file__))
legacy = os.path.join(here, "test_process_complete_wakeup.py")
assert not os.path.exists(legacy), (
f"legacy {legacy!r} must not exist after the T1 rename"
)