diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json
new file mode 100644
index 00000000000..68a2e9b895c
--- /dev/null
+++ b/plugins/example-dashboard/dashboard/manifest.json
@@ -0,0 +1,14 @@
+{
+ "name": "example",
+ "label": "Example",
+ "description": "Example dashboard plugin — used by test suite for auth coverage",
+ "icon": "Sparkles",
+ "version": "1.0.0",
+ "tab": {
+ "path": "/example",
+ "position": "after:skills"
+ },
+ "slots": [],
+ "entry": "dist/index.js",
+ "api": "plugin_api.py"
+}
diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py
new file mode 100644
index 00000000000..3e850298a09
--- /dev/null
+++ b/plugins/example-dashboard/dashboard/plugin_api.py
@@ -0,0 +1,17 @@
+"""Example dashboard plugin — backend API routes.
+
+Mounted at /api/plugins/example/ by the dashboard plugin system.
+
+This minimal plugin exists so the test suite has a stable, side-effect-free
+GET endpoint to verify that plugin API routes work with auth.
+"""
+
+from fastapi import APIRouter
+
+router = APIRouter()
+
+
+@router.get("/hello")
+async def hello():
+ """Simple greeting endpoint to demonstrate plugin API routes."""
+ return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py
index cdac34d3282..c25ca219379 100644
--- a/tests/agent/test_auxiliary_client.py
+++ b/tests/agent/test_auxiliary_client.py
@@ -660,6 +660,7 @@ class TestAuxiliaryPoolAwareness:
with (
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
patch("agent.auxiliary_client.OpenAI") as mock_openai,
+ patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None),
):
from agent.auxiliary_client import _try_nous
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 76b14e31793..332cccee497 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -222,6 +222,9 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
runner._emit_gateway_run_progress = AsyncMock()
+ # Disable destructive slash confirm gate so /new executes immediately
+ runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}}
+
runner.pairing_store = MagicMock()
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py
index c53e34b757e..c59b27d8001 100644
--- a/tests/gateway/test_config.py
+++ b/tests/gateway/test_config.py
@@ -176,8 +176,8 @@ class TestStreamingConfig:
"fresh_final_after_seconds": "oops",
}
)
- assert restored.edit_interval == 1.0
- assert restored.buffer_threshold == 40
+ assert restored.edit_interval == 0.8
+ assert restored.buffer_threshold == 24
assert restored.fresh_final_after_seconds == 60.0
diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py
index 0ef37deb3ee..ec93c33f75c 100644
--- a/tests/gateway/test_tts_media_routing.py
+++ b/tests/gateway/test_tts_media_routing.py
@@ -8,7 +8,7 @@ only renders as a voice bubble when explicitly flagged) and via
"""
from types import SimpleNamespace
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -106,6 +106,16 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_
adapter.send_document.assert_not_awaited()
+def _fake_runner(thread_meta):
+ """Build a fake GatewayRunner-like object with the helper methods needed by
+ _deliver_media_from_response."""
+ runner = SimpleNamespace(
+ _thread_metadata_for_source=lambda source, anchor=None: thread_meta,
+ _reply_anchor_for_event=lambda event: None,
+ )
+ return runner
+
+
@pytest.mark.asyncio
async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender():
event = _event(thread_id="topic-1")
@@ -121,7 +131,7 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen
)
await GatewayRunner._deliver_media_from_response(
- object(),
+ _fake_runner({"thread_id": "topic-1"}),
"MEDIA:/tmp/speech.flac",
event,
adapter,
@@ -150,7 +160,7 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc
)
await GatewayRunner._deliver_media_from_response(
- object(),
+ _fake_runner({"thread_id": "topic-1"}),
"MEDIA:/tmp/speech.ogg",
event,
adapter,
@@ -181,7 +191,7 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(
)
await GatewayRunner._deliver_media_from_response(
- object(),
+ _fake_runner({"thread_id": "topic-1"}),
"MEDIA:/tmp/speech.mp3",
event,
adapter,
diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py
index b1681e1f349..932bd1b0579 100644
--- a/tests/gateway/test_update_streaming.py
+++ b/tests/gateway/test_update_streaming.py
@@ -45,6 +45,9 @@ def _make_runner(hermes_home=None):
runner._pending_messages = {}
runner._pending_approvals = {}
runner._failed_platforms = {}
+ # config is accessed by _check_slash_access and quick_commands lookup;
+ # None makes policy_for_source return a disabled (allow-all) policy.
+ runner.config = None
# Bypass the destructive-slash confirm gate — this test exercises
# update-prompt interception, not the confirm prompt.
runner._read_user_config = lambda: {
diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py
index d6debebae59..7b8d0445129 100644
--- a/tests/gateway/test_verbose_command.py
+++ b/tests/gateway/test_verbose_command.py
@@ -129,7 +129,7 @@ class TestVerboseCommand:
@pytest.mark.asyncio
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
- """When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
+ """When tool_progress is not in config, defaults to platform default then cycles."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
@@ -143,17 +143,17 @@ class TestVerboseCommand:
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
- # Telegram default is "all" (high tier) → cycles to verbose
- assert "VERBOSE" in result
+ # Telegram platform default is "new" → cycles to "all"
+ assert "ALL" in result
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
- assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
+ assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all"
@pytest.mark.asyncio
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
"""Cycling /verbose on Telegram doesn't change Slack's setting.
Without a global tool_progress, each platform uses its built-in
- default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default).
+ default: Telegram = 'new' (overridden high tier), Slack = 'off' (quiet Slack default).
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
@@ -178,8 +178,8 @@ class TestVerboseCommand:
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
platforms = saved["display"]["platforms"]
- # Telegram: all -> verbose (high tier default = all)
- assert platforms["telegram"]["tool_progress"] == "verbose"
+ # Telegram: new -> all (platform default = new)
+ assert platforms["telegram"]["tool_progress"] == "all"
# Slack: off -> new (first /verbose cycle from quiet default)
assert platforms["slack"]["tool_progress"] == "new"
diff --git a/tests/hermes_cli/test_dashboard_profiles_nav_label.py b/tests/hermes_cli/test_dashboard_profiles_nav_label.py
index 583e62ee9fd..924f217bd2e 100644
--- a/tests/hermes_cli/test_dashboard_profiles_nav_label.py
+++ b/tests/hermes_cli/test_dashboard_profiles_nav_label.py
@@ -2,10 +2,11 @@
from pathlib import Path
-def test_profiles_nav_label_uses_short_multi_agents_copy():
+def test_profiles_nav_label_uses_short_copy():
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
content = en_i18n.read_text(encoding="utf-8")
- assert 'profiles: "profiles : multi agents"' in content
- assert "Profiles: Running Multiple Agents" not in content
+ # Nav label should be the clean short form, not the old verbose string
+ assert 'profiles: "Profiles"' in content
+ assert "profiles : multi agents" not in content
diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py
index 5493acb52c0..34c878eca79 100644
--- a/tests/hermes_cli/test_update_gateway_restart.py
+++ b/tests/hermes_cli/test_update_gateway_restart.py
@@ -6,6 +6,7 @@ rather than leaving zombie processes or telling users to manually restart
when launchd will auto-respawn.
"""
+import os
import subprocess
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
@@ -1068,13 +1069,18 @@ class TestFindGatewayPidsExclude:
def test_excludes_specified_pids(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
+ # Bypass /proc scan so the subprocess (ps) fallback is used
+ _real_isdir = os.path.isdir
+ monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
+ monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set())
+ monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd, 0,
stdout=(
- "user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
- "user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
+ "100 python gateway/run.py\n"
+ "200 python gateway/run.py\n"
),
stderr="",
)
@@ -1082,19 +1088,24 @@ class TestFindGatewayPidsExclude:
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
monkeypatch.setattr("os.getpid", lambda: 999)
- pids = gateway_cli.find_gateway_pids(exclude_pids={100})
+ pids = gateway_cli.find_gateway_pids(exclude_pids={100}, all_profiles=True)
assert 100 not in pids
assert 200 in pids
def test_no_exclude_returns_all(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
+ # Bypass /proc scan so the subprocess (ps) fallback is used
+ _real_isdir = os.path.isdir
+ monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
+ monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set())
+ monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
cmd, 0,
stdout=(
- "user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
- "user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
+ "100 python gateway/run.py\n"
+ "200 python gateway/run.py\n"
),
stderr="",
)
@@ -1102,7 +1113,7 @@ class TestFindGatewayPidsExclude:
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
monkeypatch.setattr("os.getpid", lambda: 999)
- pids = gateway_cli.find_gateway_pids()
+ pids = gateway_cli.find_gateway_pids(all_profiles=True)
assert 100 in pids
assert 200 in pids
@@ -1111,6 +1122,10 @@ class TestFindGatewayPidsExclude:
profile_dir.mkdir(parents=True)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
+ # Bypass /proc scan so the subprocess (ps) fallback is used
+ _real_isdir = os.path.isdir
+ monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
+ monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
diff --git a/tests/run_agent/test_async_httpx_del_neuter.py b/tests/run_agent/test_async_httpx_del_neuter.py
index e616ea23acb..e91102288c0 100644
--- a/tests/run_agent/test_async_httpx_del_neuter.py
+++ b/tests/run_agent/test_async_httpx_del_neuter.py
@@ -182,7 +182,7 @@ class TestClientCacheBoundedGrowth:
_get_cached_client,
)
- key = ("test_replace", True, "", "", "", (), False)
+ key = ("test_replace", True, "", "", "", (), False, "")
# Simulate a stale entry from a closed loop
old_loop = asyncio.new_event_loop()
diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py
index 8eb7478b414..f97885a0382 100644
--- a/tests/run_agent/test_provider_parity.py
+++ b/tests/run_agent/test_provider_parity.py
@@ -945,7 +945,8 @@ class TestAuxiliaryClientProviderPriority:
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
from agent.auxiliary_client import get_text_auxiliary_client
with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \
- patch("agent.auxiliary_client.OpenAI") as mock:
+ patch("agent.auxiliary_client.OpenAI") as mock, \
+ patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None):
client, model = get_text_auxiliary_client()
assert model == "google/gemini-3-flash-preview"
diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py
index 0dd3ca4e7eb..afeee84878c 100644
--- a/tests/test_ctx_halving_fix.py
+++ b/tests/test_ctx_halving_fix.py
@@ -169,6 +169,7 @@ class TestEphemeralMaxOutputTokens:
agent.reasoning_config = None
agent._is_anthropic_oauth = False
agent._ephemeral_max_output_tokens = None
+ agent._use_long_lived_prefix_cache = False
compressor = MagicMock()
compressor.context_length = 200_000
diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py
index fce3772de8e..1df3003e5cd 100644
--- a/tests/tools/test_vision_native_fast_path.py
+++ b/tests/tools/test_vision_native_fast_path.py
@@ -157,8 +157,14 @@ class TestHandleVisionAnalyzeFastPath:
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
try:
- coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
- result = asyncio.get_event_loop().run_until_complete(coro)
+ # Mock decide_image_input_mode to always return "native" so the
+ # fast path fires regardless of model-catalog state in CI.
+ with patch(
+ "agent.image_routing.decide_image_input_mode",
+ return_value="native",
+ ):
+ coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
+ result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
diff --git a/tools/process_registry.py b/tools/process_registry.py
index 8bbe1f56b7c..405abc04a3c 100644
--- a/tools/process_registry.py
+++ b/tools/process_registry.py
@@ -585,7 +585,7 @@ class ProcessRegistry:
try:
if not _IS_WINDOWS:
try:
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — guarded by _IS_WINDOWS check above
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
else:
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 7e1ca19f134..d7239c2ad11 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -473,7 +473,7 @@ export default function App() {
>
diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx
index 6877207f8de..987f4c0eeef 100644
--- a/web/src/components/OAuthProvidersCard.tsx
+++ b/web/src/components/OAuthProvidersCard.tsx
@@ -20,6 +20,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
@@ -55,6 +56,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [loginFor, setLoginFor] = useState(null);
+ const [disconnectTarget, setDisconnectTarget] =
+ useState(null);
const { t } = useI18n();
const onErrorRef = useRef(onError);
@@ -74,10 +77,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
}, [refresh]);
const handleDisconnect = async (provider: OAuthProvider) => {
- if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
- return;
- }
setBusyId(provider.id);
+ setDisconnectTarget(null);
try {
await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
@@ -236,7 +237,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)}
+ setConfirmReset(false)}
+ onConfirm={executeReset}
+ title={t.config.confirmResetScope.replace(
+ "{scope}",
+ isSearching
+ ? t.config.searchResults
+ : prettyCategoryName(activeCategory),
+ )}
+ description={`This will reset ${
+ (isSearching ? searchMatchedFields : activeFields).length
+ } field(s) to their default values.`}
+ destructive
+ confirmLabel={t.config.resetDefaults}
+ />
);
}
diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx
index e994c96f270..78880adf0bc 100644
--- a/web/src/pages/CronPage.tsx
+++ b/web/src/pages/CronPage.tsx
@@ -1,5 +1,5 @@
-import { useCallback, useEffect, useState } from "react";
-import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
+import { useCallback, useEffect, useLayoutEffect, useState } from "react";
+import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@@ -10,11 +10,13 @@ import type { CronJob } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
+import { useModalBehavior } from "@/hooks/useModalBehavior";
import { Toast } from "@/components/Toast";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
+import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
function formatTime(iso?: string | null): string {
@@ -80,11 +82,18 @@ export default function CronPage() {
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
+ const { setEnd } = usePageHeader();
- // New job form state
+ // New job modal state
+ const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
+ const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
+ const createModalRef = useModalBehavior({
+ open: createModalOpen,
+ onClose: closeCreateModal,
+ });
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
@@ -118,6 +127,7 @@ export default function CronPage() {
setSchedule("");
setName("");
setDeliver("local");
+ setCreateModalOpen(false);
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
@@ -181,6 +191,22 @@ export default function CronPage() {
),
});
+ // Put "Create" button in page header
+ useLayoutEffect(() => {
+ setEnd(
+ ,
+ );
+ return () => {
+ setEnd(null);
+ };
+ }, [setEnd, t.common.create, loading]);
+
if (loading) {
return (
@@ -213,86 +239,110 @@ export default function CronPage() {
loading={jobDelete.isDeleting}
/>
-
-
-
-
- {t.cron.newJob}
-
-
-
-
-
-
- setName(e.target.value)}
- />
-
+ {/* Create job modal */}
+ {createModalOpen && (
+
e.target === e.currentTarget && setCreateModalOpen(false)}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="create-cron-title"
+ >
+
+
-
-
-
+
-
+
-
+
setSchedule(e.target.value)}
+ id="cron-name"
+ autoFocus
+ placeholder={t.cron.namePlaceholder}
+ value={name}
+ onChange={(e) => setName(e.target.value)}
/>
-
-
+
+
-
+
+
+
+ setSchedule(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
}
- className="w-full"
+ prefix={creating ?
:
}
>
{creating ? t.common.creating : t.common.create}
-
-
+
+ )}
+
{varKey}
@@ -168,7 +169,7 @@ function EnvVarRow({
// Non-compact unset row
if (!info.is_set && !isEditing) {
return (
-
+