Release batch A: 6 low-risk gate-passed fixes (experimental) (#7089)
* fix(goal): suppress reserved SILENT sentinel at /api/goal ingress A wake relay POSTing the exact [SILENT] suppression sentinel to /api/goal lets it reach _start_chat_stream_for_session, which persists it as pending_user_message. If 8701 restarts while the turn is pending, session recovery materializes it as a visible _recovered user turn — the same phantom-recovered-turn exposure #7018 closed for chat ingress. Apply the shared _is_silent_control_message() guard immediately after /api/goal session-ID validation and before session lookup or goal-state mutation, returning the same 200 no-op. Matching stays exact and case-sensitive; ordinary kickoff text is unaffected. Add tests mirroring test_silent_control_suppression.py for the goal path (args and text fields, before-lookup suppression, exact-match semantics). Closes #7019 * fix(sessions): keep evicted subagent parents in the import window The sidebar nests a subagent row under its parent only when the parent row is present in the same payload. The visible-window limit was applied as a flat per-row recency slice, so a frozen orchestrator (which stops writing while its leaves keep streaming) lost the recency race against its own leaves and fell outside the window -- promoting those leaves to top-level sidebar rows. Re-add subagent parents that the oversampled candidate set already projected, after the slice. No extra queries, no change to CLI_VISIBLE_SESSION_LIMIT. webui ancestors are deliberately not imported because that sidebar bucket already owns them. Supersedes #7031. * fix(sessions): document and pin the parent-recovery bound (greptile review) Greptile flagged (P1) that a selected subagent child whose parent ranks below the limit * 8 oversample is still promoted to a top-level row. That is real and measured (the parent drops out at candidate #25 of 24), but it is the bound of the design, not a regression: the walk reuses rows the projection already fetched and never issues an extra query. Resolving arbitrarily old ancestors needs an unbounded per-row lookup on the hot sidebar path -- the approach rejected in #7031 -- so the bound is documented and pinned instead. - Docstring: state that `limit` bounds the recency slice, not the row count, so callers must iterate rather than assume len(rows) <= limit; state that recovery is bounded by the oversampled candidate set. - Inline comment: mark the bound at the exact lines Greptile flagged and point at `candidate_limit` as the knob if the window proves too tight. - Tests: cover the candidate-window exhaustion Greptile said was untested -- parent inside the oversample is recovered, parent beyond it stays unresolved -- plus the over-limit return contract and a parent-cycle guard. No behaviour change; 7 tests pass. * fix(share): add _hadAppearance guard to prevent fabricated appearance choice Summary: The inline appearance bootstrap in static/share.html writes hermes-theme and hermes-skin to localStorage unconditionally on every page load, even when the browser had no prior appearance state. This fabricates an explicit user choice on first access via a shared link, making the server-side SETTINGS_DEFAULTS unreachable for deployments that customise the default theme or skin. Root Cause: share.html:9 — the boot IIFE resolves a theme+skin and calls localStorage.setItem() without guarding on whether the user had previously chosen an appearance. The same bug was fixed in index.html by PR #6808 (commit tomtong2015) but share.html was left unchanged. Change: 1. Added _hadAppearance guard before the two localStorage.setItem() calls: var _hadAppearance = localStorage.getItem('hermes-theme') !== null || localStorage.getItem('hermes-skin') !== null; if (_hadAppearance) { setItem('hermes-theme', t); setItem('hermes-skin', s); } 2. The first-paint DOM mutations (classList.add('dark'), dataset.skin) remain outside the guard — only persistence is protected. 3. Synced the skin allowlist with index.html: added neon-soft and neon-paint (zeus and verdigris were already present). Verification: - test_6808_appearance_bootstrap_no_fabricated_choice.py: 11/11 passed covering fresh-browser (no writes), pre-paint fallback, explicit state normalisation, and legacy migration survival. Closes #7030 * fix(tests): pin rebuild budget in issue2513 custom-provider catalog test test (3.13, 4) failed on this PR with: assert "@custom:alpha-proxy:alpha/remote" in alpha_ids E AssertionError: assert '@custom:alpha-proxy:alpha/remote' in {'alpha/sticky'} WARNING api.config:config.py:8355 live provider-catalog rebuild exceeded 4.0s budget - serving fallback, refreshing catalog out-of-band Pre-existing wall-clock flake, not a regression from this PR: this branch touches only api/agent_sessions.py and tests/test_subagent_parent_in_import_ window.py, and both api/config.py and this test file are byte-identical to origin/master. The same shard passed on 3.11 and 3.12. The test never pinned _LIVE_REBUILD_BUDGET_SECONDS, so it raced the global 4s budget in get_available_models(). On a starved runner the cold rebuild overruns, the degraded fallback catalog is served, and the monkeypatched- urlopen model alpha/remote is dropped - leaving only the config-declared sticky model, exactly as CI observed. Force the synchronous (unbounded) rebuild path, matching the existing precedent in tests/test_issue2540_models_endpoint_error.py:20-24. Verified: with the budget forced to 0.001s the unpatched test reproduces the CI assertion verbatim; patched it passes at 0.001s, 0, and default. * fix(#7013): preserve media deny coverage across platforms * fix(docker): keep repository agents out of runtime context * fix(docker): gate image runtime proof behind integration job * test(models): stabilize custom provider catalog regression * Release batch A: 6 low-risk gate-passed fixes (experimental) Batched contributor fixes, each individually Codex-gated during the overnight certifier cycles and re-verified clean-to-ship as a combined stage (Codex SAFE TO SHIP on the combined diff; full suite green except 8 pre-existing approval tests that fail identically on clean origin/master — CI green on same commit). - #7019 (@webtecnica) suppress reserved [SILENT] sentinel at /api/goal - #7031 (@carlotestor) keep evicted subagent parents in the sidebar import window - #7030 (@webtecnica) share.html first-visit appearance guard + skin-id sync - #6853 (@rodboev) exclude repo-root AGENTS.md from the Docker runtime image - #7013 (@webtecnica) test-only: platform-neutral test portability (playwright import guard + media tests served from allowed roots) - test-only (@carlotestor) pin custom-provider catalog rebuild budget (#7054) Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: carlotestor <carlotestor@users.noreply.github.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: carlotestor <carlotestor@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: n <a@n> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
12 files changed
+476
-5
No files matched your search
@@ -5,3 +5,4 @@ __pycache__
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
/AGENTS.md
|
||||
@@ -5,6 +5,14 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A restart can no longer turn a suppressed `[SILENT]` control message into a phantom recovered turn on the `/goal` path.** The `/api/goal` endpoint now recognizes the exact reserved `[SILENT]` delivery-suppression sentinel and treats it as a successful no-op before any session lookup or goal-state mutation — matching the guard the chat-start endpoint already applies. Previously a wake relay that accidentally POSTed the sentinel to `/goal` could, after a restart, materialize it as a visible recovered user turn. Matching is exact and case-sensitive, so ordinary goals are unaffected. Thanks @webtecnica. (#7019)
|
||||
|
||||
- **A busy subagent tree no longer promotes an active child to a misleading top-level sidebar row.** The session sidebar builds from a bounded oversampled candidate window; after the recency slice, subagent parents that were present in the candidate set could be dropped, leaving their still-running child leaves rendered as if they were their own top-level conversations. Those parents are now re-added after the slice (bounded, cycle-safe, subagent-scoped so WebUI ancestors stay excluded), so the delegated-session tree nests correctly. Thanks @carlotestor. (#7031)
|
||||
|
||||
- **A shared conversation link can no longer fabricate appearance state that overrides an operator's deployment default.** The public share page (`share.html`) now applies the same first-visit appearance guard as the main app bootstrap (and picks up two skin IDs it was missing), so opening a shared link on a fresh browser no longer writes an explicit theme/skin into local storage that would then be treated as a deliberate user choice. Public-share rendering is unchanged. Thanks @webtecnica. (#7030)
|
||||
|
||||
- **Docker: the repository's own agent-instructions file is no longer baked into the runtime image.** A root-anchored `/AGENTS.md` entry was added to `.dockerignore` so the repo-root `AGENTS.md` (maintainer/build guidance, not user content) doesn't get copied into the Docker runtime and injected into the agent's project context. Nested workspace `AGENTS.md` files remain available exactly as before. Thanks @rodboev. (#6853)
|
||||
|
||||
- **Docker: the bundled SQLite is upgraded to 3.53.0 to fix a WAL-reset corruption bug (and still erases deleted rows).** The `python:3.12-slim` base image ships SQLite 3.46.1, which is vulnerable to the March 2026 WAL-reset corruption bug, and Debian hasn't backported the fix — so the image now compiles SQLite 3.53.0 from the official amalgamation (SHA-256 pinned and verified before extraction, FTS5/FTS4/R-Tree enabled, build tools purged afterward). The source build is also compiled with `SQLITE_SECURE_DELETE` and asserts `PRAGMA secure_delete = 1` at build time, so deleting a conversation still overwrites its content on disk exactly as the base image did (a from-source build without that flag would have left deleted transcript bytes recoverable in `state.db`). Only affects the Docker image. The source-built library is registered in `ld.so.conf.d` so it loads ahead of the base image's copy on every architecture (including arm64, where `/usr/local/lib` isn't on the default loader path). Thanks @qxxaa. (#6900, #7044)
|
||||
|
||||
- **Foregrounding a long or busy conversation no longer risks running the browser out of memory.** Switching back to a tab with a very long active chat could make the browser re-materialize the entire transcript's structured data at once — on the largest sessions this ballooned memory usage into the multi-gigabyte range and could OOM-kill the tab or the whole machine. The catch-up load on tab focus is now coalesced, the render window growth is bounded, and full structured-value allocations for off-screen turns are dropped, so foregrounding a long chat stays lightweight. A session-updated notification that arrives while a refresh is already in flight is now latched and reconciled once the refresh finishes (instead of being dropped), and the render cache signature now distinguishes tool-argument order the way the rendered output does (so differently-ordered arguments can't reuse stale cached markup). Thanks @webtecnica. (#7006, #6999)
|
||||
|
||||
+50
-1
@@ -517,6 +517,19 @@ def read_importable_agent_session_rows(
|
||||
``exclude_sources=None``. ``include_sources`` is an additional narrowing
|
||||
filter; callers that want an include-only query should explicitly pass
|
||||
``exclude_sources=None`` so the default exclusions do not also apply.
|
||||
|
||||
``limit`` bounds the *recency slice*, not the returned row count. Subagent
|
||||
rows only render as children when their parent row is in the same payload,
|
||||
so subagent ancestors of selected rows are re-added afterwards and the
|
||||
result can exceed ``limit`` by the number of such anchors. Callers must
|
||||
therefore iterate the result rather than assume ``len(rows) <= limit``.
|
||||
|
||||
That recovery is deliberately bounded by the oversampled candidate set
|
||||
(``limit * 8`` newest sessions): it re-uses rows the projection already
|
||||
fetched and never issues an extra query, so an ancestor older than the
|
||||
oversample stays unresolved and its children render top-level, exactly as
|
||||
they did before. Widening that window is a ``candidate_limit`` change, not
|
||||
a change to this walk.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
@@ -766,7 +779,43 @@ def read_importable_agent_session_rows(
|
||||
projected = [row for row in projected if is_cli_session_row_visible(row)]
|
||||
if limit is None:
|
||||
return projected
|
||||
return projected[:max(0, int(limit))]
|
||||
selected = projected[:max(0, int(limit))]
|
||||
|
||||
# The recency slice is per-row, but subagent rows are only renderable as
|
||||
# children: the sidebar nests a child under its parent solely when that
|
||||
# parent row is present in the same payload. A frozen orchestrator stops
|
||||
# writing while its leaves keep streaming, so the leaves win the recency
|
||||
# race and the parent falls outside the window — leaving the leaves to be
|
||||
# promoted to top-level sidebar rows. Re-add subagent parents that the
|
||||
# oversampled candidate set already projected (no extra query); webui
|
||||
# ancestors are left out because that sidebar bucket already has them.
|
||||
#
|
||||
# Bounded by construction: ``by_id`` only holds the ``limit * 8``
|
||||
# newest candidates, so an ancestor older than that oversample is not
|
||||
# recovered and its children stay top-level — the pre-existing
|
||||
# behaviour, narrowed rather than fixed. Resolving those would need an
|
||||
# unbounded per-row ancestor query on the hot sidebar path; widen
|
||||
# ``candidate_limit`` instead if the window proves too tight.
|
||||
# NOTE: this can return more than ``limit`` rows (see docstring).
|
||||
have = {row.get('id') for row in selected}
|
||||
by_id = {row.get('id'): row for row in projected if row.get('id')}
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
row = pending.pop()
|
||||
if str(row.get('raw_source') or row.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
parent_id = row.get('parent_session_id')
|
||||
if not parent_id or parent_id in have:
|
||||
continue
|
||||
parent = by_id.get(parent_id)
|
||||
if parent is None:
|
||||
continue
|
||||
if str(parent.get('raw_source') or parent.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
selected.append(parent)
|
||||
have.add(parent_id)
|
||||
pending.append(parent)
|
||||
return selected
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22537,6 +22537,12 @@ def _handle_goal_command(handler, body):
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
if _is_silent_control_message(body.get("args") or body.get("text")):
|
||||
return j(
|
||||
handler,
|
||||
{"status": "suppressed", "reason": "silent_control_message"},
|
||||
status=200,
|
||||
)
|
||||
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
|
||||
return bad(handler, "Subagent sessions are view-only and cannot run /goal from WebUI", 400)
|
||||
try:
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<title>Shared Conversation - Hermes</title>
|
||||
<meta name="robots" content="noindex,nofollow,noarchive">
|
||||
<meta name="referrer" content="same-origin">
|
||||
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
|
||||
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,'neon-soft':1,'neon-paint':1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));var _hadAppearance=localStorage.getItem('hermes-theme')!==null||localStorage.getItem('hermes-skin')!==null;if(_hadAppearance){localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);}if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
html,body{height:auto;min-height:100%}
|
||||
|
||||
@@ -12,7 +12,6 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
@@ -200,7 +199,8 @@ async ({kind, doneSid}) => {
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser():
|
||||
with sync_playwright() as playwright:
|
||||
playwright_api = pytest.importorskip("playwright.sync_api")
|
||||
with playwright_api.sync_playwright() as playwright:
|
||||
instance = playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Reserved ``[SILENT]`` must never become a goal kickoff turn.
|
||||
|
||||
Cron agents use the exact final response ``[SILENT]`` as a delivery-suppression
|
||||
sentinel. ``/api/goal`` accepts free text via ``args``/``text`` and, when it
|
||||
looks like a kickoff, persists it as ``pending_user_message`` through
|
||||
``_start_chat_stream_for_session``. If an external wake relay POSTs the
|
||||
sentinel and 8701 restarts while the turn is pending, session repair
|
||||
materializes it as ``{"role": "user", "_recovered": True}`` — the same
|
||||
phantom-recovered-turn exposure #7018 closed for ``/api/chat/start``.
|
||||
|
||||
The goal ingress therefore treats the exact normalized sentinel as a
|
||||
successful no-op *before* session lookup or goal-state mutation (mirrors
|
||||
``tests/test_silent_control_suppression.py`` for the goal path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
from api import routes
|
||||
|
||||
|
||||
class _JSONHandler:
|
||||
headers = {}
|
||||
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.wfile = io.BytesIO()
|
||||
self.headers_sent = {}
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.headers_sent[key] = value
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _payload(handler):
|
||||
raw = handler.wfile.getvalue().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def test_goal_ingress_suppresses_silent_before_session_lookup(monkeypatch):
|
||||
looked_up = []
|
||||
|
||||
def _unexpected_lookup(*args, **kwargs):
|
||||
looked_up.append((args, kwargs))
|
||||
raise AssertionError("[SILENT] must be suppressed before session lookup")
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", _unexpected_lookup)
|
||||
handler = _JSONHandler()
|
||||
|
||||
routes._handle_goal_command(
|
||||
handler,
|
||||
{"session_id": "does-not-need-to-exist", "args": " [SILENT]\n"},
|
||||
)
|
||||
|
||||
assert handler.status == 200
|
||||
assert _payload(handler) == {
|
||||
"status": "suppressed",
|
||||
"reason": "silent_control_message",
|
||||
}
|
||||
assert looked_up == []
|
||||
|
||||
|
||||
def test_goal_ingress_suppresses_silent_via_text_field(monkeypatch):
|
||||
looked_up = []
|
||||
|
||||
def _unexpected_lookup(*args, **kwargs):
|
||||
looked_up.append((args, kwargs))
|
||||
raise AssertionError("[SILENT] must be suppressed before session lookup")
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", _unexpected_lookup)
|
||||
handler = _JSONHandler()
|
||||
|
||||
routes._handle_goal_command(
|
||||
handler,
|
||||
{"session_id": "does-not-need-to-exist", "text": "\t[SILENT] "},
|
||||
)
|
||||
|
||||
assert handler.status == 200
|
||||
assert _payload(handler) == {
|
||||
"status": "suppressed",
|
||||
"reason": "silent_control_message",
|
||||
}
|
||||
assert looked_up == []
|
||||
|
||||
|
||||
def test_goal_ingress_does_not_suppress_ordinary_kickoff(monkeypatch):
|
||||
looked_up = []
|
||||
|
||||
def _record_lookup(*args, **kwargs):
|
||||
looked_up.append((args, kwargs))
|
||||
raise KeyError("session")
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", _record_lookup)
|
||||
handler = _JSONHandler()
|
||||
|
||||
routes._handle_goal_command(
|
||||
handler,
|
||||
{"session_id": "does-not-need-to-exist", "args": "write a report"},
|
||||
)
|
||||
|
||||
# Ordinary kickoff text must NOT be suppressed: the request proceeds to
|
||||
# session lookup (which raises here) instead of returning the 200 no-op.
|
||||
assert looked_up != []
|
||||
assert handler.status != 200
|
||||
|
||||
|
||||
def test_goal_silent_suppression_is_exact_and_case_sensitive():
|
||||
assert routes._is_silent_control_message("[SILENT]") is True
|
||||
assert routes._is_silent_control_message(" [SILENT]\n") is True
|
||||
assert routes._is_silent_control_message("[silent]") is False
|
||||
assert routes._is_silent_control_message("prefix [SILENT]") is False
|
||||
assert routes._is_silent_control_message("") is False
|
||||
assert routes._is_silent_control_message(None) is False
|
||||
@@ -45,13 +45,22 @@ def test_custom_provider_model_field_does_not_block_remote_catalog(monkeypatch,
|
||||
raise AssertionError(f"unexpected urlopen: {url}")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
# This file pins the custom-provider catalog contract, not the separate
|
||||
# async provider-catalog budget fallback. Force the synchronous rebuild
|
||||
# path so a slow/loaded CI shard cannot blow the global 4s budget and
|
||||
# serve the degraded fallback catalog, which drops the live-fetched
|
||||
# models under test and leaves only the config-declared sticky model.
|
||||
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
|
||||
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
|
||||
monkeypatch.setattr(config, "_get_auth_store_path", lambda: tmp_path / "auth.json")
|
||||
# Keep unrelated provider probes from replacing this complete catalog assertion with the bounded fallback.
|
||||
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0)
|
||||
|
||||
old_cfg = config.cfg
|
||||
old_mtime = config._cfg_mtime
|
||||
old_cache = config._available_models_cache
|
||||
old_cache_ts = config._available_models_cache_ts
|
||||
old_live_rebuild_ts = config._available_models_live_rebuild_ts
|
||||
old_cache_fp = config._available_models_cache_source_fingerprint
|
||||
try:
|
||||
config.cfg = {
|
||||
@@ -76,6 +85,7 @@ def test_custom_provider_model_field_does_not_block_remote_catalog(monkeypatch,
|
||||
config._cfg_mtime = 0.0
|
||||
config._available_models_cache = None
|
||||
config._available_models_cache_ts = 0.0
|
||||
config._available_models_live_rebuild_ts = 0.0
|
||||
config._available_models_cache_source_fingerprint = None
|
||||
|
||||
data = config.get_available_models()
|
||||
@@ -84,6 +94,7 @@ def test_custom_provider_model_field_does_not_block_remote_catalog(monkeypatch,
|
||||
config._cfg_mtime = old_mtime
|
||||
config._available_models_cache = old_cache
|
||||
config._available_models_cache_ts = old_cache_ts
|
||||
config._available_models_live_rebuild_ts = old_live_rebuild_ts
|
||||
config._available_models_cache_source_fingerprint = old_cache_fp
|
||||
|
||||
groups = {group["provider_id"]: group for group in data["groups"]}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Regression coverage for #6853: keep repository instructions out of the image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _dockerignore_rules() -> set[str]:
|
||||
return {
|
||||
line.strip()
|
||||
for line in (REPO / ".dockerignore").read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
}
|
||||
|
||||
|
||||
def _excluded_by_root_rule(path: str, rules: set[str]) -> bool:
|
||||
"""Model the exact root-only rule used for this context boundary."""
|
||||
return "/AGENTS.md" in rules and path == "AGENTS.md"
|
||||
|
||||
|
||||
def test_repository_agents_file_is_excluded_from_docker_context():
|
||||
"""The repository instruction file must not enter the runtime image."""
|
||||
assert _excluded_by_root_rule("AGENTS.md", _dockerignore_rules())
|
||||
|
||||
|
||||
def test_workspace_agents_file_is_not_recursively_excluded():
|
||||
"""A selected workspace may still provide its own Agent instructions."""
|
||||
assert not _excluded_by_root_rule("workspace/AGENTS.md", _dockerignore_rules())
|
||||
|
||||
|
||||
def test_dockerfile_copies_context_to_apptoo_before_runtime_seed():
|
||||
"""The image source and runtime seed source must share the filtered tree."""
|
||||
dockerfile = (REPO / "Dockerfile").read_text(encoding="utf-8")
|
||||
copy_lines = {
|
||||
line.strip()
|
||||
for line in dockerfile.splitlines()
|
||||
if line.lstrip().startswith("COPY ")
|
||||
}
|
||||
|
||||
assert "COPY --chown=root:root . /apptoo" in copy_lines
|
||||
assert "COPY --chown=root:root . /app" not in copy_lines
|
||||
|
||||
|
||||
def test_docker_init_has_both_filtered_context_seed_paths():
|
||||
"""Both startup modes must seed /app from the Dockerfile context copy."""
|
||||
init = (REPO / "docker_init.bash").read_text(encoding="utf-8")
|
||||
|
||||
assert "rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/" in init
|
||||
assert "cp -a /apptoo/. /app/" in init
|
||||
assert "HERMES_WEBUI_DEFAULT_WORKSPACE=\"/workspace\"" in init
|
||||
assert "cd /app" in init
|
||||
|
||||
|
||||
def _docker_runtime_available() -> bool:
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
return False
|
||||
result = subprocess.run(
|
||||
[docker, "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("HERMES_RUN_DOCKER_RUNTIME_TESTS") or not _docker_runtime_available(),
|
||||
reason="Docker runtime proof is opt-in for the CI integration job",
|
||||
)
|
||||
@pytest.mark.timeout(900)
|
||||
def test_docker_runtime_keeps_app_clean_in_both_seed_modes_and_workspace_intact(tmp_path):
|
||||
"""Build the image and exercise the exact normal/rootless seed destinations."""
|
||||
docker = shutil.which("docker")
|
||||
assert docker is not None
|
||||
tag = f"hermes-webui-pr-6853-{os.getpid()}"
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "AGENTS.md").write_text("workspace instructions\n", encoding="utf-8")
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[docker, "build", "--progress=plain", "-t", tag, "."],
|
||||
check=True,
|
||||
timeout=900,
|
||||
)
|
||||
for user in (None, "1000:1000"):
|
||||
command = (
|
||||
"set -eu; rm -rf /app/* /app/.[!.]* /app/..?*; "
|
||||
+ (
|
||||
"rsync -a --chown=hermeswebui:hermeswebui /apptoo/ /app/"
|
||||
if user is None
|
||||
else "cp -a /apptoo/. /app/"
|
||||
)
|
||||
+ "; test -f /app/server.py; test ! -e /app/AGENTS.md; "
|
||||
"test -f /workspace/AGENTS.md"
|
||||
)
|
||||
args = [docker, "run", "--rm", "--mount", f"type=bind,source={workspace},target=/workspace"]
|
||||
if user is not None:
|
||||
args.extend(["--user", user])
|
||||
args.extend(["--entrypoint", "bash", tag, "-c", command])
|
||||
subprocess.run(args, check=True, timeout=60)
|
||||
finally:
|
||||
subprocess.run([docker, "rmi", "-f", tag], check=False, timeout=60)
|
||||
@@ -49,6 +49,14 @@ def routes():
|
||||
return routes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def media_allowed_root(tmp_path, monkeypatch):
|
||||
# tmp_path is under /tmp only on Linux; register it and make Path.home()
|
||||
# resolve to the same fixture root on Windows so deny tests reach #3234.
|
||||
monkeypatch.setenv("MEDIA_ALLOWED_ROOTS", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
|
||||
|
||||
# ── _serve_file_bytes ETag / 304 ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,14 @@ def routes():
|
||||
return routes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def media_allowed_root(tmp_path, monkeypatch):
|
||||
# tmp_path is under /tmp only on Linux; register it and make Path.home()
|
||||
# resolve to the same fixture root on Windows so deny tests reach #3234.
|
||||
monkeypatch.setenv("MEDIA_ALLOWED_ROOTS", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snap_dir(tmp_path, monkeypatch):
|
||||
"""Isolate the snapshot store per test and point it at tmp_path."""
|
||||
@@ -694,4 +702,4 @@ def test_frontend_stamp_source_invariants():
|
||||
# transcript + transparent segments, which are the comparison surface).
|
||||
# Lazy loaders must read the stamped digest.
|
||||
assert "_mediaSnapQuery(el)" in src
|
||||
assert "el.dataset.snap" in src
|
||||
assert "el.dataset.snap" in src
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Regression tests: a recency-sliced subagent leaf must keep its parent row.
|
||||
|
||||
`read_importable_agent_session_rows()` applied the visible-window limit as a
|
||||
flat per-row recency slice. Subagent rows only render as children when their
|
||||
parent row is in the same payload, so a frozen orchestrator (no longer writing)
|
||||
lost the recency race against its own still-streaming leaves and fell out of the
|
||||
window — promoting the leaves to top-level sidebar rows. The projection now
|
||||
re-adds subagent parents that the oversampled candidate set already projected.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
from api.agent_sessions import read_importable_agent_session_rows
|
||||
|
||||
|
||||
def _make_db(path, sessions, messages):
|
||||
"""sessions: (id, title, source, parent_session_id); messages: (session_id, role, ts)."""
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.execute(
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, model TEXT, "
|
||||
"message_count INTEGER, started_at REAL, source TEXT, parent_session_id TEXT)"
|
||||
)
|
||||
conn.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, timestamp REAL)")
|
||||
for sid, title, source, parent in sessions:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, title, model, message_count, started_at, source, parent_session_id) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(sid, title, "gpt", 2, 1000.0, source, parent),
|
||||
)
|
||||
for sid, role, ts in messages:
|
||||
conn.execute("INSERT INTO messages (session_id, role, timestamp) VALUES (?,?,?)", (sid, role, ts))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _lineage_db(path, parent_source="subagent"):
|
||||
"""One quiet orchestrator + 3 hot leaves + an unrelated hot subagent row."""
|
||||
sessions: list[tuple[str, str, str, str | None]] = [
|
||||
("orch", "Orchestrator", parent_source, None),
|
||||
("loner", "Unrelated", "subagent", None),
|
||||
]
|
||||
messages = [("orch", "user", 100.0), ("orch", "assistant", 101.0)] # went quiet early
|
||||
for i in range(3):
|
||||
sessions.append((f"leaf{i}", f"Leaf {i}", "subagent", "orch"))
|
||||
messages += [(f"leaf{i}", "user", 900.0 + i), (f"leaf{i}", "assistant", 901.0 + i)]
|
||||
messages += [("loner", "user", 800.0), ("loner", "assistant", 801.0)]
|
||||
_make_db(path, sessions, messages)
|
||||
|
||||
|
||||
def test_evicted_subagent_parent_is_kept_in_window(tmp_path):
|
||||
db = tmp_path / "state.db"
|
||||
_lineage_db(db)
|
||||
|
||||
# limit=3 is exactly the three hot leaves; the quiet parent is sliced off.
|
||||
rows = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||
by_id = {r["id"]: r for r in rows}
|
||||
|
||||
assert "orch" in by_id, "quiet subagent parent must survive the recency slice"
|
||||
for i in range(3):
|
||||
leaf = by_id[f"leaf{i}"]
|
||||
assert leaf["parent_session_id"] == "orch"
|
||||
assert leaf["relationship_type"] == "child_session"
|
||||
|
||||
|
||||
def test_unrelated_older_subagent_row_stays_excluded(tmp_path):
|
||||
db = tmp_path / "state.db"
|
||||
_lineage_db(db)
|
||||
|
||||
rows = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||
|
||||
# Only ancestors are rescued — an unrelated quieter subagent row stays out.
|
||||
assert "loner" not in {r["id"] for r in rows}
|
||||
|
||||
|
||||
def test_webui_parent_is_not_force_imported(tmp_path):
|
||||
db = tmp_path / "state.db"
|
||||
_lineage_db(db, parent_source="webui")
|
||||
|
||||
rows = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||
|
||||
# The webui sidebar bucket already owns its own rows; don't duplicate them.
|
||||
assert "orch" not in {r["id"] for r in rows}
|
||||
assert {f"leaf{i}" for i in range(3)} <= {r["id"] for r in rows}
|
||||
|
||||
|
||||
def test_recovered_parent_is_returned_beyond_limit(tmp_path):
|
||||
"""`limit` bounds the recency slice, not the row count (documented contract)."""
|
||||
db = tmp_path / "state.db"
|
||||
_lineage_db(db)
|
||||
|
||||
rows = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||
|
||||
# 3 sliced leaves + 1 re-added anchor: callers must iterate, not assume <= limit.
|
||||
assert len(rows) == 4
|
||||
assert {r["id"] for r in rows} == {"leaf0", "leaf1", "leaf2", "orch"}
|
||||
|
||||
|
||||
def _window_db(path, filler):
|
||||
"""Quiet parent, `filler` newer unrelated rows, one hot child of that parent."""
|
||||
sessions: list[tuple[str, str, str, str | None]] = [("orch", "Orchestrator", "subagent", None)]
|
||||
messages = [("orch", "user", 100.0), ("orch", "assistant", 100.5)]
|
||||
for i in range(filler):
|
||||
sessions.append((f"fill{i}", f"Filler {i}", "subagent", None))
|
||||
messages += [(f"fill{i}", "user", 200.0 + i), (f"fill{i}", "assistant", 200.5 + i)]
|
||||
sessions.append(("leaf", "Leaf", "subagent", "orch"))
|
||||
messages += [("leaf", "user", 9000.0), ("leaf", "assistant", 9001.0)]
|
||||
_make_db(path, sessions, messages)
|
||||
|
||||
|
||||
def test_parent_inside_candidate_oversample_is_recovered(tmp_path):
|
||||
"""The oversample is limit * 8, so a parent ranked within it is still restored."""
|
||||
db = tmp_path / "state.db"
|
||||
_window_db(db, filler=22) # orch is candidate #24 of 24
|
||||
|
||||
rows = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||
|
||||
assert "orch" in {r["id"] for r in rows}
|
||||
|
||||
|
||||
def test_parent_beyond_candidate_oversample_stays_unresolved(tmp_path):
|
||||
"""Documented bound: recovery reuses the limit * 8 candidate set, never a new query.
|
||||
|
||||
A parent ranked below that oversample is not fetched, so its child renders
|
||||
top-level — the pre-existing behaviour. This pins the boundary so widening
|
||||
it becomes a deliberate `candidate_limit` change, not an accidental one.
|
||||
"""
|
||||
db = tmp_path / "state.db"
|
||||
_window_db(db, filler=23) # orch is pushed to candidate #25 of 24
|
||||
|
||||
rows = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||
ids = {r["id"] for r in rows}
|
||||
|
||||
assert "leaf" in ids
|
||||
assert "orch" not in ids
|
||||
|
||||
|
||||
def test_parent_cycle_does_not_hang(tmp_path):
|
||||
"""A corrupt self/mutual parent link must not spin the ancestor walk."""
|
||||
db = tmp_path / "state.db"
|
||||
_make_db(
|
||||
db,
|
||||
[("a", "A", "subagent", "b"), ("b", "B", "subagent", "a")],
|
||||
[("a", "user", 900.0), ("a", "assistant", 901.0), ("b", "user", 100.0), ("b", "assistant", 101.0)],
|
||||
)
|
||||
|
||||
rows = read_importable_agent_session_rows(db, limit=1, exclude_sources=None)
|
||||
|
||||
assert {r["id"] for r in rows} == {"a", "b"}
|
||||
Reference in new issue
Block a user