#7767 makes _agent_status_callback declare 'nonlocal _last_runtime_model_identity' (reset on a fallback warning). #7760's test compiled the extracted callback alone at module level, where a nonlocal has no binding -> SyntaxError, failing all six relay tests. Wrap the callback in a scope that binds every nonlocal it declares. Verified: 16/16 with and without the Agent on py3.11; still 16/16 against master's callback (no nonlocal); reverting reproduces the 6 failures.
127 lines
4.9 KiB
Python
127 lines
4.9 KiB
Python
"""Agent session turn-lease waits surface as WebUI ``warning`` events.
|
|
|
|
When another Hermes process (gateway, CLI, cron) holds a session's turn lease,
|
|
the Agent's ``agent/turn_facade_lease.py`` emits:
|
|
|
|
* ``_emit_status`` (kind ``lifecycle``):
|
|
"⏳ Another Hermes process is using this session; waiting for it to finish
|
|
before starting your turn..." then "⏳ Still waiting for the other Hermes
|
|
process on this session (Ns)..." and, on admission, "Session is free; loading
|
|
the latest transcript...";
|
|
* ``_emit_warning`` (kind ``warn``) on timeout: "⏳ Another Hermes process kept
|
|
this session busy too long. Your message was not processed - ...".
|
|
|
|
``_agent_status_callback`` used to drop every one of these, so a WebUI turn
|
|
looked stuck with no explanation. They are now relayed as
|
|
``{'type': 'session_lease_wait'}`` warnings shown by the existing
|
|
messages.js ``warning`` listener.
|
|
"""
|
|
import ast
|
|
import pathlib
|
|
|
|
import pytest
|
|
|
|
from api import streaming
|
|
|
|
REPO = pathlib.Path(__file__).resolve().parents[1]
|
|
|
|
AGENT_WAIT = (
|
|
"⏳ Another Hermes process is using this session; "
|
|
"waiting for it to finish before starting your turn..."
|
|
)
|
|
AGENT_STILL = "⏳ Still waiting for the other Hermes process on this session (30s)..."
|
|
AGENT_FREE = "Session is free; loading the latest transcript..."
|
|
AGENT_TIMEOUT = (
|
|
"⏳ Another Hermes process kept this session busy too long. Your message was not "
|
|
"processed - wait for the other process to finish, then send it again."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kind,text,expected",
|
|
[
|
|
("lifecycle", AGENT_WAIT, True),
|
|
("lifecycle", AGENT_STILL, True),
|
|
("lifecycle", AGENT_FREE, True),
|
|
("warn", AGENT_TIMEOUT, True),
|
|
("LIFECYCLE", AGENT_WAIT, True),
|
|
# Never classify by text alone: other kinds / user text stay dropped.
|
|
("user", AGENT_WAIT, False),
|
|
("", AGENT_WAIT, False),
|
|
("lifecycle", "Compressing conversation history", False),
|
|
("lifecycle", "Rate limited — switching to fallback", False),
|
|
("lifecycle", "", False),
|
|
],
|
|
)
|
|
def test_session_lease_wait_classification(kind, text, expected):
|
|
assert streaming._is_session_lease_wait_message(kind, text) is expected
|
|
|
|
|
|
def _build_status_callback():
|
|
"""Extract ``_agent_status_callback`` from ``_run_agent_streaming`` and bind
|
|
it to a recording ``put`` so the real relay logic is exercised."""
|
|
src = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
|
tree = ast.parse(src)
|
|
fn = next(
|
|
node
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.FunctionDef) and node.name == "_agent_status_callback"
|
|
)
|
|
# The callback is a closure inside ``_run_agent_streaming`` and may declare
|
|
# ``nonlocal`` state (e.g. the runtime-model dedupe identity it resets on a
|
|
# fallback warning). Compiled alone at module level, ``nonlocal`` has no
|
|
# binding and raises SyntaxError, so wrap it in an enclosing function that
|
|
# binds every nonlocal name it declares, then return the inner callback.
|
|
nonlocals = sorted({
|
|
name
|
|
for node in ast.walk(fn)
|
|
if isinstance(node, ast.Nonlocal)
|
|
for name in node.names
|
|
})
|
|
binds = "".join(f" {name} = None\n" for name in nonlocals)
|
|
outer = ast.parse(f"def _status_callback_scope():\n{binds} pass\n").body[0]
|
|
assert isinstance(outer, ast.FunctionDef)
|
|
outer.body[-1:] = [fn, ast.Return(value=ast.Name(id=fn.name, ctx=ast.Load()))]
|
|
module = ast.fix_missing_locations(ast.Module(body=[outer], type_ignores=[]))
|
|
events = []
|
|
ns = {
|
|
"put": lambda event, data: events.append((event, data)),
|
|
"session_id": "sid-1",
|
|
"_captured_terminal_error": [None],
|
|
"_is_agent_compression_start_status": streaming._is_agent_compression_start_status,
|
|
"_is_fallback_lifecycle_message": streaming._is_fallback_lifecycle_message,
|
|
"_is_session_lease_wait_message": streaming._is_session_lease_wait_message,
|
|
}
|
|
exec(compile(module, "streaming_status_callback", "exec"), ns)
|
|
return ns["_status_callback_scope"](), events
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kind,text",
|
|
[
|
|
("lifecycle", AGENT_WAIT),
|
|
("lifecycle", AGENT_STILL),
|
|
("lifecycle", AGENT_FREE),
|
|
("warn", AGENT_TIMEOUT),
|
|
],
|
|
)
|
|
def test_status_callback_relays_lease_wait_as_warning(kind, text):
|
|
callback, events = _build_status_callback()
|
|
callback(kind, text)
|
|
assert events == [("warning", {"type": "session_lease_wait", "message": text})]
|
|
|
|
|
|
def test_status_callback_still_drops_unrelated_lifecycle_chatter():
|
|
callback, events = _build_status_callback()
|
|
callback("lifecycle", "Loaded 12 tools")
|
|
callback("user", AGENT_WAIT)
|
|
assert events == []
|
|
|
|
|
|
def test_status_callback_fallback_warning_unchanged():
|
|
callback, events = _build_status_callback()
|
|
callback("lifecycle", "Rate limited — switching to fallback model")
|
|
assert events == [
|
|
("warning", {"type": "fallback", "message": "Rate limited — switching to fallback model"})
|
|
]
|