Files
hermes-agent/tests/agent/test_tool_executor_contextvar_propagation.py
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

161 lines
6.3 KiB
Python

"""Regression guard for PR #16660 (salvaged as PR #18027): ContextVar
propagation into concurrent tool worker threads.
Background
----------
Gateway adapters (Slack, Telegram, Discord, ...) set
``tools.approval_context._approval_session_key`` as a ContextVar before calling
``agent.run_conversation`` so that dangerous-command approval prompts route
back to the channel/session that initiated the tool call. When the agent
dispatches multiple tools in parallel, it uses
``concurrent.futures.ThreadPoolExecutor.submit(...)`` — and ``submit`` runs
the callable in a *fresh* context, NOT the caller's context. Without an
explicit ``contextvars.copy_context().run(...)`` wrapper, worker threads
observe the ContextVar's default value, fall through to the
``os.environ`` legacy fallback (which the gateway overwrites at each
agent step), and route the approval card to *whichever session stepped
most recently* — not the one that raised the prompt. Confirmed in the
wild on Slack with two concurrent channels: session A's `rm -rf`
approval card was delivered to session B.
The fix (4 LOC in ``run_agent.py``) snapshots the caller's context with
``copy_context()`` and submits ``ctx.run(_run_tool, …)`` instead of
``_run_tool`` directly. Mirrors ``asyncio.to_thread`` semantics.
This suite follows the ``contextvar-run-in-executor-bridge`` skill's
two-test pattern: one end-to-end test proves the fix works at the
call-site level, one documents the Python contract that makes the fix
necessary. If anyone ever reverts the wrapper, the call-site test
fails while the contract test keeps passing — a clear diagnostic
signal for *why* the call-site regressed.
"""
from __future__ import annotations
import concurrent.futures
import contextvars
import threading
def test_executor_submit_without_copy_context_does_not_propagate():
"""Documents the Python contract the fix relies on.
``concurrent.futures.ThreadPoolExecutor.submit(fn)`` runs ``fn`` in a
worker thread with a fresh, empty context. A ContextVar set by the
caller is invisible inside ``fn``. This is the exact trap that made
approval-session routing race in the gateway before #16660.
If this test ever fails — i.e. submit() starts propagating
ContextVars by default — the copy_context() wrapper in run_agent.py
becomes redundant but not harmful, and the call-site test below
should be updated accordingly.
"""
probe: contextvars.ContextVar[str] = contextvars.ContextVar(
"probe_default_propagation", default="unset"
)
def read_in_worker() -> str:
return probe.get()
probe.set("set-in-main")
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
observed = ex.submit(read_in_worker).result(timeout=5)
assert observed == "unset", (
"Unexpected: executor.submit propagated a ContextVar without "
"copy_context(). If Python's behavior changed, update "
"test_run_tool_worker_sees_parent_context below."
)
def test_run_tool_worker_sees_parent_approval_session_key():
"""End-to-end call-site guard.
Mirrors the exact shape of the fixed call site in
``run_agent.py::_execute_tool_calls_concurrent`` — a
``ThreadPoolExecutor`` with ``executor.submit(ctx.run, fn, *args)``.
Sets the real ``tools.approval_context._approval_session_key`` ContextVar
in the caller and asserts the worker observes it via
``tools.approval.get_current_session_key()``.
If the PR's ``copy_context().run`` wrapper is reverted, this test
fails with ``Expected 'session-A' but worker saw 'default'``.
"""
from tools.approval import get_current_session_key
from tools.approval_context import _approval_session_key
observed: dict = {}
barrier = threading.Event()
def worker_equivalent_to_run_tool() -> None:
# Mirror what real _run_tool does early: read the session key.
observed["session_key"] = get_current_session_key(default="FALLBACK")
barrier.set()
# Set the ContextVar the gateway would set before calling agent.run.
token = _approval_session_key.set("session-A")
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
ctx = contextvars.copy_context()
fut = ex.submit(ctx.run, worker_equivalent_to_run_tool)
fut.result(timeout=5)
assert barrier.is_set(), "worker did not complete"
finally:
_approval_session_key.reset(token)
assert observed.get("session_key") == "session-A", (
f"Worker thread did not inherit _approval_session_key from caller. "
f"Expected 'session-A', got {observed.get('session_key')!r}. "
"This is the bug that PR #16660 fixed — approval prompts route to "
"the wrong session in concurrent gateway traffic. Check whether "
"the copy_context().run wrapper in _execute_tool_calls_concurrent "
"was removed."
)
def test_two_concurrent_tool_batches_keep_session_keys_isolated():
"""End-to-end guard: two callers each set a different session key
and submit workers concurrently. Each worker must see its own
caller's key, not the other's.
Guards against a future "optimization" that reuses a single context
snapshot across callers (which would collapse isolation the same way
the unfixed ``submit`` does).
"""
from tools.approval import get_current_session_key
from tools.approval_context import _approval_session_key
results: dict = {}
def caller(label: str) -> None:
token = _approval_session_key.set(f"session-{label}")
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
ctx = contextvars.copy_context()
fut = ex.submit(
ctx.run,
lambda: get_current_session_key(default="FALLBACK"),
)
results[label] = fut.result(timeout=5)
finally:
_approval_session_key.reset(token)
t_a = threading.Thread(target=caller, args=("A",))
t_b = threading.Thread(target=caller, args=("B",))
t_a.start()
t_b.start()
t_a.join(timeout=10)
t_b.join(timeout=10)
assert results.get("A") == "session-A", (
f"Session A worker saw {results.get('A')!r}, expected 'session-A'"
)
assert results.get("B") == "session-B", (
f"Session B worker saw {results.get('B')!r}, expected 'session-B'"
)