Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
57cd922
fix(tests): preserve config module identity in backup tests
frizikk Jul 28, 2026
f308219
fix(update): test runs never mutate the live checkout — pytest-guard …
fcavalcantirj Jul 30, 2026
4a56ac0
test: prove concurrency with barriers/witnesses instead of wall-clock…
Kyzcreig Jul 30, 2026
c4adef7
test(tui_gateway): make the tui gateway suite order-independent
lEWFkRAD Jul 30, 2026
bf1467a
fix(tests): register no_isolate and ssh pytest markers
blut-agent Jul 30, 2026
3eb0949
test(fal): pin fal_common behavioral contracts
Christopher-Schulze Jul 30, 2026
1c30373
fix(tests): live-system guard treats only argv[0] as the executable
CaptureClient Jul 30, 2026
198dc49
test(tui_gateway): pin goal-command config home against collection-ti…
lEWFkRAD Jul 30, 2026
f19f034
fix(test-runner): native Windows venv probe + glyph-safe stdio
TheSmokeDev Jul 30, 2026
19cc560
test: harden yolo and kanban signal tests on macOS
sunwz1115 Jul 30, 2026
c9daf38
chore(tests): drop accidental temp guard probe file
CaptureClient Jul 30, 2026
5c2ac89
test(homeassistant): prevent unit tests from calling live instances
jeeves-assistant Jul 30, 2026
4673d27
test(gateway): fix order-dependent telegram-mock flake cluster
mehmetkr-31 Jul 30, 2026
d8c621d
chore: gitignore the .lazy-refresh-incomplete runtime marker
teknium1 Jul 30, 2026
591ec76
chore: keep LEGACY_AUTHOR_MAP frozen — mehmetkr-31 mapping lives in c…
teknium1 Jul 30, 2026
422008e
fix(tests): stub _ensure_vercel_sdk in vercel sandbox tests — CI has …
teknium1 Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,7 @@ infographics/
infograficos/
infografico/
native/fts5_cjk/*.so
# Runtime marker written by hermes update when a lazy dependency refresh is
# interrupted; consumed by launch-time recovery. Never commit it (was tracked
# by accident via 3a69e34702, removed in the #72002 salvage).
.lazy-refresh-incomplete
2 changes: 0 additions & 2 deletions .lazy-refresh-incomplete

This file was deleted.

2 changes: 2 additions & 0 deletions contributors/emails/degensmoke@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TheSmokeDev
# PR #66496 salvage
2 changes: 2 additions & 0 deletions contributors/emails/felipe.cavalcanti.rj@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fcavalcantirj
# PR #72002 salvage
2 changes: 2 additions & 0 deletions contributors/emails/support@captureclient.net
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
eazye19
# PR #43299 salvage
18 changes: 18 additions & 0 deletions hermes_cli/_early_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ def _run_repair_install(specs: list[str], project_root: Path) -> bool:
return True


def _pytest_owns_live_checkout(root: Path) -> bool:
"""True when running under pytest AND ``root`` is this module's own
checkout — the one whose venv is executing the suite right now.

Lifecycle tests spawn real subprocesses that import ``hermes_cli.main``
with recovery armed; ``PYTEST_CURRENT_TEST`` rides the inherited env into
those children. Without this guard, a genuinely-broken dev venv gets a
REAL ``ensurepip`` + ``pip install --force-reinstall`` from inside a
running test suite. Tests that sandbox ``project_root`` to a tmp_path are
unaffected (same posture as ``managed_scope._under_pytest``)."""
return (
"PYTEST_CURRENT_TEST" in os.environ
and root == Path(__file__).resolve().parent.parent
)


def recover_if_needed(
project_root: Path | None = None,
argv: list[str] | None = None,
Expand All @@ -193,6 +209,8 @@ def recover_if_needed(
if "update" in args:
return
root = _project_root() if project_root is None else project_root
if _pytest_owns_live_checkout(root):
return
core_marker = root / ".update-incomplete"
lazy_marker = root / ".lazy-refresh-incomplete"
if not core_marker.exists() and not lazy_marker.exists():
Expand Down
18 changes: 18 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7540,6 +7540,22 @@ def _lazy_refresh_marker_path() -> Path:
return PROJECT_ROOT / ".lazy-refresh-incomplete"


def _pytest_owns_live_checkout(root: Path) -> bool:
"""True when running under pytest AND ``root`` is this checkout itself.

Tests that drive update/recovery without sandboxing ``PROJECT_ROOT``
must neither litter the live repo root with recovery breadcrumbs
(a leftover ``.lazy-refresh-incomplete`` / ``.update-incomplete``
false-arms recovery on the developer's next real launch) nor run a real
reinstall against the executing venv. Sandboxed tests point at a
tmp_path and are unaffected (same posture as
``managed_scope._under_pytest``)."""
return (
"PYTEST_CURRENT_TEST" in os.environ
and root == Path(__file__).resolve().parent.parent
)


def _clear_marker_file(path: Path, *, label: str) -> None:
"""Remove an update-recovery breadcrumb. Never raises."""
try:
Expand Down Expand Up @@ -7586,6 +7602,8 @@ def _recover_from_interrupted_install() -> None:
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
if _pytest_owns_live_checkout(PROJECT_ROOT):
return
core_marker = _update_marker_path().exists()
lazy_marker = _lazy_refresh_marker_path().exists()
if not core_marker and not lazy_marker:
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,9 @@ def _invalidate_update_cache():

def _write_marker_file(path: Path, *, label: str) -> None:
"""Drop an update-recovery breadcrumb. Never raises."""
if _m()._pytest_owns_live_checkout(path.parent):
logger.debug("Skipping %s marker under pytest (live checkout)", label)
return
try:
path.write_text(
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ markers = [
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
"real_agent_prewarm: opt out of the autouse stub that disables the tui_gateway deferred agent pre-warm timer",
"requires_wal: needs the runtime to actually enable SQLite WAL mode (skipped where Hermes falls back to journal_mode=DELETE)",
"no_isolate: opt out of per-file subprocess isolation (tests share mutable module-level state)",
"ssh: marks tests requiring a reachable SSH server (skipped in normal CI)",
]
# integration tests take way too long to run in the normal CI environments
addopts = "-m 'not integration'"
Expand Down
16 changes: 15 additions & 1 deletion scripts/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,25 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# reported "0 tests passed" (which reads green at a glance even though the
# exit code is 1). Skip such a venv and keep probing instead.
VENV=""
VENV_PYTHON=""
SKIPPED_VENVS=""
for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agent/venv"; do
if [ -f "$candidate/bin/activate" ]; then
if "$candidate/bin/python" -c 'import pytest' 2>/dev/null; then
VENV="$candidate"
VENV_PYTHON="$candidate/bin/python"
break
fi
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
fi
# Native Windows venv layout: python.exe and activate live under
# Scripts/, and there is no bin/. Anyone running this script from
# Git Bash / MSYS with a `python -m venv`- or uv-created venv hits
# this branch — without it the canonical runner refuses to start.
if [ -f "$candidate/Scripts/activate" ]; then
if "$candidate/Scripts/python.exe" -c 'import pytest' 2>/dev/null; then
VENV="$candidate"
VENV_PYTHON="$candidate/Scripts/python.exe"
break
fi
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
Expand All @@ -67,7 +81,7 @@ if [ -n "$SKIPPED_VENVS" ]; then
fi

if [ -n "$VENV" ]; then
PYTHON="$VENV/bin/python"
PYTHON="$VENV_PYTHON"
elif [ -n "${HERMES_PYTHON:-}" ] && [ -x "$HERMES_PYTHON" ] \
&& "$HERMES_PYTHON" -c 'import pytest' 2>/dev/null; then
# Guard with an import check: HERMES_PYTHON may point at the RELEASE
Expand Down
26 changes: 26 additions & 0 deletions scripts/run_tests_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,33 @@ def _slice_files(
return target


def _make_stdio_glyph_safe() -> None:
"""Keep status glyphs from killing the runner on narrow console encodings.

On native Windows, piped or legacy-console stdio defaults to a locale
codec (usually cp1252) that cannot encode the ✓/✗ progress glyphs — the
first per-file status line then dies with UnicodeEncodeError before a
single test result is reported. Declare the runner's own output UTF-8
(what CI and every modern terminal already are), with errors="replace"
as the can't-crash backstop; where the encoding can't be changed, fall
back to errors="replace" alone so glyphs degrade to "?" instead of
killing the run. On already-UTF-8 stdio this is a no-op.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
try:
reconfigure(errors="replace")
except Exception:
pass


def main() -> int:
_make_stdio_glyph_safe()
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
Expand Down
63 changes: 50 additions & 13 deletions tests/agent/test_context_refs_concurrent.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""Tests for concurrent @-reference expansion in context_references.

RED before the refactor: test_refs_expand_concurrently asserts that N URL refs
(each a ~0.2s fetch) complete in roughly one fetch-time, not N×. On the serial
`for ref in refs: await` loop this FAILS (takes ~N×0.2s); after switching to
asyncio.gather it passes. The output-contract test guards that concurrency does
NOT change ordering, warnings, blocks, or token accounting.
test_refs_expand_concurrently asserts that N URL refs are fetched CONCURRENTLY.
It proves this with an asyncio.Barrier rendezvous rather than a stopwatch: all
N fetches must be in flight at the same instant before any is allowed to
return. On the serial `for ref in refs: await` loop the first fetch waits for
partners that never arrive and the test fails; with asyncio.gather it passes.
The output-contract test guards that concurrency does NOT change ordering,
warnings, blocks, or token accounting.
"""
from __future__ import annotations

import asyncio
import time

import pytest

Expand All @@ -26,13 +27,49 @@ async def _slow_fetcher(url: str) -> str:
async def test_refs_expand_concurrently(tmp_path):
# Three independent URL refs in one message.
msg = "see @url:https://a.example/x @url:https://b.example/y @url:https://c.example/z please"
t0 = time.perf_counter()
res = await preprocess_context_references_async(
msg, cwd=tmp_path, context_length=100_000, url_fetcher=_slow_fetcher,
)
elapsed = time.perf_counter() - t0
# Serial would be ~0.6s (3×0.2). Concurrent ~0.2s. Assert well under 2× one fetch.
assert elapsed < 0.4, f"expected concurrent (~0.2s), got {elapsed:.2f}s (serial?)"

# Concurrency is proven by construction, not by measuring elapsed time.
#
# The old form asserted `elapsed < 0.4` ("well under 2x one 0.2s fetch").
# That makes the event-loop scheduler and any fixed setup part of the
# assertion: under a loaded CI box the inequality can flip with nothing
# wrong in the code under test, and the margin shrinks silently if setup
# cost is ever added ahead of dispatch.
#
# A barrier asserts the invariant directly: all THREE fetches must be
# inside the fetcher AT THE SAME TIME before any is allowed to return. If
# expansion ever goes serial the first fetch blocks waiting for partners
# that will not arrive, the barrier times out, and the test fails with an
# explicit message. No wall-clock constant, no load sensitivity.
N_REFS = 3
rendezvous = asyncio.Barrier(N_REFS)
entered: list[str] = []
overlapped = asyncio.Event()

async def barrier_fetcher(url: str) -> str:
entered.append(url)
# Generous relative to real scheduling latency (a rendezvous between
# already-dispatched coroutines needs milliseconds), but finite so a
# serial regression fails fast instead of hanging the suite.
async with asyncio.timeout(10):
await rendezvous.wait()
overlapped.set()
return f"CONTENT[{url}]"

try:
res = await preprocess_context_references_async(
msg, cwd=tmp_path, context_length=100_000, url_fetcher=barrier_fetcher,
)
except (asyncio.BrokenBarrierError, TimeoutError): # pragma: no cover - serial regression
pytest.fail(
"references did not expand concurrently: a fetch reached the "
f"rendezvous alone, so expansion never had {N_REFS} fetches in "
f"flight at once (entered: {entered})"
)

# The barrier only clears when all three fetches are in flight together.
assert overlapped.is_set(), f"references never overlapped (entered: {entered})"
assert len(entered) == N_REFS, f"expected {N_REFS} fetches, got {entered}"
# All three blocks present, in order.
assert res.expanded
body = res.message
Expand Down
21 changes: 17 additions & 4 deletions tests/agent/test_memory_boundary_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,27 @@ def test_boundary_commit_delivers_end_strictly_before_switch():
mm = _make_manager(provider)

msgs = [{"role": "user", "content": "old turn"}]
t0 = time.monotonic()
mm.commit_session_boundary_async(
msgs, new_session_id="new-sid", parent_session_id="old-sid"
)
# Caller returns immediately — the slow extraction must not block /new.
assert time.monotonic() - t0 < 0.1
# DETERMINISTIC non-blocking witness — replaces `assert elapsed < 0.1`.
#
# The old form timed `commit_session_boundary_async` and required it under
# 100ms, which makes the scheduler part of the assertion: thread startup
# alone can exceed that on a loaded box, flipping the inequality with
# nothing wrong in the code under test.
#
# The real contract is that the caller returns WITHOUT waiting for the slow
# extraction. Assert it directly: the background `on_session_end` sleeps
# 0.15s before recording anything, so if the caller had blocked on it, the
# provider would already have recorded the "end" call by the time we get
# here. An empty call list is a positive witness that /new was not gated.
assert provider.calls == [], (
"commit_session_boundary_async blocked on the slow extraction: "
f"provider already recorded {provider.calls} before the caller returned"
)

assert mm.flush_pending(timeout=5)
assert mm.flush_pending(timeout=30)

kinds = [c[0] for c in provider.calls]
assert kinds == ["end", "switch"], f"ordering violated: {provider.calls}"
Expand Down
5 changes: 5 additions & 0 deletions tests/cli/test_cli_yolo_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
def _clear_approval_state(monkeypatch):
"""Clear the YOLO bypass + env var around every test so cases are independent."""
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
# The value is intentionally frozen at tools.approval import time. Local
# Hermes-driven test runs may inherit HERMES_YOLO_MODE=1 from the parent
# agent process, so make the default test state hermetic; the one test that
# covers startup-frozen YOLO explicitly patches it back to True.
monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False)
approval_module.clear_session(SESSION_KEY)
approval_module.clear_session("default")
yield
Expand Down
Loading
Loading