Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ def main():
if msg.get("method") == "workspace/didChangeWatchedFiles":
continue
if msg.get("method") in ("textDocument/didOpen", "textDocument/didChange"):
if msg.get("method") in {"textDocument/didOpen", "textDocument/didChange"}:
params = msg.get("params") or {}
td = params.get("textDocument") or {}
uri = td.get("uri", "")
@@ -87,10 +87,10 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
cmd = captured["cmd"]
assert "pyright" in cmd
# Should not blow up when extra_pkgs is omitted/None
install_targets = [c for c in cmd if not c.startswith("-") and c not in (
install_targets = [c for c in cmd if not c.startswith("-") and c not in {
"install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent),
"/usr/bin/npm",
)]
}]
assert install_targets == ["pyright"]
+9 -2
View File
@@ -157,6 +157,13 @@ class TestBuildAnthropicClient:
class TestReadClaudeCodeCredentials:
@pytest.fixture(autouse=True)
def no_keychain(self, monkeypatch):
monkeypatch.setattr(
"agent.anthropic_adapter._read_claude_code_credentials_from_keychain",
lambda: None,
)
def test_reads_valid_credentials(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
@@ -1651,7 +1658,7 @@ class TestThinkingBlockSignatureManagement:
_, result = convert_messages_to_anthropic(messages)
assistant = next(m for m in result if m["role"] == "assistant")
for block in assistant["content"]:
if block.get("type") in ("thinking", "redacted_thinking"):
if block.get("type") in {"thinking", "redacted_thinking"}:
assert "cache_control" not in block
def test_thinking_stripped_from_merged_consecutive_assistants(self):
@@ -1741,7 +1748,7 @@ class TestThinkingBlockSignatureManagement:
# First two: no thinking blocks
for a in assistants[:2]:
assert not any(
b.get("type") in ("thinking", "redacted_thinking")
b.get("type") in {"thinking", "redacted_thinking"}
for b in a["content"]
if isinstance(b, dict)
)
+187 -4
View File
@@ -673,6 +673,8 @@ class TestGetTextAuxiliaryClient:
def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self):
with patch("agent.auxiliary_client._resolve_custom_runtime",
return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \
patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=None), \
patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client()
@@ -923,6 +925,44 @@ class TestIsPaymentError:
exc = Exception("connection reset")
assert _is_payment_error(exc) is False
# ── Daily / monthly quota exhaustion (#26803) ────────────────────────────
def test_429_quota_exceeded(self):
"""Cloud provider quota exhaustion (e.g. Vertex AI) is a payment error."""
exc = Exception("RESOURCE_EXHAUSTED: quota exceeded for project")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_too_many_tokens_per_day(self):
"""Bedrock / LiteLLM daily token limit is a payment error."""
exc = Exception("Too many tokens per day: 1000000 used, 1000000 limit")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_daily_limit_phrase(self):
"""Generic 'daily limit' phrasing is a payment error."""
exc = Exception("You have exceeded your daily limit.")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_resource_exhausted_grpc(self):
"""Vertex AI gRPC RESOURCE_EXHAUSTED maps to payment error."""
exc = Exception("resource exhausted")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_daily_quota_phrase(self):
"""'daily quota' phrasing is a payment error."""
exc = Exception("Daily quota of 500 requests reached.")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_transient_rate_limit_not_quota(self):
"""Transient 429 rate limit without quota keywords is NOT a payment error."""
exc = Exception("Rate limit exceeded. Retry after 10s.")
exc.status_code = 429
assert _is_payment_error(exc) is False
class TestIsRateLimitError:
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
@@ -1111,6 +1151,140 @@ class TestCallLlmPaymentFallback:
# Fallback client should have been used
assert fallback_client.chat.completions.create.called
class TestAuxiliaryFallbackLayering:
"""Explicit-provider users get layered fallback: configured_chain → main agent → warn."""
def _make_payment_err(self):
exc = Exception("Payment Required: insufficient credits")
exc.status_code = 402
return exc
def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog):
"""When a user has fallback_chain configured, it's tried BEFORE the main agent model."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
chain_client = MagicMock()
chain_client.chat.completions.create.return_value = MagicMock(choices=[
MagicMock(message=MagicMock(content="from configured chain"))
])
main_called = MagicMock()
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "glm-4v-flash")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("glm", "glm-4v-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(chain_client, "gpt-4o-mini", "fallback_chain[0](openai)")), \
patch("agent.auxiliary_client._try_main_agent_model_fallback",
side_effect=main_called):
result = call_llm(
task="vision",
messages=[{"role": "user", "content": "hello"}],
)
assert chain_client.chat.completions.create.called
# Main agent fallback should NOT have been consulted — chain succeeded first
main_called.assert_not_called()
def test_explicit_provider_falls_back_to_main_when_chain_exhausted(self, monkeypatch):
"""If configured fallback_chain returns nothing, main agent model is tried next."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
main_client = MagicMock()
main_client.chat.completions.create.return_value = MagicMock(choices=[
MagicMock(message=MagicMock(content="from main agent"))
])
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "glm-4v-flash")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("glm", "glm-4v-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")), \
patch("agent.auxiliary_client._try_main_agent_model_fallback",
return_value=(main_client, "claude-sonnet-4", "main-agent(openrouter)")):
result = call_llm(
task="vision",
messages=[{"role": "user", "content": "hello"}],
)
assert main_client.chat.completions.create.called
def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog):
"""When chain AND main model both fail, a user-visible warning fires before re-raise."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "glm-4v-flash")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("glm", "glm-4v-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")), \
patch("agent.auxiliary_client._try_main_agent_model_fallback",
return_value=(None, None, "")), \
caplog.at_level("WARNING", logger="agent.auxiliary_client"):
with pytest.raises(Exception, match="Payment Required"):
call_llm(
task="vision",
messages=[{"role": "user", "content": "hello"}],
)
assert any(
"all fallbacks exhausted" in r.message for r in caplog.records
), f"Expected exhaustion warning, got: {[r.message for r in caplog.records]}"
class TestTryMainAgentModelFallback:
"""_try_main_agent_model_fallback resolves the user's main provider+model as a safety net."""
def test_returns_none_when_main_provider_is_auto(self):
from agent.auxiliary_client import _try_main_agent_model_fallback
with patch("agent.auxiliary_client._read_main_provider", return_value="auto"), \
patch("agent.auxiliary_client._read_main_model", return_value="some-model"):
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
assert client is None and model is None and label == ""
def test_returns_none_when_failed_provider_equals_main(self):
"""If the thing that failed IS the main model, no point retrying it."""
from agent.auxiliary_client import _try_main_agent_model_fallback
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"):
client, model, label = _try_main_agent_model_fallback("openrouter", task="vision")
assert client is None and label == ""
def test_resolves_main_provider_client(self):
from agent.auxiliary_client import _try_main_agent_model_fallback
fake_client = MagicMock()
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \
patch("agent.auxiliary_client._is_provider_unhealthy", return_value=False), \
patch("agent.auxiliary_client.resolve_provider_client",
return_value=(fake_client, "anthropic/claude-sonnet-4")):
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
assert client is fake_client
assert model == "anthropic/claude-sonnet-4"
assert label == "main-agent(openrouter)"
def test_skips_when_main_provider_is_unhealthy(self):
from agent.auxiliary_client import _try_main_agent_model_fallback
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \
patch("agent.auxiliary_client._is_provider_unhealthy", return_value=True):
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
assert client is None
# ---------------------------------------------------------------------------
# Gate: _resolve_api_key_provider must skip anthropic when not configured
# ---------------------------------------------------------------------------
@@ -2349,10 +2523,13 @@ class TestAuxiliaryClientPoisonedCacheEviction:
def test_call_llm_evicts_on_connection_error_with_explicit_provider(self):
"""Connection error on an explicit provider must drop the cached client.
This is the exact reporter scenario: ``auxiliary.compression.provider:
main`` (resolves to ``openai-codex``) → no fallback chain runs (not
auto), but the cached client was poisoned by a prior timeout and must
be evicted so the next call rebuilds.
Reporter scenario: ``auxiliary.compression.provider: main`` (resolves
to ``openai-codex``). After #26803, capacity errors (payment/quota/
connection) DO trigger fallback even on explicit providers — so we
also stub ``_try_payment_fallback`` to ``(None, None, "")`` so the
connection error re-raises after eviction instead of escaping into
a real network call. The contract under test is cache eviction,
not the fallback gate.
"""
from agent.auxiliary_client import _client_cache, _client_cache_lock
@@ -2372,6 +2549,9 @@ class TestAuxiliaryClientPoisonedCacheEviction:
), patch(
"agent.auxiliary_client._get_cached_client",
return_value=(poisoned, "gpt-5.5"),
), patch(
"agent.auxiliary_client._try_payment_fallback",
return_value=(None, None, ""),
):
with pytest.raises(ConnectionError):
call_llm(
@@ -2405,6 +2585,9 @@ class TestAuxiliaryClientPoisonedCacheEviction:
), patch(
"agent.auxiliary_client._get_cached_client",
return_value=(poisoned, "gpt-5.5"),
), patch(
"agent.auxiliary_client._try_payment_fallback",
return_value=(None, None, ""),
):
with pytest.raises(ConnectionError):
await async_call_llm(
+1 -1
View File
@@ -371,7 +371,7 @@ class TestResolveVisionMainFirst:
provider, client, model = resolve_vision_provider_client()
assert client is fallback_client
assert provider in ("openrouter", "nous")
assert provider in {"openrouter", "nous"}
def test_explicit_provider_override_still_wins(self):
"""Explicit config override bypasses main-first policy."""
+3 -3
View File
@@ -1046,7 +1046,7 @@ class TestCompressWithClient:
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
def test_double_collision_merges_summary_into_tail(self):
@@ -1087,7 +1087,7 @@ class TestCompressWithClient:
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
# The summary text should be merged into the first tail message
@@ -1164,7 +1164,7 @@ class TestCompressWithClient:
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
# The summary should be merged into the first tail message (assistant at index 5)
+184
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import base64
import json
import time
from datetime import datetime, timezone
import pytest
@@ -14,6 +16,14 @@ def _write_auth_store(tmp_path, payload: dict) -> None:
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
def _jwt_with_claims(claims: dict) -> str:
def _part(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
def test_fill_first_selection_skips_recently_exhausted_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
@@ -510,6 +520,180 @@ def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch):
assert entry.agent_key == "agent-key"
def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
expires_at = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc).isoformat()
token = _jwt_with_claims({
"sub": "test-user",
"scope": ["inference:invoke", "inference:mint_agent_key"],
"exp": int(time.time() + 3600),
})
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": "inference:invoke inference:mint_agent_key",
"access_token": token,
"refresh_token": "refresh-token",
"expires_at": expires_at,
"agent_key": token,
"agent_key_expires_at": expires_at,
}
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("nous")
entry = pool.select()
assert entry is not None
assert entry.source == "device_code"
assert entry.agent_key == token
assert entry.runtime_api_key == token
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
pool_entry = auth_payload["credential_pool"]["nous"][0]
assert pool_entry["agent_key"] == token
assert pool_entry["agent_key_expires_at"] == expires_at
def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared"))
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": "inference:mint_agent_key",
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_at": "2026-03-24T12:00:00+00:00",
"agent_key": "agent-key",
"agent_key_expires_at": "2026-03-24T13:30:00+00:00",
}
},
},
)
from agent.credential_pool import PooledCredential, load_pool
from hermes_cli import auth as auth_mod
from hermes_cli.auth import AuthError
refresh_calls = {"count": 0}
def _terminal_refresh_failure(*_args, **_kwargs):
refresh_calls["count"] += 1
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
pool = load_pool("nous")
selected = pool.select()
assert selected is not None
assert selected.source == "device_code"
pool.add_entry(PooledCredential.from_dict("nous", {
"id": "legacy-seeded",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "old-access-token",
"refresh_token": "old-refresh-token",
"agent_key": "old-agent-key",
}))
pool.add_entry(PooledCredential.from_dict("nous", {
"id": "manual-key",
"source": "manual",
"auth_type": "api_key",
"access_token": "manual-nous-key",
}))
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _terminal_refresh_failure)
assert pool.try_refresh_current() is None
assert [entry.id for entry in pool.entries()] == ["manual-key"]
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
nous_state = auth_payload["providers"]["nous"]
assert not nous_state.get("refresh_token")
assert not nous_state.get("access_token")
assert not nous_state.get("agent_key")
assert nous_state["last_auth_error"]["code"] == "invalid_grant"
assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"]
assert pool.try_refresh_current() is None
assert refresh_calls["count"] == 1
def test_load_pool_removes_nous_device_code_when_singleton_quarantined(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"last_auth_error": {"code": "invalid_grant"},
}
},
"credential_pool": {
"nous": [
{
"id": "seeded-current",
"source": "device_code",
"auth_type": "oauth",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
"agent_key": "stale-agent",
},
{
"id": "seeded-legacy",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "older-stale-access",
},
{
"id": "manual-key",
"source": "manual",
"auth_type": "api_key",
"access_token": "manual-nous-key",
},
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("nous")
assert [entry.id for entry in pool.entries()] == ["manual-key"]
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"]
def test_load_pool_removes_stale_file_backed_singleton_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
@@ -191,7 +191,7 @@ class TestDeepSeekAnthropicPreservesThinking:
if not isinstance(m.get("content"), list):
continue
for b in m["content"]:
if isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"):
if isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}:
assert "cache_control" not in b
def test_openai_compat_deepseek_base_is_not_matched(self) -> None:
+10
View File
@@ -746,6 +746,16 @@ class TestGetModelContextLength:
mock_fetch.return_value = {}
assert get_model_context_length("qwen3-coder") == 262144
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen3_6_plus_context_length(self, mock_fetch):
"""qwen3.6-plus has a 1M context window, not the generic 128K Qwen default."""
mock_fetch.return_value = {}
assert get_model_context_length("qwen3.6-plus") == 1048576
# Provider-prefixed variants must resolve to the same explicit entry
# via the longest-substring fallback (no portal/OR cache available).
assert get_model_context_length("qwen/qwen3.6-plus") == 1048576
assert get_model_context_length("dashscope/qwen3.6-plus") == 1048576
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen_generic_context_length(self, mock_fetch):
"""Generic qwen models still get the 128K default."""
+24
View File
@@ -100,6 +100,30 @@ class TestParseResponse:
)
assert r is None
def test_block_action_without_message_uses_default(self):
"""Block is honored even when message/reason is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
def test_block_decision_without_reason_uses_default(self):
"""Block is honored even when reason/message is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"decision": "block"}')
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
def test_block_action_empty_message_uses_default(self):
"""Empty string message falls back to default, not empty string."""
r = shell_hooks._parse_response(
"pre_tool_call", '{"action": "block", "message": ""}',
)
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
def test_block_action_non_string_message_uses_default(self):
"""Non-string message (e.g. integer) falls back to default."""
r = shell_hooks._parse_response(
"pre_tool_call", '{"action": "block", "message": 42}',
)
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
# ── _serialize_payload ────────────────────────────────────────────────────
@@ -241,3 +241,58 @@ class TestSpawnEnvIsolation:
assert captured["env"].get("CODEX_HOME") == "/tmp/profile/codex"
# And HOME still passes through unchanged
assert captured["env"].get("HOME") == "/users/alice"
def test_kanban_worker_adds_only_kanban_writable_root(self, monkeypatch):
"""Codex-runtime Kanban workers need to write board state outside
their scratch/worktree workspace, but should not fall back to
danger-full-access. Hermes passes a narrow app-server config override
for the Kanban root only.
"""
import subprocess
from agent.transports import codex_app_server as cas
captured = {}
class FakePopen:
def __init__(self, cmd, *args, **kwargs):
captured["cmd"] = list(cmd)
captured["env"] = kwargs.get("env", {}).copy()
self.stdin = None
self.stdout = None
self.stderr = None
self.pid = 1
self.returncode = None
def poll(self):
return None
def terminate(self):
pass
def wait(self, timeout=None):
return 0
def kill(self):
pass
monkeypatch.setattr(subprocess, "Popen", FakePopen)
monkeypatch.setenv("HOME", "/users/alice")
monkeypatch.setenv("HERMES_HOME", "/users/alice/.hermes/profiles/backend-worker")
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_smoke")
monkeypatch.setenv(
"HERMES_KANBAN_DB",
"/users/alice/.hermes/kanban/boards/smoke/kanban.db",
)
client = cas.CodexAppServerClient(codex_bin="codex")
client._closed = True
cmd = captured["cmd"]
assert cmd[:2] == ["codex", "app-server"]
assert 'sandbox_mode="workspace-write"' in cmd
assert (
'sandbox_workspace_write.writable_roots=["/users/alice/.hermes/kanban/boards/smoke"]'
in cmd
)
assert "sandbox_workspace_write.network_access=false" in cmd
assert all("danger" not in part for part in cmd)
@@ -9,10 +9,12 @@ from __future__ import annotations
import threading
import time
from unittest.mock import patch
from typing import Any, Optional
import pytest
import agent.transports.codex_app_server_session as session_mod
from agent.transports.codex_app_server_session import (
CodexAppServerSession,
TurnResult,
@@ -344,6 +346,23 @@ class TestRunTurn:
assert r.interrupted is True
assert r.error and "timed out" in r.error
def test_deadline_uses_monotonic_clock(self):
client = FakeClient()
s = make_session(client)
monotonic_values = iter([1000.0, 999.0, 999.0, 1001.0])
with patch.object(
session_mod.time,
"monotonic",
side_effect=lambda: next(monotonic_values),
):
r = s.run_turn(
"never finishes",
turn_timeout=0.1,
notification_poll_timeout=0.0,
)
assert r.interrupted is True
assert r.error and "timed out" in r.error
def test_failed_turn_records_error_from_turn_completed(self):
client = FakeClient()
client.queue_notification(
@@ -666,6 +685,35 @@ class TestSessionRetirement:
# Confirm we issued turn/interrupt to free codex compute
assert any(method == "turn/interrupt" for (method, _) in client.requests)
def test_post_tool_watchdog_uses_monotonic_clock(self):
client = FakeClient()
client.queue_notification(
"item/completed",
item={
"type": "commandExecution", "id": "ex1",
"command": "echo hi", "cwd": "/tmp",
"status": "completed", "aggregatedOutput": "hi",
"exitCode": 0, "commandActions": [],
},
threadId="t", turnId="tu1",
)
s = make_session(client)
monotonic_values = iter([1000.0, 999.0, 999.0, 999.0, 1000.2])
with patch.object(
session_mod.time,
"monotonic",
side_effect=lambda: next(monotonic_values),
):
r = s.run_turn(
"tool then silence",
turn_timeout=5.0,
notification_poll_timeout=0.0,
post_tool_quiet_timeout=0.15,
)
assert r.interrupted is True
assert r.should_retire is True
assert r.error and "silent" in r.error
def test_post_tool_watchdog_resets_on_further_activity(self):
"""A tool completion followed by an agent message should NOT trip
the watchdog further activity = codex still alive."""