hermes-agent/tests/tools/test_delegate_apiserver_background.py
Ian Ker-Seymer 246eacea7b fix(gateway): deliver kanban/delegate wake-ups to api_server sessions
Wake-ups for kanban notifications and background delegation completions were
injected via handle_message() using a build_session_key()-derived key, which
can never match the raw X-Hermes-Session-Id key that api_server sessions run
under — so the wake landed in a session nobody was reading. On top of that,
ApiServerAdapter.send() reports failure without raising, and that was treated
as a successful delivery, so the notify cursor advanced past events that were
permanently lost; and background delegation was forced synchronous on
api_server since there was no way to wake the session afterward.

Fix: route wake-ups for non-push adapters through a self-post to
/v1/chat/completions with the original session id, treat non-raising send
failures as failures (rewind instead of advancing the cursor), and re-enable
background delegation whenever a session id is available to wake.

The origin session id is captured from the request-scoped api_server chat_id
binding rather than HERMES_SESSION_ID: constructing a child agent calls
set_current_session_id() with the subagent's internal id, clobbering that
variable right before dispatch would read it and misrouting the wake into
the subagent's own session.

Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
2026-07-23 11:55:17 -07:00

190 lines
7.1 KiB
Python

"""delegate_task(background=true) on stateless API-server sessions.
Previously async_delivery_supported()=False forced SYNCHRONOUS execution for
every background dispatch on the API server, blocking the whole turn. Now
that background completions can wake the originating session via the
/v1/chat/completions self-post (gateway/wake.py), a session-continuable
turn (raw session id bound as the api_server chat_id) dispatches async; only
session-id-less one-shot requests keep the sync fallback.
The wake target must be captured from the request-scoped chat_id binding,
NOT from HERMES_SESSION_ID: constructing a child agent calls
set_current_session_id(child.session_id), clobbering the HERMES_SESSION_ID
ContextVar and os.environ with the subagent's internal id before the
dispatch code reads it — the fake child build below reproduces that clobber.
"""
import json
import time
from unittest.mock import MagicMock
import pytest
from gateway.session_context import set_session_vars
from tools.process_registry import process_registry
@pytest.fixture(autouse=True)
def _clean_queue_and_context(monkeypatch):
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
while not process_registry.completion_queue.empty():
try:
process_registry.completion_queue.get_nowait()
except Exception:
break
yield
# Restore ContextVars to the pristine "never set" sentinel rather than
# clear_session_vars()'s explicit-"" state, which would mask env vars for
# unrelated tests running later in the same worker.
import gateway.session_context as sc
for var in sc._VAR_MAP.values():
var.set(sc._UNSET)
sc._SESSION_ASYNC_DELIVERY.set(sc._UNSET)
# set_current_session_id (invoked by the clobber-reproducing fake child
# build) writes os.environ directly — scrub it so it can't leak into
# other test modules.
import os
os.environ.pop("HERMES_SESSION_ID", None)
while not process_registry.completion_queue.empty():
try:
process_registry.completion_queue.get_nowait()
except Exception:
break
def _drain_one(timeout=5.0):
deadline = time.time() + timeout
while time.time() < deadline:
if not process_registry.completion_queue.empty():
return process_registry.completion_queue.get_nowait()
time.sleep(0.02)
return None
def _fake_parent():
parent = MagicMock()
parent._delegate_depth = 0
parent.session_id = "sess"
parent._interrupt_requested = False
parent._active_children = []
parent._active_children_lock = None
return parent
def _patch_delegate(monkeypatch):
import tools.delegate_tool as dt
fake_child = MagicMock()
fake_child._delegate_role = "leaf"
fake_child._subagent_id = "s1"
def fast_child(task_index, goal, child=None, parent_agent=None, **kw):
return {
"task_index": 0, "status": "completed", "summary": f"done: {goal}",
"api_calls": 1, "duration_seconds": 0.1, "model": "m",
"exit_reason": "completed",
}
creds = {
"model": "m", "provider": None, "base_url": None, "api_key": None,
"api_mode": None, "command": None, "args": None,
}
def clobbering_build_child(**kw):
# Reproduce what the real _build_child_agent -> AIAgent -> agent_init
# path does: it synchronizes the child's internal session id into the
# HERMES_SESSION_ID ContextVar + os.environ, clobbering the spawner's
# id ~milliseconds before delegate_tool dispatches the batch.
from gateway.session_context import set_current_session_id
set_current_session_id("20260715_child1")
return fake_child
monkeypatch.setattr(dt, "_build_child_agent", clobbering_build_child)
monkeypatch.setattr(dt, "_run_single_child", fast_child)
monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds)
return dt
def test_apiserver_session_with_id_dispatches_background(monkeypatch):
"""async_delivery=False + a raw session id (HERMES_SESSION_ID) →
background dispatch (the completion wakes the session via the
api_server self-post), NOT the forced-sync fallback."""
dt = _patch_delegate(monkeypatch)
monkeypatch.setenv("HERMES_SESSION_ID", "raw-sid-7")
set_session_vars(
platform="api_server",
chat_id="raw-sid-7",
session_key="raw-sid-7",
session_id="raw-sid-7",
async_delivery=False,
)
out = dt.delegate_task(
goal="bg on api_server", context="ctx",
background=True, parent_agent=_fake_parent(),
)
parsed = json.loads(out)
assert parsed["status"] == "dispatched", parsed
assert parsed["mode"] == "background"
evt = _drain_one()
assert evt is not None
assert evt["type"] == "async_delegation"
# The raw session id is stamped so the gateway drain can self-post the
# wake to the REAL session (session_key alone is the raw id here, which
# carries no parseable routing metadata). Crucially this is the SPAWNER's
# id, not the subagent-internal id the child build clobbered
# HERMES_SESSION_ID with (see clobbering_build_child).
assert evt["origin_session_id"] == "raw-sid-7"
# ---------------------------------------------------------------------------
# _current_origin_session_id — the clobber-proof origin capture helper
# ---------------------------------------------------------------------------
def test_origin_helper_survives_child_session_clobber(monkeypatch):
"""set_current_session_id (child agent construction) rewrites the
HERMES_SESSION_ID ContextVar + env, but the request-scoped chat_id
binding is untouched — the helper must keep returning the spawner's id."""
from gateway.session_context import set_current_session_id
from tools.async_delegation import _current_origin_session_id
set_session_vars(platform="api_server", chat_id="raw-origin-1")
assert _current_origin_session_id() == "raw-origin-1"
set_current_session_id("20260715_child2") # the clobber
assert _current_origin_session_id() == "raw-origin-1"
def test_origin_helper_empty_on_push_platforms(monkeypatch):
"""On push platforms chat_id identifies a chat, not a session — the
helper must yield empty rather than misroute a wake there."""
from tools.async_delegation import _current_origin_session_id
set_session_vars(platform="telegram", chat_id="123456789")
assert _current_origin_session_id() == ""
def test_apiserver_session_without_id_stays_synchronous(monkeypatch):
"""No session id to wake → keep the sync fallback (a detached result
would never re-enter any conversation)."""
dt = _patch_delegate(monkeypatch)
set_session_vars(
platform="api_server",
chat_id="",
session_key="",
session_id="",
async_delivery=False,
)
out = dt.delegate_task(
goal="one-shot", context="ctx",
background=True, parent_agent=_fake_parent(),
)
parsed = json.loads(out)
assert parsed.get("status") != "dispatched", parsed
assert "SYNCHRONOUSLY" in parsed.get("note", "")
assert process_registry.completion_queue.empty()