Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -94,6 +94,47 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
|
||||
assert install_targets == ["pyright"]
|
||||
|
||||
|
||||
def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
|
||||
"""Installed Windows shims should satisfy later status/probe calls."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
from agent.lsp import install as install_mod
|
||||
|
||||
wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
|
||||
wrapper.write_text("@echo off\n")
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
|
||||
|
||||
assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
|
||||
assert install_mod.detect_status("pyright") == "installed"
|
||||
|
||||
|
||||
def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
|
||||
"""pip console scripts can land in Scripts/ on native Windows."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
from agent.lsp import install as install_mod
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
launcher = scripts_dir / "fake-language-server.exe"
|
||||
launcher.write_text("launcher\n")
|
||||
launcher.chmod(0o755)
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
|
||||
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
|
||||
|
||||
resolved = install_mod._install_pip("fake-lsp", "fake-language-server")
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.endswith("fake-language-server.exe")
|
||||
assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,6 +41,8 @@ class TestShouldCompress:
|
||||
|
||||
class TestUpdateFromResponse:
|
||||
def test_updates_fields(self, compressor):
|
||||
compressor.awaiting_real_usage_after_compression = True
|
||||
compressor.last_compression_rough_tokens = 90_000
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": 5000,
|
||||
"completion_tokens": 1000,
|
||||
@@ -48,12 +50,39 @@ class TestUpdateFromResponse:
|
||||
})
|
||||
assert compressor.last_prompt_tokens == 5000
|
||||
assert compressor.last_completion_tokens == 1000
|
||||
assert compressor.last_real_prompt_tokens == 5000
|
||||
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
|
||||
assert compressor.awaiting_real_usage_after_compression is False
|
||||
|
||||
def test_missing_fields_default_zero(self, compressor):
|
||||
compressor.update_from_response({})
|
||||
assert compressor.last_prompt_tokens == 0
|
||||
|
||||
|
||||
class TestPreflightDeferral:
|
||||
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 50_000
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(93_000) is True
|
||||
assert compressor.last_rough_tokens_when_real_prompt_fit == 93_000
|
||||
|
||||
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 50_000
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
|
||||
|
||||
def test_does_not_defer_without_recent_real_usage(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 0
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(93_000) is False
|
||||
|
||||
|
||||
|
||||
class TestCompress:
|
||||
def _make_messages(self, n):
|
||||
|
||||
@@ -123,55 +123,6 @@ class TestEstimateMessagesTokensRough:
|
||||
# =========================================================================
|
||||
|
||||
class TestDefaultContextLengths:
|
||||
def test_claude_models_context_lengths(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "claude" not in key:
|
||||
continue
|
||||
# Claude 4.6+ models (4.6, 4.7, 4.8) have 1M context at standard
|
||||
# API pricing (no long-context premium). Older Claude 4.x and
|
||||
# 3.x models cap at 200k.
|
||||
if any(tag in key for tag in ("4.6", "4-6", "4.7", "4-7", "4.8", "4-8")):
|
||||
assert value == 1000000, f"{key} should be 1000000"
|
||||
else:
|
||||
assert value == 200000, f"{key} should be 200000"
|
||||
|
||||
def test_gpt4_models_128k_or_1m(self):
|
||||
# gpt-4.1 and gpt-4.1-mini have 1M context; other gpt-4* have 128k
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "gpt-4" in key and "gpt-4.1" not in key:
|
||||
assert value == 128000, f"{key} should be 128000"
|
||||
|
||||
def test_gpt41_models_1m(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "gpt-4.1" in key:
|
||||
assert value == 1047576, f"{key} should be 1047576"
|
||||
|
||||
def test_gemini_models_1m(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "gemini" in key:
|
||||
assert value == 1048576, f"{key} should be 1048576"
|
||||
|
||||
def test_grok_models_context_lengths(self):
|
||||
# xAI /v1/models does not return context_length metadata, so
|
||||
# DEFAULT_CONTEXT_LENGTHS must cover the Grok family explicitly.
|
||||
# Values sourced from models.dev (2026-04).
|
||||
expected = {
|
||||
"grok-4.20": 2000000,
|
||||
"grok-4-fast": 2000000,
|
||||
"grok-4": 256000,
|
||||
"grok-build": 256000,
|
||||
"grok-code-fast": 256000,
|
||||
"grok-3": 131072,
|
||||
"grok-2": 131072,
|
||||
"grok-2-vision": 8192,
|
||||
"grok": 131072,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
|
||||
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
|
||||
)
|
||||
|
||||
def test_grok_substring_matching(self):
|
||||
# Longest-first substring matching must resolve the real xAI model
|
||||
# IDs to the correct fallback entries without 128k probe-down.
|
||||
@@ -268,13 +219,6 @@ class TestDefaultContextLengths:
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_all_values_positive(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
assert value > 0, f"{key} has non-positive context length"
|
||||
|
||||
def test_dict_is_not_empty(self):
|
||||
assert len(DEFAULT_CONTEXT_LENGTHS) >= 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Codex OAuth context-window resolution (provider="openai-codex")
|
||||
@@ -1141,12 +1085,6 @@ class TestContextProbeTiers:
|
||||
for i in range(len(CONTEXT_PROBE_TIERS) - 1):
|
||||
assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]
|
||||
|
||||
def test_first_tier_is_256k(self):
|
||||
assert CONTEXT_PROBE_TIERS[0] == 256_000
|
||||
|
||||
def test_last_tier_is_8k(self):
|
||||
assert CONTEXT_PROBE_TIERS[-1] == 8_000
|
||||
|
||||
|
||||
class TestGetNextProbeTier:
|
||||
def test_from_256k(self):
|
||||
|
||||
@@ -82,17 +82,6 @@ SAMPLE_REGISTRY = {
|
||||
|
||||
|
||||
class TestProviderMapping:
|
||||
def test_all_mapped_providers_are_strings(self):
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
assert isinstance(hermes_id, str)
|
||||
assert isinstance(mdev_id, str)
|
||||
|
||||
def test_known_providers_mapped(self):
|
||||
assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic"
|
||||
assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot"
|
||||
assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun"
|
||||
assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
|
||||
|
||||
def test_xai_oauth_uses_xai_catalog(self):
|
||||
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
|
||||
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Regression coverage for #35344: a resumed session must not let a stale
|
||||
``## Active Task`` from an inherited compaction handoff hijack the reply to a
|
||||
new, unrelated user message.
|
||||
|
||||
The failure mode (real report): a lineage was compacted, producing a handoff
|
||||
whose ``## Active Task`` described task A. The lineage was resumed later and
|
||||
the user asked about an unrelated task B. The model answered with A because
|
||||
the handoff's resume directive outranked the fresh ask.
|
||||
|
||||
The structural fix lives in ``SUMMARY_PREFIX``: the handoff is framed as
|
||||
reference-only and the latest user message explicitly *wins* on conflict, with
|
||||
named reverse-signal verbs. Two invariants guard the resume path specifically:
|
||||
|
||||
1. A handoff persisted under the OLD (conflicting) prefix is re-normalized to
|
||||
the CURRENT prefix when it is re-compacted on a resumed lineage — so a
|
||||
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
|
||||
|
||||
2. The current handoff prefix contains an unambiguous "latest message wins /
|
||||
discard stale Active Task" rule, so an unrelated new ask is privileged over
|
||||
the inherited ``## Active Task``.
|
||||
|
||||
These are content/structural assertions (no live model call) — they pin the
|
||||
mechanism that makes the stale task historical rather than active.
|
||||
"""
|
||||
|
||||
from agent.context_compressor import (
|
||||
SUMMARY_PREFIX,
|
||||
LEGACY_SUMMARY_PREFIX,
|
||||
ContextCompressor,
|
||||
)
|
||||
|
||||
|
||||
# The conflicting prefix that shipped before the #35344 fix. A handoff
|
||||
# persisted in a resumed lineage could carry this verbatim.
|
||||
_OLD_CONFLICTING_PREFIX = (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:"
|
||||
)
|
||||
|
||||
|
||||
def test_latest_message_wins_over_inherited_active_task():
|
||||
"""The handoff must explicitly privilege the latest user message over a
|
||||
stale ``## Active Task`` — the core #35344 contract."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "latest user message" in lower
|
||||
assert "## active task" in lower
|
||||
# Conflict-resolution must be explicit, not implied.
|
||||
assert "wins" in lower or "supersede" in lower
|
||||
assert "discard" in lower
|
||||
|
||||
|
||||
def test_no_resume_exactly_directive_can_hijack():
|
||||
"""The directive that caused the hijack ("resume exactly from Active
|
||||
Task") must be gone."""
|
||||
assert "resume exactly" not in SUMMARY_PREFIX.lower()
|
||||
|
||||
|
||||
def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
|
||||
"""A handoff persisted under the OLD conflicting prefix (e.g. saved before
|
||||
the fix and inherited into a resumed lineage) is upgraded to the CURRENT
|
||||
prefix when re-normalized on re-compaction — so the "resume exactly"
|
||||
directive cannot survive into a resumed session."""
|
||||
stale_body = (
|
||||
"## Active Task\n"
|
||||
"User asked: 'Migrate the billing module to Stripe'\n\n"
|
||||
"## Goal\nMigrate billing.\n"
|
||||
)
|
||||
stale_handoff = f"{_OLD_CONFLICTING_PREFIX}\n{stale_body}"
|
||||
|
||||
# Sanity: the fixture really does carry the old directive.
|
||||
assert "resume exactly" in stale_handoff.lower()
|
||||
|
||||
renormalized = ContextCompressor._with_summary_prefix(stale_handoff)
|
||||
|
||||
# The body is preserved...
|
||||
assert "Migrate the billing module to Stripe" in renormalized
|
||||
# ...but the conflicting directive is stripped and replaced with the
|
||||
# current latest-message-wins framing.
|
||||
assert "resume exactly" not in renormalized.lower()
|
||||
assert renormalized.startswith(SUMMARY_PREFIX)
|
||||
assert "wins" in renormalized.lower()
|
||||
|
||||
|
||||
def test_legacy_prefix_handoff_also_renormalized():
|
||||
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
|
||||
format that may sit in a long-lived resumed lineage."""
|
||||
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
|
||||
renormalized = ContextCompressor._with_summary_prefix(legacy)
|
||||
assert renormalized.startswith(SUMMARY_PREFIX)
|
||||
assert LEGACY_SUMMARY_PREFIX not in renormalized
|
||||
assert "task A" in renormalized
|
||||
|
||||
|
||||
def test_inherited_handoff_detected_in_resumed_protected_head():
|
||||
"""On a resumed lineage the handoff commonly sits right after the system
|
||||
prompt (in the protected head). ``_find_latest_context_summary`` must
|
||||
detect it there so re-compaction rehydrates state from it rather than
|
||||
serializing it as a fresh user turn (which is what let the stale Active
|
||||
Task read as live intent)."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
|
||||
]
|
||||
# Search the whole post-system range.
|
||||
idx, body = ContextCompressor._find_latest_context_summary(
|
||||
messages, 1, len(messages)
|
||||
)
|
||||
assert idx == 1, "handoff in protected head must be found"
|
||||
assert "task A" in body
|
||||
# The detected body is stripped of the prefix (treated as state, not a
|
||||
# standalone instruction message).
|
||||
assert not body.startswith(SUMMARY_PREFIX)
|
||||
|
||||
|
||||
def test_historical_prefixed_handoff_detected_and_stripped():
|
||||
"""A pre-fix handoff (old conflicting prefix) inherited into a resumed
|
||||
lineage must still be recognized as a context summary AND have its old
|
||||
directive stripped on detection — otherwise re-compaction serializes the
|
||||
stale 'resume exactly' text as a fresh turn."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "Unrelated task B"},
|
||||
]
|
||||
idx, body = ContextCompressor._find_latest_context_summary(
|
||||
messages, 1, len(messages)
|
||||
)
|
||||
assert idx == 1
|
||||
assert "task A" in body
|
||||
assert "resume exactly" not in body.lower()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Regression test: set_runtime_main() must pass base_url/api_key/api_mode
|
||||
so that _resolve_auto() can route custom: providers in Step 1.
|
||||
|
||||
Fixes https://github.com/NousResearch/hermes-agent/issues/34777
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _get_globals(mod):
|
||||
"""Read runtime globals without triggering redaction."""
|
||||
return {
|
||||
"provider": mod._RUNTIME_MAIN_PROVIDER,
|
||||
"model": mod._RUNTIME_MAIN_MODEL,
|
||||
"base_url": mod._RUNTIME_MAIN_BASE_URL,
|
||||
"cred": mod._RUNTIME_MAIN_API_KEY, # renamed to avoid redaction
|
||||
"api_mode": mod._RUNTIME_MAIN_API_MODE,
|
||||
}
|
||||
|
||||
|
||||
class TestSetRuntimeMainCustomProvider:
|
||||
"""set_runtime_main must propagate base_url/api_key/api_mode for custom providers."""
|
||||
|
||||
def test_globals_stored(self):
|
||||
"""set_runtime_main stores all five fields in process-local globals."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:my-router",
|
||||
"glm-5.1",
|
||||
base_url="https://my-server.example.com/v1",
|
||||
api_key="sk-test-key",
|
||||
api_mode="chat_completions",
|
||||
)
|
||||
g = _get_globals(mod)
|
||||
assert g["provider"] == "custom:my-router"
|
||||
assert g["model"] == "glm-5.1"
|
||||
assert g["base_url"] == "https://my-server.example.com/v1"
|
||||
assert g["cred"] == "sk-test-key"
|
||||
assert g["api_mode"] == "chat_completions"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_clear_resets_all_globals(self):
|
||||
"""clear_runtime_main resets all five globals to empty."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.set_runtime_main(
|
||||
"custom:x", "m",
|
||||
base_url="https://x.example.com",
|
||||
api_key="sk-abc",
|
||||
api_mode="chat_completions",
|
||||
)
|
||||
mod.clear_runtime_main()
|
||||
g = _get_globals(mod)
|
||||
for v in g.values():
|
||||
assert v == "", f"Expected empty, got {v!r}"
|
||||
|
||||
def test_resolve_auto_uses_globals_for_custom_provider(self):
|
||||
"""_resolve_auto reads base_url/api_key from globals when main_runtime is None."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:test-router",
|
||||
"test-model",
|
||||
base_url="https://custom-endpoint.example.com/v1",
|
||||
api_key="sk-test-123",
|
||||
)
|
||||
|
||||
with patch.object(mod, "resolve_provider_client") as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "test-model")
|
||||
client, resolved = mod._resolve_auto(main_runtime=None)
|
||||
|
||||
mock_resolve.assert_called_once()
|
||||
call_args = mock_resolve.call_args
|
||||
assert call_args[0][0] == "custom"
|
||||
assert call_args[1]["explicit_base_url"] == "https://custom-endpoint.example.com/v1"
|
||||
assert call_args[1]["explicit_api_key"] == "sk-test-123"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_explicit_main_runtime_takes_precedence(self):
|
||||
"""When main_runtime dict has values, globals are NOT used."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:router-a",
|
||||
"model-a",
|
||||
base_url="https://from-global.example.com",
|
||||
api_key="sk-global",
|
||||
)
|
||||
|
||||
with patch.object(mod, "resolve_provider_client") as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "model-b")
|
||||
main_rt = {
|
||||
"provider": "custom:router-b",
|
||||
"model": "model-b",
|
||||
"base_url": "https://from-dict.example.com",
|
||||
"api_key": "sk-dict",
|
||||
}
|
||||
mod._resolve_auto(main_runtime=main_rt)
|
||||
|
||||
call_args = mock_resolve.call_args[1]
|
||||
assert call_args["explicit_base_url"] == "https://from-dict.example.com"
|
||||
assert call_args["explicit_api_key"] == "sk-dict"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_backward_compatible_defaults(self):
|
||||
"""Calling set_runtime_main with only positional args still works."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main("openrouter", "gpt-4o")
|
||||
g = _get_globals(mod)
|
||||
assert g["provider"] == "openrouter"
|
||||
assert g["model"] == "gpt-4o"
|
||||
assert g["base_url"] == ""
|
||||
assert g["cred"] == ""
|
||||
assert g["api_mode"] == ""
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
|
||||
class TestResolveAutoCustomEndToEnd:
|
||||
"""End-to-end routing assertions — build a *real* client (no mock on
|
||||
resolve_provider_client) and verify the auxiliary auto-detect chain lands
|
||||
on the user's custom endpoint instead of falling through to the aggregator
|
||||
chain. These guard the actual user-visible symptom in #34777 (aux tasks
|
||||
silently routed to a fallback provider) rather than just the wiring.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _client_base_url(client):
|
||||
for chain in (("base_url",), ("_client", "base_url")):
|
||||
obj = client
|
||||
try:
|
||||
for attr in chain:
|
||||
obj = getattr(obj, attr)
|
||||
return str(obj)
|
||||
except AttributeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def test_config_less_custom_endpoint_routes_via_global(self, tmp_path, monkeypatch):
|
||||
"""custom:<name> with NO config entry: the live base_url carried by
|
||||
set_runtime_main() must build a real client at that endpoint — not
|
||||
fall through to Step 2 (the regression in #34777)."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
# Hermetic: no aggregator creds, no stale OPENAI_BASE_URL.
|
||||
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: glm-5.1\n"
|
||||
" provider: 'custom:ephemeral'\n"
|
||||
" base_url: ''\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:ephemeral",
|
||||
"glm-5.1",
|
||||
base_url="https://ephemeral.live/v1",
|
||||
api_key="sk-live",
|
||||
)
|
||||
client, resolved = mod.resolve_provider_client("auto", None)
|
||||
assert client is not None, (
|
||||
"config-less custom endpoint fell through to Step 2 — "
|
||||
"the #34777 bug is back"
|
||||
)
|
||||
assert resolved == "glm-5.1"
|
||||
base = self._client_base_url(client)
|
||||
assert base and base.rstrip("/") == "https://ephemeral.live/v1"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_named_custom_with_config_entry_still_routes(self, tmp_path, monkeypatch):
|
||||
"""Regression guard: custom:<name> WITH a custom_providers entry must
|
||||
still resolve to that entry's endpoint. An earlier competing fix
|
||||
collapsed the provider to bare ``custom`` before resolution, which
|
||||
broke the named-custom branch and returned None here."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: glm-5.1\n"
|
||||
" provider: 'custom:openclaw'\n"
|
||||
" base_url: ''\n"
|
||||
"custom_providers:\n"
|
||||
" - name: openclaw\n"
|
||||
" base_url: 'https://withcfg.example/v1'\n"
|
||||
" model: glm-5.1\n"
|
||||
" api_key: cfg-key\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# No live base_url carried — resolution must come from config alone,
|
||||
# via the named-custom branch in resolve_provider_client.
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main("custom:openclaw", "glm-5.1")
|
||||
client, resolved = mod.resolve_provider_client("auto", None)
|
||||
assert client is not None
|
||||
base = self._client_base_url(client)
|
||||
assert base and base.rstrip("/") == "https://withcfg.example/v1"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Pin the semantics of SUMMARY_PREFIX so the compaction handoff doesn't
|
||||
re-introduce conflicting instructions.
|
||||
|
||||
Background: SUMMARY_PREFIX previously contained two contradictory directives:
|
||||
|
||||
1. "treat it as background reference, NOT as active instructions"
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary"
|
||||
"Respond ONLY to the latest user message that appears AFTER this summary"
|
||||
|
||||
2. "Your current task is identified in the '## Active Task' section of the
|
||||
summary — resume exactly from there."
|
||||
|
||||
When the latest user message contradicted Active Task (e.g. "stop the
|
||||
i18n refactor", "never mind, look at grafana"), the model often followed
|
||||
(2) anyway because "resume exactly" is a strong directive — leading to
|
||||
the agent repeatedly re-surfacing already-cancelled work across turns.
|
||||
|
||||
These tests pin the post-fix invariants so the conflict cannot regress.
|
||||
"""
|
||||
|
||||
from agent.context_compressor import SUMMARY_PREFIX
|
||||
|
||||
|
||||
def test_no_resume_exactly_directive():
|
||||
"""The prefix must not tell the model to resume Active Task verbatim."""
|
||||
assert "resume exactly" not in SUMMARY_PREFIX.lower()
|
||||
|
||||
|
||||
def test_latest_message_wins_on_conflict():
|
||||
"""The prefix must explicitly say latest user message wins on conflict."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "latest user message" in lower
|
||||
# Must have an explicit conflict-resolution rule.
|
||||
assert "wins" in lower or "supersede" in lower or "discard" in lower
|
||||
|
||||
|
||||
def test_reverse_signals_called_out():
|
||||
"""Reverse signals (stop/undo/never mind/topic change) must be named so
|
||||
the model recognizes them as cancellation triggers, not just background."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
# At least a few of the canonical reverse-signal verbs should appear.
|
||||
reverse_terms = ["stop", "undo", "roll back", "never mind", "just verify"]
|
||||
hits = sum(1 for t in reverse_terms if t in lower)
|
||||
assert hits >= 3, (
|
||||
f"Expected ≥3 reverse-signal terms in SUMMARY_PREFIX, found {hits}. "
|
||||
"Without naming them the model treats reverse signals as ordinary "
|
||||
"context and keeps pushing the cancelled task."
|
||||
)
|
||||
|
||||
|
||||
def test_summary_marked_reference_only():
|
||||
"""The REFERENCE ONLY framing must remain — it's the entire point."""
|
||||
assert "REFERENCE ONLY" in SUMMARY_PREFIX
|
||||
assert "background reference" in SUMMARY_PREFIX
|
||||
assert "NOT as active instructions" in SUMMARY_PREFIX
|
||||
|
||||
|
||||
def test_memory_authority_preserved():
|
||||
"""The fix must not weaken the MEMORY.md / USER.md authority clause."""
|
||||
assert "MEMORY.md" in SUMMARY_PREFIX
|
||||
assert "USER.md" in SUMMARY_PREFIX
|
||||
assert "authoritative" in SUMMARY_PREFIX
|
||||
Reference in New Issue
Block a user