opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
-67
View File
@@ -1213,73 +1213,6 @@ class TestBuildAnthropicKwargs:
assert _supports_fast_mode("claude-haiku-4-5") is False
assert _supports_fast_mode("") is False
def test_fable_class_models_route_as_adaptive_thinking(self):
"""Invariant: unknown/new Claude models default to the modern (4.7+)
contract — adaptive thinking, xhigh-capable, sampling-params-forbidden —
without any per-model code change. Named models (claude-fable-5) and
hypothetical future ones must all classify modern; only the explicit
legacy list stays on the manual path.
"""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
_get_anthropic_max_output,
)
# New / unknown Claude models → modern contract by default.
for m in (
"claude-fable-5",
"anthropic/claude-fable-5",
"claude-saga-2", # hypothetical future named model
"anthropic/claude-opus-9", # hypothetical future numbered model
):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is True, m
assert _forbids_sampling_params(m) is True, m
# 1M-context reasoning model → highest output ceiling.
assert _get_anthropic_max_output("anthropic/claude-fable-5") == 128_000
def test_legacy_claude_stays_on_manual_thinking(self):
"""Older Claude families keep the legacy manual-thinking contract."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_forbids_sampling_params,
)
for m in (
"claude-3-5-sonnet",
"claude-3-7-sonnet",
"anthropic/claude-opus-4.5",
"anthropic/claude-sonnet-4.5",
"claude-haiku-4-5",
):
assert _supports_adaptive_thinking(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_claude_46_is_adaptive_but_not_xhigh_or_no_sampling(self):
"""4.6 is adaptive, but predates xhigh and still accepts sampling."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("claude-opus-4.6", "claude-sonnet-4-6"):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_non_claude_anthropic_models_use_manual_path(self):
"""Non-Claude Anthropic-Messages models (minimax, qwen3, kimi) must not
be misclassified as adaptive by the default-to-modern rule."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("minimax-m2", "qwen3-max", "moonshotai/kimi-k2.5", "glm-4.6"):
assert _supports_adaptive_thinking(m) is False, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
@@ -1,94 +0,0 @@
"""Tests for sanitize_anthropic_kwargs (#31673).
Guards the Anthropic Messages dispatch boundary against Responses-API-only
kwargs (``instructions``, ``input``, ``store``, ``parallel_tool_calls``)
leaking in under an api_mode-flip race. The Anthropic SDK raises a
non-retryable ``TypeError`` on any of them, killing the whole turn.
"""
import logging
import pytest
from agent.anthropic_adapter import (
_RESPONSES_ONLY_KWARGS,
sanitize_anthropic_kwargs,
)
def _fake_anthropic_call(**kwargs):
"""Mimic the Anthropic SDK's strict kwarg signature."""
allowed = {
"model", "messages", "max_tokens", "system", "tools", "tool_choice",
"extra_body", "extra_headers", "temperature", "top_p", "top_k",
"thinking", "timeout",
}
bad = set(kwargs) - allowed
if bad:
raise TypeError(
"Messages.stream() got an unexpected keyword argument "
f"{sorted(bad)[0]!r}"
)
return "OK"
def test_bare_leaked_payload_reproduces_the_typeerror():
"""Without the guard, a Responses-shaped payload raises the issue's error."""
with pytest.raises(TypeError, match="unexpected keyword argument"):
_fake_anthropic_call(model="claude-sonnet-4-6", instructions="sys")
def test_strips_all_responses_only_keys():
payload = {
"model": "claude-sonnet-4-6",
"instructions": "You are Hermes.",
"input": [{"role": "user", "content": "hi"}],
"store": False,
"parallel_tool_calls": True,
}
out = sanitize_anthropic_kwargs(payload)
assert out is payload # mutates in place and returns same dict
assert payload == {"model": "claude-sonnet-4-6"}
assert _fake_anthropic_call(**payload) == "OK"
def test_clean_anthropic_payload_is_untouched():
payload = {
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1024,
"system": "sys",
"tools": [{"name": "x"}],
}
snapshot = dict(payload)
sanitize_anthropic_kwargs(payload)
assert payload == snapshot
assert _fake_anthropic_call(**payload) == "OK"
def test_warns_when_keys_are_stripped(caplog):
with caplog.at_level(logging.WARNING, logger="agent.anthropic_adapter"):
sanitize_anthropic_kwargs(
{"model": "m", "instructions": "sys"}, log_prefix="[pfx] "
)
assert any(
"31673" in r.message and "[pfx] " in r.message
for r in caplog.records
), caplog.records
def test_no_warning_on_clean_payload(caplog):
with caplog.at_level(logging.WARNING, logger="agent.anthropic_adapter"):
sanitize_anthropic_kwargs({"model": "m", "messages": []})
assert not caplog.records
def test_non_dict_input_is_noop():
assert sanitize_anthropic_kwargs(None) is None
assert sanitize_anthropic_kwargs("not a dict") == "not a dict"
def test_responses_only_kwargs_membership():
# Contract: instructions (the reported symptom) plus the sibling
# Responses-shape keys are all covered.
assert {"instructions", "input", "store", "parallel_tool_calls"} <= _RESPONSES_ONLY_KWARGS
@@ -1,96 +0,0 @@
"""Regression: output-only SDK fields must not leak into Anthropic request input.
Reproduces HTTP 400 `messages.N.content.M.text.parsed_output: Extra inputs are
not permitted`. Anthropic SDK response blocks carry output-only attributes
(text blocks: `parsed_output`, `citations=None`; tool_use blocks: `caller`)
that the Messages *input* schema forbids. normalize_response captured blocks
verbatim via _to_plain_data and replayed them as input → 400.
Fix: whitelist input-permitted fields per block type at three points —
normalize_response capture, _sanitize_replay_block (ordered-blocks replay), and
_convert_content_part_to_anthropic (content-list replay).
"""
import sys, os
sys.path.insert(0, os.path.expanduser("~/.hermes/hermes-agent"))
import pytest
from agent.anthropic_adapter import (
_sanitize_replay_block,
_convert_content_part_to_anthropic,
_convert_assistant_message,
)
FORBIDDEN = {"parsed_output", "caller"}
def _assert_clean(block):
"""No forbidden output-only key, and no null citations, anywhere."""
assert isinstance(block, dict)
for k in FORBIDDEN:
assert k not in block, f"forbidden field {k!r} survived: {block}"
if "citations" in block:
assert isinstance(block["citations"], list) and block["citations"], \
"citations must be a non-empty list if present (None/[] is input-invalid)"
class TestSanitizeReplayBlock:
def test_text_block_strips_parsed_output_and_null_citations(self):
poisoned = {"type": "text", "text": "hi", "parsed_output": None, "citations": None}
out = _sanitize_replay_block(poisoned)
_assert_clean(out)
assert out == {"type": "text", "text": "hi"}
def test_tool_use_strips_caller(self):
poisoned = {"type": "tool_use", "id": "toolu_1", "name": "read_file",
"input": {"path": "a"}, "caller": {"type": "agent"}}
out = _sanitize_replay_block(poisoned)
_assert_clean(out)
assert out["name"] == "read_file" and out["input"] == {"path": "a"}
def test_thinking_preserves_signature(self):
b = {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
out = _sanitize_replay_block(b)
assert out == {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
def test_text_keeps_real_citations(self):
real = [{"type": "char_location", "cited_text": "q"}]
out = _sanitize_replay_block({"type": "text", "text": "t", "citations": real})
assert out["citations"] == real
def test_unknown_type_dropped(self):
assert _sanitize_replay_block({"type": "server_tool_use", "foo": 1}) is None
class TestContentPartConversion:
def test_stored_text_block_with_parsed_output_cleaned(self):
# The exact content.N.text.parsed_output failure shape.
part = {"type": "text", "text": "hello", "parsed_output": None, "citations": None}
out = _convert_content_part_to_anthropic(part)
_assert_clean(out)
class TestAssistantReplay:
def test_interleaved_blocks_replayed_clean_and_ordered(self):
m = {
"role": "assistant",
"anthropic_content_blocks": [
{"type": "thinking", "thinking": "plan", "signature": "s1"},
{"type": "text", "text": "doing it", "parsed_output": None, "citations": None},
{"type": "tool_use", "id": "toolu_1", "name": "read_file",
"input": {"path": "a"}, "caller": {"type": "agent"}},
],
}
out = _convert_assistant_message(m)
blocks = out["content"]
# order preserved
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
# every block clean
for b in blocks:
_assert_clean(b)
# signature + tool fields intact
assert blocks[0]["signature"] == "s1"
assert blocks[2]["name"] == "read_file"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -1,314 +0,0 @@
"""Regression test for the Anthropic interleaved thinking-block 400.
Reproduces: HTTP 400 ``messages.N.content.M: thinking or redacted_thinking
blocks in the latest assistant message cannot be modified. These blocks must
remain as they were in the original response.``
Root cause under test
----------------------
With adaptive / interleaved thinking (Claude 4.6+, e.g. Opus 4.8), a single
assistant turn can emit content blocks in an interleaved order::
thinking_1 (signed) · tool_use_1 · thinking_2 (signed) · tool_use_2
Anthropic signs each thinking block against the turn content that precedes it
at its position. ``thinking_2`` is signed with ``tool_use_1`` before it.
``AnthropicTransport.normalize_response`` (agent/transports/anthropic.py)
splits the turn into two *parallel* lists — ``reasoning_details`` (thinking
blocks) and ``tool_calls`` (tool_use blocks) — discarding the cross-type
ordering. ``run_agent`` stores those as separate fields on the assistant
message. On replay, ``_convert_assistant_message`` (agent/anthropic_adapter.py)
rebuilds the content as ``[all thinking][text][all tool_use]``, which reorders
``thinking_2`` ahead of ``tool_use_1``. The signature no longer matches its
original position, so Anthropic rejects the latest assistant message with the
400 above.
This test asserts that an interleaved turn round-trips through
normalize_response -> stored message -> convert_messages_to_anthropic with its
block order preserved. It FAILS on the current code (documenting the bug) and
should PASS once block ordering is preserved on replay.
"""
import json
from types import SimpleNamespace
import pytest
from agent.transports import get_transport
from agent.anthropic_adapter import convert_messages_to_anthropic
def _thinking_block(text: str, signature: str) -> SimpleNamespace:
"""A signed Anthropic thinking block, shaped like the SDK object."""
return SimpleNamespace(type="thinking", thinking=text, signature=signature)
def _tool_use_block(block_id: str, name: str, payload: dict) -> SimpleNamespace:
return SimpleNamespace(type="tool_use", id=block_id, name=name, input=payload)
def _interleaved_response() -> SimpleNamespace:
"""An assistant turn with thinking interleaved between two tool_use blocks."""
return SimpleNamespace(
content=[
_thinking_block("Plan: inspect file A first.", "sig-AAA"),
_tool_use_block("toolu_1", "read_file", {"path": "a.py"}),
_thinking_block("A looked fine; now inspect B.", "sig-BBB"),
_tool_use_block("toolu_2", "read_file", {"path": "b.py"}),
],
stop_reason="tool_use",
usage=None,
)
def _stored_assistant_message(normalized) -> dict:
"""Reconstruct the OpenAI-style assistant message the way run_agent stores it.
run_agent.py persists assistant turns as separate fields: content,
reasoning_details (from provider_data), and tool_calls. See
run_agent.py L1513-1516 and hermes_state.py.
"""
provider_data = normalized.provider_data or {}
tool_calls = []
for tc in (normalized.tool_calls or []):
tool_calls.append({
"id": tc.id,
"type": "function",
"function": {"name": tc.name, "arguments": tc.arguments},
})
msg = {
"role": "assistant",
"content": normalized.content or "",
"reasoning_details": provider_data.get("reasoning_details"),
"tool_calls": tool_calls,
}
# build_assistant_message lifts the verbatim ordered-block channel onto
# the stored message; mirror that here.
blocks = provider_data.get("anthropic_content_blocks")
if blocks:
msg["anthropic_content_blocks"] = blocks
return msg
def _original_block_order(response) -> list:
"""The (type, key) sequence of the original interleaved response."""
order = []
for b in response.content:
if b.type == "thinking":
order.append(("thinking", b.signature))
elif b.type == "tool_use":
order.append(("tool_use", b.id))
return order
def _replayed_block_order(assistant_content) -> list:
order = []
for b in assistant_content:
if not isinstance(b, dict):
continue
if b.get("type") in ("thinking", "redacted_thinking"):
order.append(("thinking", b.get("signature")))
elif b.get("type") == "tool_use":
order.append(("tool_use", b.get("id")))
return order
class TestInterleavedThinkingBlockOrder:
def test_normalize_response_loses_interleaving(self):
"""Confirm the lossy split: normalize_response stores thinking and
tool_use in independent fields with no positional linkage."""
transport = get_transport("anthropic_messages")
normalized = transport.normalize_response(_interleaved_response())
# Both thinking blocks are captured...
details = (normalized.provider_data or {}).get("reasoning_details")
assert details is not None and len(details) == 2
# ...and both tool calls...
assert normalized.tool_calls is not None and len(normalized.tool_calls) == 2
# ...but they live in separate fields. There is no single ordered
# structure recording that thinking_2 sat between the two tool calls.
# (This is the structural precondition for the reorder bug.)
def test_interleaved_order_preserved_on_replay(self):
"""The latest assistant message must replay blocks in their ORIGINAL
order, or Anthropic rejects the signed thinking blocks with a 400.
FAILS on current code: _convert_assistant_message front-loads all
thinking blocks, producing
thinking_1 · thinking_2 · tool_use_1 · tool_use_2
instead of the original
thinking_1 · tool_use_1 · thinking_2 · tool_use_2
"""
response = _interleaved_response()
original_order = _original_block_order(response)
transport = get_transport("anthropic_messages")
normalized = transport.normalize_response(response)
assistant_msg = _stored_assistant_message(normalized)
# Build a minimal conversation where this assistant turn is the LATEST
# assistant message (the one whose signed blocks are sent verbatim).
messages = [
{"role": "user", "content": "Inspect a.py and b.py."},
assistant_msg,
{"role": "tool", "tool_call_id": "toolu_1", "content": "a.py: ok"},
{"role": "tool", "tool_call_id": "toolu_2", "content": "b.py: ok"},
]
_system, anthropic_messages = convert_messages_to_anthropic(
messages,
base_url=None, # direct Anthropic
model="claude-opus-4-8", # adaptive thinking family
)
# Find the (latest) assistant message in the converted output.
assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"]
assert assistant_out, "no assistant message in converted output"
replayed_order = _replayed_block_order(assistant_out[-1]["content"])
assert replayed_order == original_order, (
"Interleaved thinking/tool_use order was not preserved on replay.\n"
f" original: {original_order}\n"
f" replayed: {replayed_order}\n"
"Anthropic signs thinking blocks against their original position; "
"reordering invalidates the signature -> HTTP 400 'thinking blocks "
"in the latest assistant message cannot be modified'."
)
def test_replay_falls_back_gracefully_without_ordered_blocks(self):
"""Without the ordered-block channel, conversion must not crash.
The channel is intentionally NOT persisted to state.db (in-memory
only): a session reloaded from disk after a crash loses the field
and falls back to reconstruction. That replay may take one HTTP 400,
which the thinking-signature recovery (#43667) absorbs by stripping
reasoning_details and retrying. This test pins the fallback shape:
conversion still produces a valid assistant message from the
parallel reasoning_details + tool_calls fields.
"""
response = _interleaved_response()
transport = get_transport("anthropic_messages")
normalized = transport.normalize_response(response)
assistant_msg = _stored_assistant_message(normalized)
# Simulate a disk reload: the in-memory-only channel is gone.
assistant_msg.pop("anthropic_content_blocks", None)
messages = [
assistant_msg,
{"role": "tool", "tool_call_id": "toolu_1", "content": "a ok"},
{"role": "tool", "tool_call_id": "toolu_2", "content": "b ok"},
]
_system, anthropic_messages = convert_messages_to_anthropic(
messages, base_url=None, model="claude-opus-4-8",
)
assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"]
assert assistant_out, "no assistant message in converted output"
content = assistant_out[-1]["content"]
assert isinstance(content, list) and content, "fallback produced empty content"
# Reconstruction keeps both tool_use blocks (answered by results).
tool_ids = [b.get("id") for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
assert set(tool_ids) == {"toolu_1", "toolu_2"}
class TestInterleavedReplayCredentialRedaction:
"""The verbatim-replay fast path must not leak un-redacted secrets.
anthropic_content_blocks captures each tool_use ``input`` from the RAW API
response (normalize_response), which is NOT credential-redacted. The
parallel tool_calls[].function.arguments IS redacted at storage time
(build_assistant_message, #19798). If the fast path replays the block's raw
input verbatim, a secret the model inlined into a tool call rides back onto
the wire — even though it is redacted everywhere else in history. The fix
re-sources tool_use input from the redacted tool_calls map by id.
"""
def test_tool_use_input_resourced_from_redacted_tool_calls(self):
REDACTED = "[REDACTED_SECRET]"
# Ordered channel: raw input carries the live secret (as captured from
# the unredacted API response).
ordered = [
{"type": "thinking", "thinking": "Call the API.", "signature": "sig-AAA"},
{
"type": "tool_use",
"id": "toolu_1",
"name": "terminal",
"input": {"command": "curl -H 'Authorization: Bearer sk-LIVE-SECRET-123'"},
},
{"type": "thinking", "thinking": "Now the second call.", "signature": "sig-BBB"},
{
"type": "tool_use",
"id": "toolu_2",
"name": "terminal",
"input": {"command": "echo done"},
},
]
# Stored tool_calls: arguments already redacted (the #19798 path).
assistant_msg = {
"role": "assistant",
"content": "",
"reasoning_details": [b for b in ordered if b["type"] == "thinking"],
"tool_calls": [
{
"id": "toolu_1",
"type": "function",
"function": {
"name": "terminal",
"arguments": json.dumps(
{"command": f"curl -H 'Authorization: Bearer {REDACTED}'"}
),
},
},
{
"id": "toolu_2",
"type": "function",
"function": {
"name": "terminal",
"arguments": json.dumps({"command": "echo done"}),
},
},
],
"anthropic_content_blocks": ordered,
}
messages = [
{"role": "user", "content": "Hit the API twice."},
assistant_msg,
{"role": "tool", "tool_call_id": "toolu_1", "content": "200 OK"},
{"role": "tool", "tool_call_id": "toolu_2", "content": "done"},
]
_system, anthropic_messages = convert_messages_to_anthropic(
messages, base_url=None, model="claude-opus-4-8",
)
assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"]
assert assistant_out, "no assistant message in converted output"
blocks = assistant_out[-1]["content"]
tool_uses = {b["id"]: b for b in blocks if b.get("type") == "tool_use"}
assert set(tool_uses) == {"toolu_1", "toolu_2"}, "tool_use blocks missing/renamed"
# The replayed input must be the REDACTED value, not the live secret.
replayed_cmd = tool_uses["toolu_1"]["input"]["command"]
assert "sk-LIVE-SECRET-123" not in replayed_cmd, (
"Un-redacted secret leaked onto the wire via the verbatim-replay "
"fast path. tool_use input must be re-sourced from the redacted "
"tool_calls map, not the raw captured block."
)
assert REDACTED in replayed_cmd
# Interleave order is still preserved (the reason the channel exists).
order = [
("thinking", b.get("signature")) if b.get("type") == "thinking"
else ("tool_use", b.get("id"))
for b in blocks if b.get("type") in ("thinking", "tool_use")
]
assert order == [
("thinking", "sig-AAA"),
("tool_use", "toolu_1"),
("thinking", "sig-BBB"),
("tool_use", "toolu_2"),
]
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
-181
View File
@@ -1794,108 +1794,6 @@ def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
# ---------------------------------------------------------------------------
class TestTransientTransportRetry:
"""call_llm retries ONCE on the same provider for a transient transport
blip before escalating to the fallback chain.
Salvaged from PR #16587 (@ARegalado1). The original fixed only the
context-compression caller; this lives in call_llm so every auxiliary
task (compression, memory flush, title-gen, session-search, vision)
gets the same same-target retry, and the gate reuses the canonical
_is_connection_error detector.
"""
def _patches(self, client):
return (
patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("openrouter", "some-model", None, None, None),
),
patch(
"agent.auxiliary_client._get_cached_client",
return_value=(client, "some-model"),
),
patch(
"agent.auxiliary_client._validate_llm_response",
side_effect=lambda resp, _task: resp,
),
)
def test_retries_streaming_close_once_same_provider(self):
client = MagicMock()
client.base_url = "https://openrouter.ai/api/v1"
client.chat.completions.create.side_effect = [
Exception(
"peer closed connection without sending complete message body "
"(incomplete chunked read)"
),
{"ok": True},
]
p1, p2, p3 = self._patches(client)
with p1, p2, p3:
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
assert result == {"ok": True}
# Same client called twice — no provider fallback needed.
assert client.chat.completions.create.call_count == 2
def test_retries_5xx_once_same_provider(self):
class _Err503(Exception):
status_code = 503
client = MagicMock()
client.base_url = "https://openrouter.ai/api/v1"
client.chat.completions.create.side_effect = [_Err503("upstream"), {"ok": True}]
p1, p2, p3 = self._patches(client)
with p1, p2, p3:
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
assert result == {"ok": True}
assert client.chat.completions.create.call_count == 2
def test_does_not_retry_non_transient_400(self):
class _Err400(Exception):
status_code = 400
client = MagicMock()
client.base_url = "https://openrouter.ai/api/v1"
client.chat.completions.create.side_effect = _Err400("bad request")
p1, p2, p3 = self._patches(client)
with p1, p2, p3, pytest.raises(_Err400):
call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
# Non-transient: single attempt, no same-target retry.
assert client.chat.completions.create.call_count == 1
def test_second_transient_failure_escalates_to_fallback(self):
"""Two transient failures in a row exhaust the same-target retry and
fall through to the existing connection-error provider fallback."""
primary = MagicMock()
primary.base_url = "https://openrouter.ai/api/v1"
primary.chat.completions.create.side_effect = Exception(
"peer closed connection without sending complete message body"
)
fb_client = MagicMock()
fb_client.base_url = "https://api.openai.com/v1"
fb_client.chat.completions.create.return_value = {"fallback": True}
p1, p2, p3 = self._patches(primary)
with (
p1, p2, p3,
patch(
"agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, ""),
),
patch(
"agent.auxiliary_client._try_main_agent_model_fallback",
return_value=(fb_client, "fb-model", "openai"),
),
):
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
assert result == {"fallback": True}
# Primary tried twice (initial + same-target retry), then fallback.
assert primary.chat.completions.create.call_count == 2
assert fb_client.chat.completions.create.call_count == 1
class TestIsConnectionError:
"""Tests for _is_connection_error detection."""
@@ -3791,82 +3689,3 @@ class TestAuxUnhealthyCache:
)
# After the 402, OpenRouter is in the unhealthy cache.
assert _is_provider_unhealthy("openrouter") is True
# ── auxiliary_max_tokens_param ──────────────────────────────────────────────
class TestAuxiliaryMaxTokensParam:
"""Verify the kwarg emitted by ``auxiliary_max_tokens_param`` across
URL / provider / model-name combinations. Regression cover: a custom
OpenAI-compatible endpoint serving ``gpt-5.x`` was silently getting
``max_tokens`` and 400-ing on ``unsupported_parameter``."""
def test_direct_openai_returns_max_completion_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://api.openai.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_completion_tokens": 4096}
def test_local_endpoint_without_model_uses_max_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="http://localhost:11434/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
def test_openrouter_api_key_present_keeps_max_tokens_without_model_hint(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://openrouter.ai/api/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
# Model-name fallback — this is the regression guard.
def test_custom_endpoint_serving_gpt5_uses_max_completion_tokens(self):
"""Third-party gateway + gpt-5.x: name-based detection must kick in."""
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="gpt-5.4") == {
"max_completion_tokens": 4096
}
def test_openrouter_serving_gpt4o_uses_max_completion_tokens(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://openrouter.ai/api/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="openai/gpt-4o-mini") == {
"max_completion_tokens": 4096
}
def test_custom_endpoint_serving_classic_llama_keeps_max_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="llama3-70b") == {
"max_tokens": 4096
}
def test_empty_model_falls_back_to_url_only(self):
"""No model hint → only the URL-based rule applies."""
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="") == {"max_tokens": 4096}
assert auxiliary_max_tokens_param(4096, model=None) == {"max_tokens": 4096}
-124
View File
@@ -1471,127 +1471,3 @@ class TestCallConverseInvalidatesOnStaleError:
)
assert _bedrock_runtime_client_cache.get("us-east-1") is live_client
class TestStreamingAccessDeniedDetection:
"""is_streaming_access_denied_error() recognizes IAM denials of
bedrock:InvokeModelWithResponseStream (InvokeModel-only policies)."""
def _denied_client_error(self):
from botocore.exceptions import ClientError
return ClientError(
error_response={
"Error": {
"Code": "AccessDeniedException",
"Message": (
"User: arn:aws:iam::123456789012:user/x is not "
"authorized to perform: "
"bedrock:InvokeModelWithResponseStream on resource: "
"arn:aws:bedrock:us-east-1::foundation-model/"
"anthropic.claude-3-sonnet-20240229-v1:0"
),
}
},
operation_name="ConverseStream",
)
def test_matches_access_denied_client_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_streaming_access_denied_error
assert is_streaming_access_denied_error(self._denied_client_error()) is True
def test_ignores_access_denied_for_other_actions(self):
"""AccessDenied on InvokeModel itself is NOT a streaming-only denial."""
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_streaming_access_denied_error
from botocore.exceptions import ClientError
exc = ClientError(
error_response={
"Error": {
"Code": "AccessDeniedException",
"Message": (
"User is not authorized to perform: bedrock:InvokeModel"
),
}
},
operation_name="Converse",
)
assert is_streaming_access_denied_error(exc) is False
def test_ignores_validation_error_mentioning_action(self):
"""Non-authz ClientErrors don't match even if the action name appears."""
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_streaming_access_denied_error
from botocore.exceptions import ClientError
exc = ClientError(
error_response={
"Error": {
"Code": "ValidationException",
"Message": "InvokeModelWithResponseStream input malformed",
}
},
operation_name="ConverseStream",
)
assert is_streaming_access_denied_error(exc) is False
def test_matches_wrapped_sdk_permission_error(self):
"""Non-ClientError wrappers (AnthropicBedrock SDK) match on message."""
from agent.bedrock_adapter import is_streaming_access_denied_error
exc = RuntimeError(
"PermissionDeniedError: user is not authorized to perform: "
"bedrock:InvokeModelWithResponseStream"
)
assert is_streaming_access_denied_error(exc) is True
def test_ignores_unrelated_errors(self):
from agent.bedrock_adapter import is_streaming_access_denied_error
assert is_streaming_access_denied_error(ValueError("boom")) is False
assert is_streaming_access_denied_error(
RuntimeError("stream not supported")
) is False
class TestCallConverseStreamIamFallback:
"""call_converse_stream() falls back to converse() when IAM denies the
streaming action — InvokeModel-only policies keep working."""
def test_falls_back_to_converse_on_streaming_denial(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse_stream,
reset_client_cache,
)
from botocore.exceptions import ClientError
reset_client_cache()
client = MagicMock()
client.converse_stream.side_effect = ClientError(
error_response={
"Error": {
"Code": "AccessDeniedException",
"Message": (
"User is not authorized to perform: "
"bedrock:InvokeModelWithResponseStream"
),
}
},
operation_name="ConverseStream",
)
client.converse.return_value = {
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
}
_bedrock_runtime_client_cache["us-east-1"] = client
result = call_converse_stream(
region="us-east-1",
model="anthropic.claude-3-sonnet-20240229-v1:0",
messages=[{"role": "user", "content": "hi"}],
)
client.converse.assert_called_once()
assert result.choices[0].message.content == "hi"
# Not a stale connection — client stays cached.
assert _bedrock_runtime_client_cache.get("us-east-1") is client
@@ -1,134 +0,0 @@
"""Regression guard for the cascading-interrupt hang (PR #6600).
Original diagnosis and fix by Kristian Vastveit (@kristianvast) in PR #6600,
against the then-inline ``_interruptible_api_call`` /
``_interruptible_streaming_api_call`` methods in run_agent.py. Those methods
have since been extracted into ``agent/chat_completion_helpers.py``, so the
fix is reapplied there and these tests target the extracted functions.
The bug: when ``agent.interrupt()`` fires during an active LLM call, the main
poll loop force-closes the worker-local httpx client to stop token generation.
That raises a transport error (RemoteProtocolError) on the worker the
EXPECTED consequence of our own close, not a network bug. The streaming retry
loop misclassified it as a transient connection error and retried, each doomed
retry stalling for the full stream-stale timeout (up to 300s). Because the
gateway caches AIAgent instances per session, the stale worker outlived the
turn and raced the next turn's request — the root of the multi-minute
cascading-interrupt hang.
The fix: a request-local ``_request_cancelled`` token set by the poll loop
right before the force-close. The worker's exception handler checks it and
exits cleanly (no retry, no fallback, no "reconnecting" status) instead of
treating the forced error as transient.
"""
import threading
import time
import types
from unittest.mock import MagicMock
import httpx
import pytest
from agent import chat_completion_helpers as cch
class _FakeInterruptError(Exception):
"""Stand-in for the transport error a force-close raises on the worker."""
def _make_agent():
"""A MagicMock agent wired with just enough surface for the helpers."""
agent = MagicMock()
agent.api_mode = "chat_completions"
agent._interrupt_requested = False
agent.verbose_logging = False
# _compute_non_stream_stale_timeout / streaming setup helpers return
# benign values; the real call path is mocked per-test.
agent._compute_non_stream_stale_timeout.return_value = 5.0
return agent
def test_non_streaming_cancel_does_not_surface_network_error():
"""A force-close during a non-streaming call must raise InterruptedError,
not the swallowed transport error."""
agent = _make_agent()
create_calls = {"n": 0}
fake_client = MagicMock()
def _create(**kwargs):
create_calls["n"] += 1
# Simulate the main thread firing an interrupt mid-call, then the
# force-close raising a transport error on this worker.
agent._interrupt_requested = True
time.sleep(0.3) # let the poll loop observe the interrupt + force-close
raise httpx.RemoteProtocolError("peer closed connection")
fake_client.chat.completions.create.side_effect = _create
agent._create_request_openai_client.return_value = fake_client
agent._close_request_openai_client = MagicMock()
agent._abort_request_openai_client = MagicMock()
t0 = time.time()
with pytest.raises(InterruptedError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
elapsed = time.time() - t0
# The forced RemoteProtocolError must NOT surface as the raised error.
assert create_calls["n"] == 1
assert elapsed < 3.0, f"interrupt took {elapsed:.1f}s — should be near-instant"
def test_normal_transient_error_still_raises_when_not_cancelled():
"""Regression guard: a real transport error with NO interrupt must still
surface to the caller (so the outer retry loop can recover)."""
agent = _make_agent()
fake_client = MagicMock()
fake_client.chat.completions.create.side_effect = httpx.RemoteProtocolError(
"genuine network drop"
)
agent._create_request_openai_client.return_value = fake_client
agent._close_request_openai_client = MagicMock()
agent._abort_request_openai_client = MagicMock()
agent._interrupt_requested = False
with pytest.raises(httpx.RemoteProtocolError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
def test_request_cancelled_token_is_request_local():
"""The cancellation token must be created per call, not shared on the
agent a stale worker from a previous turn must not see the next turn's
interrupt flag flip back to False and mistake its own forced error for a
network bug. We assert the helper reads agent._interrupt_requested at the
force-close site (request-local token set there), by confirming two
independent calls don't share cancellation state."""
agent = _make_agent()
# First call: interrupted.
fake_client_1 = MagicMock()
def _create_1(**kwargs):
agent._interrupt_requested = True
time.sleep(0.3)
raise httpx.RemoteProtocolError("forced close turn A")
fake_client_1.chat.completions.create.side_effect = _create_1
agent._create_request_openai_client.return_value = fake_client_1
agent._close_request_openai_client = MagicMock()
agent._abort_request_openai_client = MagicMock()
with pytest.raises(InterruptedError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
# Second call: NOT interrupted (turn boundary cleared the flag). A genuine
# error must still surface — the previous call's cancellation must not leak.
agent._interrupt_requested = False
fake_client_2 = MagicMock()
fake_client_2.chat.completions.create.side_effect = httpx.RemoteProtocolError(
"genuine drop turn B"
)
agent._create_request_openai_client.return_value = fake_client_2
with pytest.raises(httpx.RemoteProtocolError):
cch.interruptible_api_call(agent, {"model": "x", "messages": []})
-451
View File
@@ -1,451 +0,0 @@
"""Tests for agent.coding_context — RuntimeMode seam, resolver, toolset, git probe."""
import json
import os
import subprocess
import shutil
from pathlib import Path
import pytest
from agent import coding_context as cc
def test_coding_guidance_advertises_persistent_terminal_state():
assert "Terminal state persists across calls" in cc.CODING_AGENT_GUIDANCE
assert "Activate a virtualenv" in cc.CODING_AGENT_GUIDANCE
assert "instead of re-sourcing it before every test command" in cc.CODING_AGENT_GUIDANCE
def _git_init(path):
env = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": str(path),
}
for args in (
["init", "-q", "-b", "main"],
["commit", "-q", "--allow-empty", "-m", "init commit"],
):
subprocess.run([shutil.which("git"), "-C", str(path), *args], check=True, env=env)
# ── resolver ──────────────────────────────────────────────────────────────
class TestIsCodingContext:
def test_off_never_activates(self, tmp_path):
_git_init(tmp_path)
cfg = {"agent": {"coding_context": "off"}}
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
def test_on_forces_even_without_git(self, tmp_path):
cfg = {"agent": {"coding_context": "on"}}
assert cc.is_coding_context(platform="telegram", cwd=tmp_path, config=cfg) is True
def test_auto_requires_git_repo(self, tmp_path):
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
_git_init(tmp_path)
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
def test_auto_skips_messaging_surfaces(self, tmp_path):
_git_init(tmp_path)
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="discord", cwd=tmp_path, config=cfg) is False
assert cc.is_coding_context(platform="tui", cwd=tmp_path, config=cfg) is True
def test_default_mode_is_auto(self, tmp_path):
# Unknown/missing value normalizes to auto.
_git_init(tmp_path)
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config={}) is True
# ── toolset substitution ────────────────────────────────────────────────────
class TestCodingSelection:
def test_selects_coding_under_focus(self, tmp_path):
_git_init(tmp_path)
cfg = {"agent": {"coding_context": "focus"}}
out = cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg)
assert out is not None
assert out[0] == cc.CODING_TOOLSET
def test_auto_is_prompt_only(self, tmp_path):
# Default posture must never override the user's configured toolsets —
# off-by-default toolsets are already off, and explicit opt-ins
# (image-gen, spotify, …) survive entering a code workspace.
_git_init(tmp_path)
cfg = {"agent": {"coding_context": "auto"}}
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
# …while the prompt posture is still active.
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
def test_on_is_prompt_only(self, tmp_path):
cfg = {"agent": {"coding_context": "on"}}
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
def test_focus_requires_workspace(self, tmp_path):
# focus inherits auto's detection gate — bare dir stays general.
cfg = {"agent": {"coding_context": "focus"}}
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
def test_none_when_inactive(self, tmp_path):
cfg = {"agent": {"coding_context": "off"}}
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
def test_coding_toolset_is_registered(self):
from toolsets import resolve_toolset
tools = resolve_toolset(cc.CODING_TOOLSET)
# Coding essentials present…
for t in ("read_file", "write_file", "patch", "search_files", "terminal", "todo"):
assert t in tools
# …and the noise is gone.
for t in ("send_message", "text_to_speech", "image_generate", "computer_use"):
assert t not in tools
# ── git/workspace probe ─────────────────────────────────────────────────────
class TestWorkspaceBlock:
def test_empty_outside_repo(self, tmp_path):
assert cc.build_coding_workspace_block(tmp_path) == ""
def test_reports_branch_and_clean_status(self, tmp_path):
_git_init(tmp_path)
block = cc.build_coding_workspace_block(tmp_path)
assert "Workspace" in block
assert f"Root: {tmp_path.resolve()}" in block or "Root:" in block
assert "Branch: main" in block
assert "Status: clean" in block
assert "init commit" in block
def test_reports_dirty_counts(self, tmp_path):
_git_init(tmp_path)
(tmp_path / "untracked.txt").write_text("hi")
block = cc.build_coding_workspace_block(tmp_path)
assert "untracked" in block
assert "clean" not in block.split("Status:")[1].splitlines()[0]
# ── project facts (verify-loop detection) ───────────────────────────────────
class TestProjectFacts:
def test_package_json_scripts_surface_verify_commands(self, tmp_path):
_git_init(tmp_path)
(tmp_path / "package.json").write_text(
json.dumps({"scripts": {"test": "vitest", "lint": "eslint .", "dev": "vite"}})
)
(tmp_path / "pnpm-lock.yaml").write_text("")
block = cc.build_coding_workspace_block(tmp_path)
assert "Project: package.json (pnpm)" in block
assert "pnpm run test" in block and "pnpm run lint" in block
# Non-verify scripts (dev servers, …) stay out of the snapshot.
assert "run dev" not in block
def test_pytest_config_and_run_tests_script(self, tmp_path):
_git_init(tmp_path)
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\n")
scripts = tmp_path / "scripts"
scripts.mkdir()
(scripts / "run_tests.sh").write_text("#!/bin/sh\n")
block = cc.build_coding_workspace_block(tmp_path)
assert "scripts/run_tests.sh" in block
assert "pytest" in block.split("Verify:")[1]
def test_makefile_verify_targets_only(self, tmp_path):
_git_init(tmp_path)
(tmp_path / "Makefile").write_text("test:\n\tgo test ./...\n\ndeploy:\n\t./deploy.sh\n")
block = cc.build_coding_workspace_block(tmp_path)
assert "make test" in block
assert "make deploy" not in block
def test_context_files_listed(self, tmp_path):
_git_init(tmp_path)
(tmp_path / "AGENTS.md").write_text("# rules")
block = cc.build_coding_workspace_block(tmp_path)
assert "Context files: AGENTS.md" in block
def test_worktree_detected_without_primary_path(self, tmp_path):
# A linked worktree should be detected, but the output must NOT contain
# the absolute path to the primary tree — exposing that path causes the
# model to sometimes run commands in the wrong directory.
main_tree = tmp_path / "main"
main_tree.mkdir()
_git_init(main_tree)
worktree = tmp_path / "worktree"
subprocess.run(
["git", "-C", str(main_tree), "worktree", "add", "-b", "wt-branch", str(worktree)],
check=True,
env={"PATH": os.environ.get("PATH", ""), "HOME": str(tmp_path),
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"},
)
block = cc.build_coding_workspace_block(worktree)
assert "Worktree: linked" in block
# The primary tree path must NOT appear anywhere in the output.
assert str(main_tree.resolve()) not in block
assert str(main_tree) not in block
# The worktree root IS the reported root.
assert f"Root: {worktree.resolve()}" in block or "Root:" in block
def test_marker_only_project_gets_snapshot_without_git(self, tmp_path):
# A non-git project (manifest only) still gets a workspace snapshot —
# just without the git lines.
(tmp_path / "package.json").write_text("{}")
block = cc.build_coding_workspace_block(tmp_path)
assert f"Root: {tmp_path.resolve()}" in block
assert "package.json" in block
assert "Branch:" not in block and "Status:" not in block
def test_malformed_package_json_is_ignored(self, tmp_path):
_git_init(tmp_path)
(tmp_path / "package.json").write_text("{not json")
block = cc.build_coding_workspace_block(tmp_path)
assert "Project: package.json" in block
assert "Verify:" not in block
# ── $HOME dotfiles guard ────────────────────────────────────────────────────
class TestHomeDotfilesGuard:
def test_dotfiles_repo_at_home_is_not_coding(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
_git_init(home)
monkeypatch.setattr(Path, "home", lambda: home)
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
# …and a plain subdirectory of the dotfiles repo stays general too.
docs = home / "Documents"
docs.mkdir()
assert cc.is_coding_context(platform="cli", cwd=docs, config=cfg) is False
def test_marker_at_home_is_not_a_project_signal(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
(home / "Makefile").write_text("all:\n")
monkeypatch.setattr(Path, "home", lambda: home)
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
def test_real_project_under_dotfiles_home_still_detects(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
_git_init(home)
monkeypatch.setattr(Path, "home", lambda: home)
proj = home / "www" / "app"
proj.mkdir(parents=True)
(proj / "package.json").write_text("{}")
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=proj, config=cfg) is True
def test_on_mode_bypasses_the_guard(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
cfg = {"agent": {"coding_context": "on"}}
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is True
# ── prompt assembly integration ─────────────────────────────────────────────
class TestStatusParsing:
def test_parse_status_counts_and_branch(self):
porcelain = (
"# branch.head feature\n"
"# branch.upstream origin/feature\n"
"# branch.ab +2 -1\n"
"1 M. N... 100644 100644 100644 aaa bbb staged.py\n"
"1 .M N... 100644 100644 100644 ccc ddd modified.py\n"
"? new.py\n"
"u UU N... 1 2 3 abc def conflict.py\n"
)
branch, counts = cc._parse_status(porcelain)
assert branch["head"] == "feature"
assert branch["upstream"] == "origin/feature"
assert branch["ahead"] == "2" and branch["behind"] == "1"
assert counts["staged"] == 1
assert counts["modified"] == 1
assert counts["untracked"] == 1
assert counts["conflicts"] == 1
# ── RuntimeMode seam ────────────────────────────────────────────────────────
class TestRuntimeMode:
def test_resolves_coding_in_repo(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
assert mode.is_coding is True
assert mode.kind == "coding"
assert mode.profile is cc.CODING_PROFILE
def test_resolves_general_outside_workspace(self, tmp_path):
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
assert mode.is_coding is False
assert mode.kind == "general"
# General posture pins no toolset and injects no blocks.
assert mode.toolset_selection() is None
assert mode.system_blocks() == []
def test_is_frozen(self, tmp_path):
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
with pytest.raises(Exception):
mode.profile = cc.CODING_PROFILE # type: ignore[misc]
def test_system_blocks_include_brief_and_workspace(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
blocks = mode.system_blocks()
assert any("coding agent" in b for b in blocks)
assert any("Workspace" in b for b in blocks)
def test_toolset_selection_gated_on_focus(self, tmp_path):
_git_init(tmp_path)
focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}})
sel = focus.toolset_selection()
assert sel and sel[0] == cc.CODING_TOOLSET
# auto/on resolve the coding profile but stay prompt-only.
for raw in ("auto", "on"):
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}})
assert mode.is_coding is True
assert mode.toolset_selection() is None
# ── edit-format steering (per-model harness tuning) ──────────────────────────
class TestEditFormatSteering:
def test_family_detection(self):
assert cc._model_family("openai/gpt-5.4") == "patch"
assert cc._model_family("openai/codex-mini") == "patch"
assert cc._model_family("anthropic/claude-opus-4.8") == "replace"
assert cc._model_family("anthropic/claude-sonnet-4") == "replace"
# Gemini + open-weight coding models (RL'd on str_replace-style
# editors) steer to replace, not neutral.
for m in (
"google/gemini-3-pro", "deepseek-v3.2", "qwen3-coder",
"moonshot/kimi-k2", "zai/glm-4.6", "nousresearch/hermes-4-405b",
):
assert cc._model_family(m) == "replace"
# Unknown family and no model both fall through to neutral wording.
assert cc._model_family("acme/foo-1") is None
assert cc._model_family(None) is None
assert cc._model_family("") is None
def test_openai_family_gets_v4a_nudge(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path,
config={"agent": {"coding_context": "on"}}, model="openai/gpt-5.4",
)
brief = mode.system_blocks()[0]
assert "mode='patch'" in brief
assert "V4A" in brief
assert "write_file" in brief # new files authored, not patched
# Codex-family harnesses ship apply_patch (V4A) as the ONLY editor and
# instruct it even for single-file edits — never nudge replace mode.
assert "single-file" in brief
assert "mode='replace'" not in brief
def test_anthropic_family_gets_replace_nudge(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path,
config={"agent": {"coding_context": "on"}},
model="anthropic/claude-opus-4.8",
)
brief = mode.system_blocks()[0]
assert "mode='replace'" in brief
assert "write_file" in brief # new files authored, not patched
def test_unknown_model_keeps_neutral_brief(self, tmp_path):
# No edit-format line appended — brief equals the bare profile guidance.
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path,
config={"agent": {"coding_context": "on"}}, model="acme/foo-1",
)
assert mode.system_blocks()[0] == cc.CODING_AGENT_GUIDANCE
def test_no_model_keeps_neutral_brief(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path,
config={"agent": {"coding_context": "on"}},
)
assert mode.system_blocks()[0] == cc.CODING_AGENT_GUIDANCE
def test_general_posture_emits_nothing_regardless_of_model(self, tmp_path):
# Edit steering only fires inside the coding posture.
mode = cc.resolve_runtime_mode(
platform="telegram", cwd=tmp_path, config={}, model="openai/gpt-5.4",
)
assert mode.system_blocks() == []
# ── profile registry ────────────────────────────────────────────────────────
class TestProfiles:
def test_registered_profiles(self):
assert cc.get_profile("coding") is cc.CODING_PROFILE
assert cc.get_profile("general") is cc.GENERAL_PROFILE
def test_unknown_profile_falls_back_to_general(self):
assert cc.get_profile("nonsense") is cc.GENERAL_PROFILE
def test_coding_profile_shape(self):
# The coding profile declares the seams other domains read.
assert cc.CODING_PROFILE.toolset == cc.CODING_TOOLSET
assert cc.CODING_PROFILE.guidance
assert cc.CODING_PROFILE.model_hint == "coding"
# General is inert.
assert cc.GENERAL_PROFILE.toolset is None
assert cc.GENERAL_PROFILE.guidance == ""
def test_skill_demotion_gated_on_focus(self, tmp_path):
# Names-only demotion is opt-in via focus mode — the default (auto)
# and forced (on) postures leave the skill index untouched. Under
# focus, clearly-non-coding categories are demoted (never hidden) and
# coding-adjacent ones keep full entries (deny-list semantics).
_git_init(tmp_path)
for raw in ("auto", "on"):
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}
)
assert mode.is_coding is True
assert mode.compact_skill_categories() == frozenset()
focus = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path,
config={"agent": {"coding_context": "focus"}},
)
assert focus.is_coding is True
compact = focus.compact_skill_categories()
assert "social-media" in compact and "smart-home" in compact
for kept in ("github", "devops", "software-development", "data-science"):
assert kept not in compact
# General posture demotes nothing.
general = cc.resolve_runtime_mode(platform="telegram", cwd=tmp_path, config={})
assert general.compact_skill_categories() == frozenset()
# ── detection signals ───────────────────────────────────────────────────────
class TestDetection:
@pytest.mark.parametrize("marker", ["pyproject.toml", "package.json", "go.mod", "AGENTS.md"])
def test_project_manifest_triggers_without_git(self, tmp_path, marker):
(tmp_path / marker).write_text("x")
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
def test_marker_in_parent_counts_from_subdir(self, tmp_path):
(tmp_path / "pyproject.toml").write_text("x")
sub = tmp_path / "src" / "pkg"
sub.mkdir(parents=True)
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=sub, config=cfg) is True
def test_bare_dir_is_not_coding(self, tmp_path):
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
+3 -8
View File
@@ -3,11 +3,7 @@
import pytest
from unittest.mock import patch, MagicMock
from agent.context_compressor import (
ContextCompressor,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
@pytest.fixture()
@@ -161,7 +157,7 @@ class TestCompress:
result = c.compress(msgs)
combined = "\n".join(str(m.get("content", "")) for m in result)
assert HISTORICAL_TASK_HEADING in combined
assert "## Active Task" in combined
assert "Please fix the compression summary failure" in combined
assert "read_file" in combined
assert "agent/context_compressor.py" in combined
@@ -1217,8 +1213,7 @@ class TestCompressWithClient:
"""When the summary lands as standalone role='user' (e.g. head ends
with assistant/tool), the message body must include the explicit
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
read the verbatim past user request quoted in the historical task
snapshot as
read the verbatim past user request quoted in '## Active Task' as
fresh input (#11475, #14521).
"""
mock_response = MagicMock()
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import hermes_time
from agent.context_compressor import ContextCompressor, HISTORICAL_TASK_HEADING
from agent.context_compressor import ContextCompressor
def _compressor() -> ContextCompressor:
@@ -98,7 +98,7 @@ def test_clock_failure_omits_rule_but_compaction_still_runs():
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert "TEMPORAL ANCHORING" not in prompt
# Structured template still intact.
assert HISTORICAL_TASK_HEADING in prompt
assert "## Active Task" in prompt
def test_anchoring_rule_uses_date_from_hermes_time_now():
+4 -23
View File
@@ -192,38 +192,19 @@ def test_expand_git_diff_staged_and_log(sample_repo: Path):
assert "VALUE = 2" in result.message
def test_missing_file_becomes_warning(sample_repo: Path):
def test_binary_and_missing_files_become_warnings(sample_repo: Path):
from agent.context_references import preprocess_context_references
result = preprocess_context_references(
"Check @file:nope.txt",
"Check @file:blob.bin and @file:nope.txt",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
assert len(result.warnings) == 1
assert "not found" in result.message.lower()
def test_binary_file_yields_actionable_block_not_a_dead_warning(sample_repo: Path):
from agent.context_references import preprocess_context_references
result = preprocess_context_references(
"Check @file:blob.bin",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
# The whole point: a binary attachment must NOT degrade into a discouraging
# warning that makes the model give up — it gets an actionable content block.
assert not result.warnings
assert "blob.bin" in result.message
assert len(result.warnings) == 2
assert "binary" in result.message.lower()
assert "not supported" not in result.message.lower()
# And it must point the agent at the file so it can act on it with tools.
assert str(sample_repo / "blob.bin") in result.message
assert "not found" in result.message.lower()
def test_soft_budget_warns_and_hard_budget_refuses(sample_repo: Path):
+4 -19
View File
@@ -123,29 +123,22 @@ def test_dev_fixtures_drive_cold_start():
class _FakeAgent:
"""Minimal agent surface for the seed helper: state slots + an emit that runs
the real policy against the latch (mirroring run_agent._emit_credits_notices,
including the free-model suppression flag)."""
the real policy against the latch."""
def __init__(self, provider="nous", model=""):
from agent.credits_tracker import evaluate_credits_notices, is_free_tier_model
def __init__(self, provider="nous"):
from agent.credits_tracker import evaluate_credits_notices
self.provider = provider
self.model = model
self._credits_state = None
self._credits_session_start_micros = None
self._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None}
self.emitted: list = []
self._eval = evaluate_credits_notices
self._is_free = is_free_tier_model
def _emit_credits_notices(self):
if self._credits_state is None:
return
show, clear = self._eval(
self._credits_state,
self._credits_latch,
model_is_free=self._is_free(self.model),
)
show, clear = self._eval(self._credits_state, self._credits_latch)
self.emitted.append(([n.key for n in show], clear))
@@ -176,14 +169,6 @@ def test_seed_fires_depleted_at_session_open():
assert a.emitted == [(["credits.depleted"], [])]
def test_seed_depleted_suppressed_on_free_model():
"""A session that opens depleted but on a Nous ``:free`` model must NOT show
the depleted banner inference works fine on the free tier."""
a = _FakeAgent(model="nvidia/nemotron-3-ultra:free")
assert _seed(a, "depleted") is True
assert a.emitted == [([], [])]
def test_seed_healthy_no_notice():
a = _FakeAgent()
assert _seed(a, "healthy") is True
-137
View File
@@ -265,143 +265,6 @@ class TestDepleted:
assert "credits.depleted" in keys
# ── Scenario 5b: free-model suppression of the depleted notice ───────────────
class TestDepletedFreeModelSuppression:
def test_depleted_suppressed_when_model_is_free(self):
latch = fresh_latch()
s = CreditsState(paid_access=False)
to_show, to_clear = evaluate_credits_notices(s, latch, model_is_free=True)
assert all(n.key != "credits.depleted" for n in to_show)
assert "credits.depleted" not in latch["active"]
assert to_clear == []
def test_switch_to_free_model_clears_without_restored(self):
latch = fresh_latch()
# Depleted on a paid model → notice fires
evaluate_credits_notices(CreditsState(paid_access=False), latch)
assert "credits.depleted" in latch["active"]
# Same depleted account, but now on a free model → clear, NO "restored"
to_show, to_clear = evaluate_credits_notices(
CreditsState(paid_access=False), latch, model_is_free=True
)
assert "credits.depleted" in to_clear
assert "credits.depleted" not in latch["active"]
assert all(n.key != "credits.restored" for n in to_show)
def test_switch_back_to_paid_model_while_depleted_reshows(self):
latch = fresh_latch()
evaluate_credits_notices(CreditsState(paid_access=False), latch)
evaluate_credits_notices(CreditsState(paid_access=False), latch, model_is_free=True)
# Back on a paid model, still depleted → notice re-fires
to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=False), latch)
keys = [n.key for n in to_show]
assert "credits.depleted" in keys
assert "credits.depleted" in latch["active"]
def test_genuine_recovery_on_free_model_no_spurious_restored(self):
"""Recovery observed while suppressed (notice never shown) → nothing to
clear, no 'restored' (there was no visible depleted state to restore)."""
latch = fresh_latch()
evaluate_credits_notices(CreditsState(paid_access=False), latch, model_is_free=True)
to_show, to_clear = evaluate_credits_notices(
CreditsState(paid_access=True), latch, model_is_free=True
)
assert to_clear == []
assert all(n.key != "credits.restored" for n in to_show)
def test_genuine_recovery_still_emits_restored_when_notice_active(self):
"""paid_access flip back to True with the notice showing → clear + restored
(unchanged behaviour, regardless of the model-free flag)."""
latch = fresh_latch()
evaluate_credits_notices(CreditsState(paid_access=False), latch)
to_show, to_clear = evaluate_credits_notices(
CreditsState(paid_access=True), latch, model_is_free=True
)
assert "credits.depleted" in to_clear
restored = [n for n in to_show if n.key == "credits.restored"]
assert len(restored) == 1
def test_free_flag_does_not_affect_other_notices(self):
"""Usage-band and grant notices are independent of the model-free gate."""
latch = fresh_latch()
evaluate_credits_notices(state_with_fraction(0.10), latch, model_is_free=True)
to_show, _ = evaluate_credits_notices(
state_with_fraction(0.95, paid_access=False), latch, model_is_free=True
)
keys = [n.key for n in to_show]
assert "credits.usage" in keys
assert "credits.depleted" not in keys
# ── Scenario 5c: is_free_tier_model (local-data-only check) ──────────────────
class TestIsFreeTierModel:
def test_free_suffix_is_free(self):
from agent.credits_tracker import is_free_tier_model
assert is_free_tier_model("nvidia/nemotron-3-ultra:free") is True
assert is_free_tier_model("Hermes-4-70B:free", "https://inference-api.nousresearch.com") is True
def test_empty_or_paid_model_is_not_free(self):
from agent.credits_tracker import is_free_tier_model
assert is_free_tier_model("") is False
assert is_free_tier_model("Hermes-4-405B") is False
def test_pricing_cache_peek_zero_priced_model(self, monkeypatch):
from agent.credits_tracker import is_free_tier_model
import hermes_cli.models as models_mod
# The picker keys the cache on the pre-/v1 root (get_pricing_for_provider
# strips a trailing /v1 before fetch_models_with_pricing).
monkeypatch.setattr(
models_mod,
"_pricing_cache",
{
"https://inference-api.nousresearch.com": {
"some/zero-priced": {"prompt": "0", "completion": "0"},
"some/paid": {"prompt": "0.000001", "completion": "0.000002"},
}
},
)
# The agent holds the /v1-suffixed URL (DEFAULT_NOUS_INFERENCE_URL) —
# the helper must normalize it down to the picker's cache key.
base = "https://inference-api.nousresearch.com/v1"
assert is_free_tier_model("some/zero-priced", base) is True
assert is_free_tier_model("some/paid", base) is False
# Pre-stripped and trailing-slash variants resolve to the same key.
assert is_free_tier_model("some/zero-priced", "https://inference-api.nousresearch.com/") is True
assert is_free_tier_model("some/zero-priced", "https://inference-api.nousresearch.com/v1/") is True
def test_cache_miss_is_not_free_and_no_fetch(self, monkeypatch):
from agent.credits_tracker import is_free_tier_model
import hermes_cli.models as models_mod
monkeypatch.setattr(models_mod, "_pricing_cache", {})
def _boom(*args, **kwargs): # any network attempt fails the test
raise AssertionError("is_free_tier_model must never hit the network")
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _boom)
assert is_free_tier_model("some/model", "https://inference-api.nousresearch.com/v1") is False
def test_exception_fails_open_to_false(self, monkeypatch):
from agent.credits_tracker import is_free_tier_model
import hermes_cli.models as models_mod
class _Exploding:
def get(self, *_a, **_kw):
raise RuntimeError("boom")
monkeypatch.setattr(models_mod, "_pricing_cache", _Exploding())
assert is_free_tier_model("some/model", "https://inference-api.nousresearch.com") is False
# ── Scenario 6: denominator none (uf is None) ────────────────────────────────
+2 -2
View File
@@ -668,8 +668,8 @@ def test_state_atomic_write_no_tmp_leftovers(curator_env):
c = curator_env["curator"]
c.save_state({"paused": True})
parent = c._state_file().parent
tmp_files = [p.name for p in parent.iterdir() if p.name.endswith(".tmp")]
assert tmp_files == []
for p in parent.iterdir():
assert not p.name.startswith(".curator_state_"), f"tmp leftover: {p.name}"
def test_state_preserves_last_report_path(curator_env):
-41
View File
@@ -12,7 +12,6 @@ from agent.display import (
set_tool_preview_max_len,
_render_inline_unified_diff,
_summarize_rendered_diff_sections,
_used_free_parallel,
render_edit_diff_with_delta,
)
@@ -172,46 +171,6 @@ class TestCuteToolMessagePreviewLength:
assert "[error]" not in line
class TestWebProviderLabel:
"""The free-path "Parallel search"/"Parallel fetch" verb labeling."""
def test_free_search_verb_is_parallel(self):
result = json.dumps({"success": True, "data": {"web": []}, "provider": "parallel"})
line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1, result=result)
assert "Parallel search" in line
assert "hello" in line
def test_paid_search_verb_is_plain(self):
result = json.dumps({"success": True, "data": {"web": [{"url": "u"}]}})
line = get_cute_tool_message("web_search", {"query": "hi"}, 0.1, result=result)
assert "Parallel" not in line
assert "search" in line
def test_missing_result_verb_is_plain(self):
line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1)
assert "Parallel" not in line
assert "search" in line
def test_helper_is_parallel_free_specific(self):
# Only Parallel's free MCP path marks results; nothing else does.
assert _used_free_parallel(json.dumps({"provider": "parallel"})) is True
assert _used_free_parallel(json.dumps({"provider": "exa"})) is False
assert _used_free_parallel(json.dumps({"provider": "firecrawl"})) is False
assert _used_free_parallel(json.dumps({"success": True, "data": {}})) is False
assert _used_free_parallel('not json') is False
assert _used_free_parallel(None) is False
def test_free_extract_verb_is_parallel(self):
result = json.dumps({"results": [{"url": "u", "content": "x"}], "provider": "parallel"})
line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result)
assert "Parallel fetch" in line
def test_paid_extract_verb_is_plain(self):
result = json.dumps({"results": [{"url": "u", "content": "x"}]})
line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result)
assert "Parallel" not in line
class TestEditDiffPreview:
def test_extract_edit_diff_for_patch(self):
diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}')
-87
View File
@@ -661,42 +661,6 @@ class TestClassifyApiError:
# Without "thinking" in the message, it shouldn't be thinking_signature
assert result.reason != FailoverReason.thinking_signature
def test_anthropic_thinking_blocks_cannot_be_modified(self):
"""Frozen-block mutation 400 (no 'signature' token) must route to
thinking_signature recovery, not hard-abort. Regression for the
real-world error: latest-assistant thinking blocks 'cannot be
modified' after upstream message mutation."""
e = MockAPIError(
"messages.73.content.10: `thinking` or `redacted_thinking` blocks "
"in the latest assistant message cannot be modified. These blocks "
"must remain as they were in the original response.",
status_code=400,
)
result = classify_api_error(e, provider="anthropic")
assert result.reason == FailoverReason.thinking_signature
assert result.retryable is True
def test_anthropic_thinking_cannot_be_modified_via_openrouter(self):
"""Same frozen-block error proxied through OpenRouter must also be
caught (provider is not gated)."""
e = MockAPIError(
"`thinking` or `redacted_thinking` blocks in the latest assistant "
"message cannot be modified.",
status_code=400,
)
result = classify_api_error(e, provider="openrouter")
assert result.reason == FailoverReason.thinking_signature
assert result.retryable is True
def test_400_cannot_be_modified_without_thinking_not_classified(self):
"""A 400 'cannot be modified' that has nothing to do with thinking
blocks must NOT be swept into thinking_signature recovery."""
e = MockAPIError(
"this field cannot be modified after creation", status_code=400,
)
result = classify_api_error(e, provider="anthropic", approx_tokens=0)
assert result.reason != FailoverReason.thinking_signature
def test_invalid_encrypted_content_classified_as_retryable_replay_failure(self):
body = {
"error": {
@@ -1000,57 +964,6 @@ class TestClassifyApiError:
assert result.reason == FailoverReason.format_error
assert result.retryable is False
def test_400_unsupported_max_tokens_param_not_context_overflow(self):
"""A GPT-5 model rejecting max_tokens must NOT be misclassified as
context overflow. The OpenAI error string contains the literal
'max_tokens' (a _CONTEXT_OVERFLOW_PATTERNS entry), so without the
request-validation guard it was routed into the compression loop,
re-sent with the same bad param, and ended in "Cannot compress
further". Regression for gpt-5-context-overflow-misclassification."""
msg = ("Unsupported parameter: 'max_tokens' is not supported with this "
"model. Use 'max_completion_tokens' instead.")
e = MockAPIError(
msg,
status_code=400,
body={"error": {"message": msg, "type": "invalid_request_error",
"code": "unsupported_parameter"}},
)
# Tiny context against a huge window — definitely not a real overflow.
result = classify_api_error(e, model="gpt-5.4",
approx_tokens=6962, context_length=1050000)
assert result.reason == FailoverReason.format_error
assert result.retryable is False
assert result.should_compress is False
def test_400_unknown_parameter_not_context_overflow(self):
"""'Unknown parameter' 400s are deterministic request-validation
failures, not overflows."""
e = MockAPIError(
"Unknown parameter: 'foo'.",
status_code=400,
body={"error": {"message": "Unknown parameter: 'foo'.",
"code": "unknown_parameter"}},
)
result = classify_api_error(e, approx_tokens=1000)
assert result.reason == FailoverReason.format_error
assert result.should_compress is False
def test_400_real_overflow_with_invalid_request_error_code_still_compresses(self):
"""Guard the guard: OpenAI stamps genuine context-overflow 400s with
the generic 'invalid_request_error' code. The request-validation guard
must NOT key off that code, or real overflows stop compressing."""
msg = ("This model's maximum context length is 128000 tokens, however "
"you requested 150000 tokens.")
e = MockAPIError(
msg,
status_code=400,
body={"error": {"message": msg, "type": "invalid_request_error"}},
)
result = classify_api_error(e, model="gpt-5.4",
approx_tokens=150000, context_length=128000)
assert result.reason == FailoverReason.context_overflow
assert result.should_compress is True
def test_422_format_error(self):
e = MockAPIError("Unprocessable Entity", status_code=422)
result = classify_api_error(e)
-138
View File
@@ -1,138 +0,0 @@
"""Regression guard: end-of-turn memory sync must not block the turn.
Before this fix, ``MemoryManager.sync_all`` / ``queue_prefetch_all`` looped
``provider.sync_turn`` / ``provider.queue_prefetch`` INLINE on the
turn-completion path. A provider making a blocking network/daemon call (a
misconfigured Hindsight daemon was observed blocking ~298s before failing)
held ``run_conversation`` open long after the user saw their response, so
every interface (CLI, TUI, gateway) kept the agent marked "running" for
minutes and any follow-up message triggered an aggressive interrupt that
dropped the message.
The fix dispatches provider work to a single-worker background executor.
``sync_all`` / ``queue_prefetch_all`` return immediately; the work completes
(or fails, logged) in the background. ``flush_pending`` provides a barrier
for session boundaries and deterministic tests. ``shutdown_all`` drains the
executor with a bounded timeout so a wedged provider can't hang teardown.
"""
import time
import pytest
from agent.memory_provider import MemoryProvider
from agent.memory_manager import MemoryManager
class _SlowProvider(MemoryProvider):
"""Provider whose sync/prefetch block, simulating a slow backend."""
_name = "slow"
def __init__(self, delay: float = 1.0):
self._delay = delay
self.sync_done = False
self.prefetch_done = False
@property
def name(self) -> str:
return self._name
def initialize(self, session_id: str = "", **kwargs) -> None:
pass
def is_available(self) -> bool:
return True
def system_prompt_block(self) -> str:
return ""
def prefetch(self, query, *, session_id: str = "") -> str:
return ""
def queue_prefetch(self, query, *, session_id: str = "") -> None:
time.sleep(self._delay)
self.prefetch_done = True
def sync_turn(self, user_content, assistant_content, *, session_id: str = "", messages=None) -> None:
time.sleep(self._delay)
self.sync_done = True
def get_tool_schemas(self):
return []
def handle_tool_call(self, tool_name, args, **kwargs) -> str:
return ""
def test_sync_all_does_not_block_on_slow_provider():
"""The crux of the fix: a slow provider must NOT stall the caller."""
mgr = MemoryManager()
mgr.add_provider(_SlowProvider(delay=2.0))
t0 = time.time()
mgr.sync_all("hi", "hey", session_id="s1")
mgr.queue_prefetch_all("hi", session_id="s1")
elapsed = time.time() - t0
# Provider blocks 2s per call inline; off-thread dispatch returns ~instantly.
assert elapsed < 0.5, f"turn-completion path blocked {elapsed:.2f}s"
def test_background_work_still_completes():
"""Dispatching off-thread must not silently drop the write."""
mgr = MemoryManager()
p = _SlowProvider(delay=0.1)
mgr.add_provider(p)
mgr.sync_all("hi", "hey", session_id="s1")
mgr.queue_prefetch_all("hi", session_id="s1")
assert mgr.flush_pending(timeout=10) is True
assert p.sync_done is True
assert p.prefetch_done is True
def test_flush_pending_no_executor_is_true():
"""flush_pending must be a no-op (return True) before any sync ran."""
mgr = MemoryManager()
assert mgr.flush_pending(timeout=1) is True
def test_no_providers_does_not_create_executor():
"""Builtin-only / no-provider sessions must not spawn an executor."""
mgr = MemoryManager()
mgr.sync_all("hi", "hey")
mgr.queue_prefetch_all("hi")
assert mgr._sync_executor is None
def test_shutdown_all_is_bounded_with_wedged_provider():
"""A provider that never returns must not hang teardown."""
mgr = MemoryManager()
mgr.add_provider(_SlowProvider(delay=30.0))
mgr.sync_all("hi", "hey")
t0 = time.time()
mgr.shutdown_all()
elapsed = time.time() - t0
# Bounded by _SYNC_DRAIN_TIMEOUT_S (5s) plus a little slack.
assert elapsed < 8.0, f"shutdown blocked {elapsed:.1f}s on wedged provider"
def test_writes_are_serialized_in_order():
"""Single-worker executor must preserve turn ordering (N before N+1)."""
order = []
class _OrderProvider(_SlowProvider):
_name = "order"
def sync_turn(self, user_content, assistant_content, *, session_id="", messages=None):
order.append(user_content)
mgr = MemoryManager()
mgr.add_provider(_OrderProvider(delay=0.0))
for i in range(5):
mgr.sync_all(f"turn-{i}", "resp", session_id="s1")
assert mgr.flush_pending(timeout=10) is True
assert order == [f"turn-{i}" for i in range(5)]
+2 -5
View File
@@ -229,7 +229,6 @@ class TestMemoryManager:
mgr.add_provider(p2)
mgr.queue_prefetch_all("next turn")
mgr.flush_pending(timeout=5)
assert p1.queued_prefetches == ["next turn"]
assert p2.queued_prefetches == ["next turn"]
@@ -241,7 +240,6 @@ class TestMemoryManager:
mgr.add_provider(p2)
mgr.sync_all("user msg", "assistant msg")
mgr.flush_pending(timeout=5)
assert p1.synced_turns == [("user msg", "assistant msg")]
assert p2.synced_turns == [("user msg", "assistant msg")]
@@ -255,7 +253,7 @@ class TestMemoryManager:
]
mgr.sync_all("user msg", "assistant msg", session_id="sess-1", messages=messages)
mgr.flush_pending(timeout=5)
assert p.synced_turns == [("user msg", "assistant msg", "sess-1", messages)]
def test_sync_all_omits_messages_for_legacy_provider(self):
@@ -264,7 +262,7 @@ class TestMemoryManager:
mgr.add_provider(p)
mgr.sync_all("user msg", "assistant msg", messages=[{"role": "tool"}])
mgr.flush_pending(timeout=5)
assert p.synced_turns == [("user msg", "assistant msg")]
def test_sync_failure_doesnt_block_others(self):
@@ -277,7 +275,6 @@ class TestMemoryManager:
mgr.add_provider(p2)
mgr.sync_all("user", "assistant")
mgr.flush_pending(timeout=5)
# p1 failed but p2 still synced
assert p2.synced_turns == [("user", "assistant")]
@@ -179,7 +179,6 @@ def test_sync_all_propagates_session_id_to_providers():
p = _RecordingProvider()
mm.add_provider(p)
mm.sync_all("hello", "world", session_id="sess-42")
mm.flush_pending(timeout=5)
assert p.sync_calls == [
{"user": "hello", "asst": "world", "session_id": "sess-42"}
]
@@ -190,7 +189,6 @@ def test_queue_prefetch_all_propagates_session_id_to_providers():
p = _RecordingProvider()
mm.add_provider(p)
mm.queue_prefetch_all("next query", session_id="sess-42")
mm.flush_pending(timeout=5)
assert p.queue_calls == [{"query": "next query", "session_id": "sess-42"}]
-53
View File
@@ -220,59 +220,6 @@ class TestDefaultContextLengths:
f"{model_id}: expected {expected_ctx}, got {actual}"
)
def test_openrouter_live_metadata_beats_hardcoded_catchall(self):
"""OpenRouter-routed slugs resolve via the live OR catalog before the
hardcoded family catch-all.
Regression for the claude-fable-5 under-report: a brand-new Anthropic
slug that is absent from models.dev but present in OpenRouter's live
catalog (with a 1M window) used to fall through to the generic
``"claude": 200000`` entry, because the step-6 OR fallback was gated on
``not effective_provider`` and ``effective_provider`` is "openrouter"
for any OpenRouter selection. The dedicated step-5 OR branch must read
the live value instead.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
or_url = "https://openrouter.ai/api/v1"
live = {
"anthropic/claude-fable-5": {"context_length": 1_000_000},
"anthropic/claude-haiku-4.5": {"context_length": 200_000},
}
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value=live), \
mock_patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None), \
mock_patch("agent.models_dev.lookup_models_dev_context", return_value=None):
# The bug: would have returned 200_000 via the "claude" catch-all.
assert get_model_context_length(
"anthropic/claude-fable-5", base_url=or_url, provider="openrouter"
) == 1_000_000
# A genuinely-200k model still resolves to its real OR value — the
# fix reads per-model context, it does not blanket-bump to 1M.
assert get_model_context_length(
"anthropic/claude-haiku-4.5", base_url=or_url, provider="openrouter"
) == 200_000
def test_openrouter_kimi_32k_underreport_still_guarded(self):
"""The live OR branch keeps the Kimi-family 32k underreport guard:
a bogus 32768 from OpenRouter for a Kimi slug must NOT win it falls
through to the hardcoded default instead.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
or_url = "https://openrouter.ai/api/v1"
live = {"moonshotai/kimi-k2.6": {"context_length": 32768}}
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value=live), \
mock_patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None), \
mock_patch("agent.models_dev.lookup_models_dev_context", return_value=None):
ctx = get_model_context_length(
"moonshotai/kimi-k2.6", base_url=or_url, provider="openrouter"
)
assert ctx != 32768, "Kimi 32k OR underreport must not be accepted"
# =========================================================================
# Codex OAuth context-window resolution (provider="openai-codex")
-48
View File
@@ -276,54 +276,6 @@ class TestBuildSkillsSystemPrompt:
# "search" should appear only once per category
assert result.count("- search") == 1
def test_compact_categories_demoted_to_names_only(self, monkeypatch, tmp_path):
"""Posture-driven demotion keeps every skill NAME visible.
Demoted categories lose their descriptions, never their entries
full pruning caused silent capability loss in a real workflow
(agent-created skills are the model's project memory, and models
don't rediscover them via skills_list once the index goes quiet).
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
for cat, name in (("social-media", "tweet-stuff"), ("github", "pr-review")):
d = tmp_path / "skills" / cat / name
d.mkdir(parents=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Does {name} things\n---\n"
)
result = build_skills_system_prompt(
compact_categories=frozenset({"social-media"})
)
# Coding-adjacent category keeps its full entry.
assert "pr-review" in result and "Does pr-review things" in result
# Demoted category: name stays visible, description is dropped.
assert "tweet-stuff" in result
assert "Does tweet-stuff things" not in result
assert "social-media [names only]" in result
# Disclosure note explains the demotion and how to load.
assert "skill_view" in result
def test_compact_categories_demote_nested_and_miss_cache_separately(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
d = tmp_path / "skills" / "social-media" / "twitter" / "thread-writer"
d.mkdir(parents=True)
(d / "SKILL.md").write_text(
"---\nname: thread-writer\ndescription: Write threads\n---\n"
)
# Nested category ("social-media/twitter") demoted via its parent:
# name visible, description gone.
compact = build_skills_system_prompt(
compact_categories=frozenset({"social-media"})
)
assert "thread-writer" in compact
assert "Write threads" not in compact
# Unfiltered call must not be served from the compacted cache entry.
full = build_skills_system_prompt()
assert "Write threads" in full
def test_excludes_incompatible_platform_skills(self, monkeypatch, tmp_path):
"""Skills with platforms: [macos] should not appear on Linux."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+11 -18
View File
@@ -1,9 +1,9 @@
"""Regression coverage for #35344: a resumed session must not let a stale
historical task snapshot from an inherited compaction handoff hijack the reply to a
``## 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 historical task snapshot described task A. The lineage was resumed later and
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.
@@ -16,15 +16,14 @@ named reverse-signal verbs. Two invariants guard the resume path specifically:
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
2. The current handoff prefix contains an unambiguous "latest message wins /
discard stale historical task" rule, so an unrelated new ask is privileged over
the inherited task snapshot.
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 (
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
LEGACY_SUMMARY_PREFIX,
ContextCompressor,
@@ -49,17 +48,13 @@ _OLD_CONFLICTING_PREFIX = (
def test_latest_message_wins_over_inherited_active_task():
"""The handoff must explicitly privilege the latest user message over a
stale historical task snapshot the core #35344 contract."""
stale ``## Active Task`` — the core #35344 contract."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert HISTORICAL_TASK_HEADING.lower() 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
# The "consistent -> use as background" carveout licensed stale-task
# resumption on topic overlap (#41607, #38364) — it must stay gone.
assert "you may use the summary as background" not in lower
assert "topic overlap" in lower
def test_no_resume_exactly_directive_can_hijack():
@@ -74,7 +69,7 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
prefix when re-normalized on re-compaction so the "resume exactly"
directive cannot survive into a resumed session."""
stale_body = (
f"{HISTORICAL_TASK_HEADING}\n"
"## Active Task\n"
"User asked: 'Migrate the billing module to Stripe'\n\n"
"## Goal\nMigrate billing.\n"
)
@@ -91,15 +86,13 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
# current latest-message-wins framing.
assert "resume exactly" not in renormalized.lower()
assert renormalized.startswith(SUMMARY_PREFIX)
assert ("wins" in renormalized.lower()
or "priority" in renormalized.lower()
or "supersede" in renormalized.lower())
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} {HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"
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
@@ -114,7 +107,7 @@ def test_inherited_handoff_detected_in_resumed_protected_head():
Task read as live intent)."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
{"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?"},
]
@@ -136,7 +129,7 @@ def test_historical_prefixed_handoff_detected_and_stripped():
stale 'resume exactly' text as a fresh turn."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B"},
]
@@ -1,111 +0,0 @@
"""Stream read timeout must never preempt the stale-stream detector.
Reasoning models (e.g. Opus) routinely pause mid-stream for minutes during
extended thinking. The stale-stream detector is deliberately scaled up to
tolerate this (180s base, raised to 240s/300s for large contexts). The httpx
socket read timeout, however, defaulted to a flat 120s for cloud providers and
fired *first* tearing down a healthy reasoning stream before the stale
detector (which owns retry + diagnostics) could act.
These tests pin the invariant: for a cloud provider on the default read
timeout, the httpx socket read timeout is floored at the stale-stream timeout
so it can never fire before the detector. They mirror the inline logic in
``agent/chat_completion_helpers.py`` (the real builder lives deep inside a
worker thread, so like ``test_local_stream_timeout.py`` the resolution is
reproduced here rather than driven end-to-end).
"""
import os
import pytest
from agent.model_metadata import is_local_endpoint
def _resolve_stale_timeout(base_url, est_tokens, stale_base=180.0):
"""Mirror of the stale-stream detector resolution."""
if stale_base == 180.0 and base_url and is_local_endpoint(base_url):
return float("inf") # detector disabled for local providers
if est_tokens > 100_000:
return max(stale_base, 300.0)
if est_tokens > 50_000:
return max(stale_base, 240.0)
return stale_base
def _resolve_read_timeout(base_url, stale_timeout, base_timeout=1800.0):
"""Mirror of the httpx socket read-timeout builder (cloud branch)."""
read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
if read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
read_timeout = base_timeout
elif (
read_timeout == 120.0
and stale_timeout is not None
and stale_timeout != float("inf")
and stale_timeout > read_timeout
):
read_timeout = stale_timeout
return read_timeout
CLOUD_URLS = [
"https://api.githubcopilot.com",
"https://api.openai.com",
"https://openrouter.ai/api",
"https://api.anthropic.com",
]
class TestCloudReadTimeoutFloor:
@pytest.fixture(autouse=True)
def _clear_env(self):
with pytest.MonkeyPatch.context() as mp:
mp.delenv("HERMES_STREAM_READ_TIMEOUT", raising=False)
yield
@pytest.mark.parametrize("base_url", CLOUD_URLS)
@pytest.mark.parametrize("est_tokens", [0, 10_000, 60_000, 150_000])
def test_read_timeout_never_below_stale(self, base_url, est_tokens):
"""Core invariant: the socket read timeout >= the stale detector."""
stale = _resolve_stale_timeout(base_url, est_tokens)
read = _resolve_read_timeout(base_url, stale)
assert read >= stale
@pytest.mark.parametrize("base_url", CLOUD_URLS)
def test_small_context_floored_to_stale_base(self, base_url):
"""Reported case: ~120s timeouts on Copilot are raised to the 180s base."""
stale = _resolve_stale_timeout(base_url, est_tokens=37_000)
read = _resolve_read_timeout(base_url, stale)
assert read == 180.0
@pytest.mark.parametrize("base_url", CLOUD_URLS)
def test_large_context_tracks_scaled_stale(self, base_url):
"""Big contexts scale the stale detector; the read timeout follows."""
assert _resolve_read_timeout(base_url, _resolve_stale_timeout(base_url, 60_000)) == 240.0
assert _resolve_read_timeout(base_url, _resolve_stale_timeout(base_url, 150_000)) == 300.0
def test_user_override_is_respected(self):
"""An explicit HERMES_STREAM_READ_TIMEOUT is never overridden by the floor."""
with pytest.MonkeyPatch.context() as mp:
mp.setenv("HERMES_STREAM_READ_TIMEOUT", "90")
stale = _resolve_stale_timeout("https://api.githubcopilot.com", est_tokens=0)
assert _resolve_read_timeout("https://api.githubcopilot.com", stale) == 90.0
class TestLocalUnaffected:
@pytest.fixture(autouse=True)
def _clear_env(self):
with pytest.MonkeyPatch.context() as mp:
mp.delenv("HERMES_STREAM_READ_TIMEOUT", raising=False)
yield
def test_local_still_raised_to_base(self):
"""Local providers keep their existing behavior (raise to base timeout)."""
stale = _resolve_stale_timeout("http://localhost:11434", est_tokens=0)
assert stale == float("inf") # detector disabled for local
read = _resolve_read_timeout("http://localhost:11434", stale)
assert read == 1800.0 # not clamped by inf
def test_stale_none_falls_back_to_default(self):
"""If the stale value is unresolved, the read timeout keeps its default."""
assert _resolve_read_timeout("https://api.githubcopilot.com", None) == 120.0
+2 -56
View File
@@ -18,13 +18,7 @@ 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 (
HISTORICAL_IN_PROGRESS_HEADING,
HISTORICAL_PENDING_ASKS_HEADING,
HISTORICAL_REMAINING_WORK_HEADING,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
from agent.context_compressor import SUMMARY_PREFIX
def test_no_resume_exactly_directive():
@@ -36,22 +30,8 @@ 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
assert HISTORICAL_TASK_HEADING.lower() in lower
assert HISTORICAL_PENDING_ASKS_HEADING.lower() in lower
assert HISTORICAL_REMAINING_WORK_HEADING.lower() in lower
# Must have an explicit conflict-resolution rule.
assert "wins" in lower or "supersede" in lower or "discard" in lower or "priority" in lower
def test_handoff_sections_are_framed_as_historical():
"""The summary headings referenced in the prefix must sound historical,
not like live instructions for the current turn."""
lower = SUMMARY_PREFIX.lower()
assert "## active task" not in lower
assert "## pending user asks" not in lower
assert "## remaining work" not in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
assert HISTORICAL_IN_PROGRESS_HEADING.lower() in lower
assert "wins" in lower or "supersede" in lower or "discard" in lower
def test_reverse_signals_called_out():
@@ -80,37 +60,3 @@ def test_memory_authority_preserved():
assert "MEMORY.md" in SUMMARY_PREFIX
assert "USER.md" in SUMMARY_PREFIX
assert "authoritative" in SUMMARY_PREFIX
def test_no_background_consistency_carveout():
"""The "consistent → use as background" carveout licensed stale-task
resumption on topic overlap (#41607, #38364, #42812). It must stay gone,
and the prefix must explicitly neutralize topic overlap."""
lower = SUMMARY_PREFIX.lower()
assert "you may use the summary as background" not in lower
assert "topic overlap" in lower
def test_replaced_prefixes_are_frozen_for_renormalization():
"""Every retired SUMMARY_PREFIX must be frozen into
_HISTORICAL_SUMMARY_PREFIXES, otherwise summaries persisted by older
builds lose detection/renormalization after an upgrade. The carveout-era
prefix is the latest retiree."""
from agent.context_compressor import (
_HISTORICAL_SUMMARY_PREFIXES,
ContextCompressor,
)
carveout_era = [
p for p in _HISTORICAL_SUMMARY_PREFIXES
if "you may use the summary as background" in p
]
assert carveout_era, "carveout-era prefix missing from frozen tuple"
# The live prefix must never be one of the frozen ones.
assert SUMMARY_PREFIX not in _HISTORICAL_SUMMARY_PREFIXES
# Detection + strip must work for every frozen prefix.
for old_prefix in _HISTORICAL_SUMMARY_PREFIXES:
content = old_prefix + "\n## Summary body"
assert ContextCompressor._is_context_summary_content(content)
stripped = ContextCompressor._strip_summary_prefix(content)
assert not stripped.startswith(old_prefix)
-41
View File
@@ -55,44 +55,3 @@ class TestContextFileCwd:
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert _captured_context_cwd(_make_agent()) == tmp_path
def _stable_prompt(agent):
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value=""),
):
return build_system_prompt_parts(agent)["stable"]
class TestCodingContextBlock:
def test_injected_when_active(self, monkeypatch, tmp_path):
import subprocess
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
stable = _stable_prompt(agent)
assert "coding agent" in stable
assert "Workspace" in stable
def test_absent_when_off(self, monkeypatch, tmp_path):
import subprocess
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
# Drive the real path: force the resolved mode to "off" via config.
with patch("agent.coding_context._coding_mode", return_value="off"):
stable = _stable_prompt(agent)
assert "coding agent" not in stable
def test_absent_without_tools(self, monkeypatch, tmp_path):
import subprocess
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
agent = _make_agent(valid_tool_names=[], platform="cli")
assert "coding agent" not in _stable_prompt(agent)
-26
View File
@@ -192,32 +192,6 @@ def test_custom_endpoint_models_api_pricing_is_supported(monkeypatch):
assert float(entry.output_cost_per_million) == 2.0
def test_nous_portal_pricing_preserves_vendor_prefixed_model_ids(monkeypatch):
seen = {}
def _fake_fetch_endpoint_model_metadata(base_url, api_key=None):
seen["base_url"] = base_url
return {
"openai/gpt-5.5-pro": {
"pricing": {
"prompt": "0.000025",
"completion": "0.000125",
}
}
}
monkeypatch.setattr(
"agent.usage_pricing.fetch_endpoint_model_metadata",
_fake_fetch_endpoint_model_metadata,
)
entry = get_pricing_entry("openai/gpt-5.5-pro", provider="nous")
assert seen["base_url"] == "https://inference-api.nousresearch.com/v1"
assert float(entry.input_cost_per_million) == 25.0
assert float(entry.output_cost_per_million) == 125.0
def test_deepseek_v4_pro_pricing_entry_exists():
"""Regression test: deepseek-v4-pro must have a pricing entry.
@@ -196,40 +196,6 @@ class TestRunTurn:
# turn_id propagated for downstream session-DB linkage
assert r.turn_id == "turn-fake-001"
def test_token_usage_notification_is_captured(self):
client = FakeClient()
client.queue_notification(
"thread/tokenUsage/updated",
threadId="thread-fake-001",
turnId="turn-fake-001",
tokenUsage={
"last": {
"totalTokens": 130,
"inputTokens": 80,
"cachedInputTokens": 20,
"outputTokens": 25,
"reasoningOutputTokens": 5,
},
"total": {
"totalTokens": 500,
"inputTokens": 300,
"cachedInputTokens": 75,
"outputTokens": 100,
"reasoningOutputTokens": 25,
},
"modelContextWindow": 200000,
},
)
client.queue_notification(
"turn/completed",
threadId="t",
turn={"id": "tu1", "status": "completed", "error": None},
)
r = make_session(client).run_turn("hi", turn_timeout=2.0)
assert r.token_usage_last["totalTokens"] == 130
assert r.token_usage_total["totalTokens"] == 500
assert r.model_context_window == 200000
def test_rich_content_turn_is_collapsed_to_text_payload(self):
client = FakeClient()
client.queue_notification(