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:
@@ -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"]))
|
||||
@@ -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}
|
||||
|
||||
@@ -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": []})
|
||||
@@ -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,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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
@@ -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"}]
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user