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" + > +
+ -
- -