* 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>
148 lines
6.1 KiB
Python
148 lines
6.1 KiB
Python
"""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"}
|