From fd354e229cf7687fab3811ba337f77be031e5f73 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:50:34 -0700 Subject: [PATCH] feat: greeting trigger, audio player, and digest scheduler - "Good morning" intent detection routes to MorningDigestAgent via pattern matching in JarvisSystem.ask() (zero latency, no API cost) - Inline AudioPlayer component renders play/pause + progress bar in chat when digest audio is available - `jarvis digest --schedule "0 6 * * *"` wires into TaskScheduler for daily auto-generation; persists to config.toml - Server /api/digest/schedule GET+POST endpoints for frontend/desktop - Chat completion responses include audio metadata when digest produces audio, frontend auto-detects via /api/digest endpoint Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/Chat/AudioPlayer.tsx | 119 +++++++++++ frontend/src/components/Chat/InputArea.tsx | 17 +- .../src/components/Chat/MessageBubble.tsx | 4 + frontend/src/lib/store.ts | 3 + frontend/src/types/index.ts | 1 + src/openjarvis/agents/digest_store.py | 2 +- src/openjarvis/cli/digest_cmd.py | 151 ++++++++++++++ src/openjarvis/core/config.py | 1 + src/openjarvis/server/digest_routes.py | 50 +++++ src/openjarvis/server/models.py | 5 + src/openjarvis/server/routes.py | 17 +- src/openjarvis/system.py | 26 +++ tests/core/test_intent_routing.py | 66 ++++++ tests/scheduler/test_digest_schedule.py | 193 ++++++++++++++++++ 14 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Chat/AudioPlayer.tsx create mode 100644 tests/core/test_intent_routing.py create mode 100644 tests/scheduler/test_digest_schedule.py diff --git a/frontend/src/components/Chat/AudioPlayer.tsx b/frontend/src/components/Chat/AudioPlayer.tsx new file mode 100644 index 00000000..15d7f057 --- /dev/null +++ b/frontend/src/components/Chat/AudioPlayer.tsx @@ -0,0 +1,119 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; +import { Play, Pause, Volume2 } from 'lucide-react'; + +interface AudioPlayerProps { + src: string; +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export function AudioPlayer({ src }: AudioPlayerProps) { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const toggle = useCallback(() => { + const el = audioRef.current; + if (!el) return; + if (playing) { + el.pause(); + } else { + el.play(); + } + setPlaying(!playing); + }, [playing]); + + useEffect(() => { + const el = audioRef.current; + if (!el) return; + + const onTime = () => setCurrentTime(el.currentTime); + const onMeta = () => setDuration(el.duration); + const onEnded = () => { + setPlaying(false); + setCurrentTime(0); + }; + + el.addEventListener('timeupdate', onTime); + el.addEventListener('loadedmetadata', onMeta); + el.addEventListener('ended', onEnded); + return () => { + el.removeEventListener('timeupdate', onTime); + el.removeEventListener('loadedmetadata', onMeta); + el.removeEventListener('ended', onEnded); + }; + }, []); + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + const seek = (e: React.MouseEvent) => { + const el = audioRef.current; + if (!el || !duration) return; + const rect = e.currentTarget.getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + el.currentTime = pct * duration; + }; + + return ( +
+
)} + {/* Audio player (e.g. morning digest) */} + {message.audio?.url && } + {/* Assistant message */} {cleanContent && (
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index e7c3bb08..60cabea0 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -149,6 +149,7 @@ interface AppState { toolCalls?: ToolCallInfo[], usage?: TokenUsage, telemetry?: MessageTelemetry, + audio?: { url: string }, ) => void; setStreamState: (state: Partial) => void; resetStream: () => void; @@ -337,6 +338,7 @@ export const useAppStore = create((set, get) => { toolCalls?: ToolCallInfo[], usage?: TokenUsage, telemetry?: MessageTelemetry, + audio?: { url: string }, ) => { const store = loadConversations(); const conv = store.conversations[conversationId]; @@ -347,6 +349,7 @@ export const useAppStore = create((set, get) => { if (toolCalls) lastMsg.toolCalls = toolCalls; if (usage) lastMsg.usage = usage; if (telemetry) lastMsg.telemetry = telemetry; + if (audio) lastMsg.audio = audio; conv.updatedAt = Date.now(); saveConversations(store); set({ messages: [...conv.messages] }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 747e1d25..923fe165 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -69,6 +69,7 @@ export interface ChatMessage { toolCalls?: ToolCallInfo[]; usage?: TokenUsage; telemetry?: MessageTelemetry; + audio?: { url: string }; } export interface Conversation { diff --git a/src/openjarvis/agents/digest_store.py b/src/openjarvis/agents/digest_store.py index b164b244..8eef4248 100644 --- a/src/openjarvis/agents/digest_store.py +++ b/src/openjarvis/agents/digest_store.py @@ -30,7 +30,7 @@ class DigestStore: if not db_path: db_path = str(Path.home() / ".openjarvis" / "digest.db") self._db_path = db_path - self._conn = sqlite3.connect(db_path) + self._conn = sqlite3.connect(db_path, check_same_thread=False) self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute( """ diff --git a/src/openjarvis/cli/digest_cmd.py b/src/openjarvis/cli/digest_cmd.py index c414485b..755f59b9 100644 --- a/src/openjarvis/cli/digest_cmd.py +++ b/src/openjarvis/cli/digest_cmd.py @@ -4,12 +4,14 @@ from __future__ import annotations import subprocess import threading +from typing import Optional import click from rich.console import Console from rich.markdown import Markdown from openjarvis.agents.digest_store import DigestStore +from openjarvis.core.config import DEFAULT_CONFIG_PATH, load_config def _play_audio(audio_path: str) -> None: @@ -29,21 +31,135 @@ def _play_audio(audio_path: str) -> None: continue +def _save_digest_schedule(enabled: bool, cron: str) -> None: + """Persist digest schedule to config.toml.""" + config_path = DEFAULT_CONFIG_PATH + + # Read existing TOML content (or start fresh) + content = "" + if config_path.exists(): + content = config_path.read_text() + + # Check if [digest] section already exists + lines = content.split("\n") + new_lines: list[str] = [] + in_digest = False + digest_written = False + + for line in lines: + stripped = line.strip() + # Detect start of [digest] section + if stripped == "[digest]": + in_digest = True + digest_written = True + new_lines.append("[digest]") + new_lines.append(f"enabled = {str(enabled).lower()}") + new_lines.append(f'schedule = "{cron}"') + continue + # If inside [digest], skip old enabled/schedule keys + if in_digest: + if stripped.startswith("[") and stripped != "[digest]": + in_digest = False + new_lines.append(line) + elif stripped.startswith("enabled") or stripped.startswith("schedule"): + continue + else: + new_lines.append(line) + else: + new_lines.append(line) + + if not digest_written: + # Append [digest] section at the end + if new_lines and new_lines[-1].strip(): + new_lines.append("") + new_lines.append("[digest]") + new_lines.append(f"enabled = {str(enabled).lower()}") + new_lines.append(f'schedule = "{cron}"') + new_lines.append("") + + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text("\n".join(new_lines)) + + +def _create_scheduler_task(cron: str) -> Optional[str]: + """Create a digest task in the TaskScheduler. Returns task ID or None.""" + try: + from openjarvis.scheduler.scheduler import TaskScheduler + from openjarvis.scheduler.store import SchedulerStore + + db_path = DEFAULT_CONFIG_PATH.parent / "scheduler.db" + store = SchedulerStore(db_path) + scheduler = TaskScheduler(store) + + # Cancel any existing digest tasks first + for task in scheduler.list_tasks(status="active"): + if task.agent == "morning_digest": + scheduler.cancel_task(task.id) + + task = scheduler.create_task( + prompt="Generate my morning digest", + schedule_type="cron", + schedule_value=cron, + agent="morning_digest", + ) + store.close() + return task.id + except Exception: + return None + + +def _cancel_scheduler_tasks() -> int: + """Cancel all active digest tasks. Returns count cancelled.""" + try: + from openjarvis.scheduler.scheduler import TaskScheduler + from openjarvis.scheduler.store import SchedulerStore + + db_path = DEFAULT_CONFIG_PATH.parent / "scheduler.db" + store = SchedulerStore(db_path) + scheduler = TaskScheduler(store) + count = 0 + for task in scheduler.list_tasks(status="active"): + if task.agent == "morning_digest": + scheduler.cancel_task(task.id) + count += 1 + store.close() + return count + except Exception: + return 0 + + @click.command("digest", help="Display and play the morning digest.") @click.option("--text-only", is_flag=True, help="Print text without audio playback.") @click.option("--fresh", is_flag=True, help="Re-generate the digest (skip cache).") @click.option("--history", is_flag=True, help="Show past digests.") @click.option("--section", type=str, default="", help="Show only a specific section.") @click.option("--db-path", type=str, default="", help="Path to digest database.") +@click.option( + "--schedule", + type=str, + default=None, + is_eager=True, + help=( + 'Set cron schedule (e.g. "0 6 * * *"), ' + '"off" to disable, or empty to show status.' + ), +) def digest( text_only: bool, fresh: bool, history: bool, section: str, db_path: str, + schedule: Optional[str], ) -> None: """Display and optionally play the morning digest.""" console = Console() + + # Handle --schedule flag + if schedule is not None: + _handle_schedule(console, schedule) + return + store = DigestStore(db_path=db_path) if db_path else DigestStore() if history: @@ -111,3 +227,38 @@ def digest( console.print(Markdown(text)) store.close() + + +def _handle_schedule(console: Console, schedule: str) -> None: + """Handle the --schedule option logic.""" + cfg = load_config() + digest_cfg = cfg.digest + + if schedule == "": + # Show current schedule status + status = "enabled" if digest_cfg.enabled else "disabled" + console.print(f"[bold]Digest schedule:[/bold] {status}") + console.print(f" Cron: {digest_cfg.schedule}") + console.print(f" Timezone: {digest_cfg.timezone}") + return + + if schedule.lower() == "off": + # Disable the schedule + _save_digest_schedule(enabled=False, cron=digest_cfg.schedule) + cancelled = _cancel_scheduler_tasks() + console.print("[yellow]Digest schedule disabled.[/yellow]") + if cancelled: + console.print(f" Cancelled {cancelled} scheduler task(s).") + return + + # Set a new cron schedule + _save_digest_schedule(enabled=True, cron=schedule) + task_id = _create_scheduler_task(schedule) + console.print(f"[green]Digest schedule set:[/green] {schedule}") + if task_id: + console.print(f" Scheduler task created: {task_id}") + else: + console.print( + " [dim]Note: scheduler task not created " + "(start the scheduler separately).[/dim]" + ) diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index a492385b..ba15d625 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1516,6 +1516,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig: "speech", "optimize", "agent_manager", + "digest", ) for section_name in top_sections: if section_name in data: diff --git a/src/openjarvis/server/digest_routes.py b/src/openjarvis/server/digest_routes.py index ce92a4f1..abcf86ae 100644 --- a/src/openjarvis/server/digest_routes.py +++ b/src/openjarvis/server/digest_routes.py @@ -2,10 +2,26 @@ from __future__ import annotations +from typing import Optional + from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse +from pydantic import BaseModel from openjarvis.agents.digest_store import DigestStore +from openjarvis.cli.digest_cmd import ( + _cancel_scheduler_tasks, + _create_scheduler_task, + _save_digest_schedule, +) +from openjarvis.core.config import load_config + + +class ScheduleUpdate(BaseModel): + """Request body for updating the digest schedule.""" + + enabled: bool + cron: Optional[str] = None def create_digest_router(*, db_path: str = "") -> APIRouter: @@ -71,4 +87,38 @@ def create_digest_router(*, db_path: str = "") -> APIRouter: for a in history ] + @router.get("/schedule") + async def get_schedule(): + """Return the current digest schedule configuration.""" + cfg = load_config() + return { + "enabled": cfg.digest.enabled, + "cron": cfg.digest.schedule, + } + + @router.post("/schedule") + async def update_schedule(body: ScheduleUpdate): + """Update the digest schedule configuration.""" + cfg = load_config() + cron = body.cron if body.cron is not None else cfg.digest.schedule + + try: + _save_digest_schedule(enabled=body.enabled, cron=cron) + except Exception as exc: + raise HTTPException( + status_code=500, + detail=f"Failed to save config: {exc}", + ) + + # Sync with the TaskScheduler + if body.enabled: + _create_scheduler_task(cron) + else: + _cancel_scheduler_tasks() + + return { + "enabled": body.enabled, + "cron": cron, + } + return router diff --git a/src/openjarvis/server/models.py b/src/openjarvis/server/models.py index 8086f078..be67f394 100644 --- a/src/openjarvis/server/models.py +++ b/src/openjarvis/server/models.py @@ -41,10 +41,15 @@ class UsageInfo(BaseModel): total_tokens: int = 0 +class AudioMeta(BaseModel): + url: str + + class ChoiceMessage(BaseModel): role: str = "assistant" content: Optional[str] = "" tool_calls: Optional[List[Dict[str, Any]]] = None + audio: Optional[AudioMeta] = None class Choice(BaseModel): diff --git a/src/openjarvis/server/routes.py b/src/openjarvis/server/routes.py index a615d7d0..7a8d473c 100644 --- a/src/openjarvis/server/routes.py +++ b/src/openjarvis/server/routes.py @@ -263,11 +263,26 @@ def _handle_agent( total_tokens=result.metadata.get("total_tokens", 0), ) + # Include audio metadata if the agent produced audio (e.g. morning digest) + audio_meta = None + audio_path = result.metadata.get("audio_path", "") + if audio_path: + from pathlib import Path + + from openjarvis.server.models import AudioMeta + + if Path(audio_path).exists(): + audio_meta = AudioMeta(url="/api/digest/audio") + return ChatCompletionResponse( model=model, choices=[ Choice( - message=ChoiceMessage(role="assistant", content=result.content), + message=ChoiceMessage( + role="assistant", + content=result.content, + audio=audio_meta, + ), finish_reason="stop", ) ], diff --git a/src/openjarvis/system.py b/src/openjarvis/system.py index 3c6da3fe..e04cfbbb 100644 --- a/src/openjarvis/system.py +++ b/src/openjarvis/system.py @@ -97,6 +97,11 @@ class JarvisSystem: # Agent mode use_agent = agent or self.agent_name + # Auto-detect agent intent if no explicit agent was requested + if not agent and use_agent != "none": + detected = self._detect_agent_intent(query) + if detected: + use_agent = detected if use_agent and use_agent != "none": return self._run_agent( query, @@ -124,6 +129,27 @@ class JarvisSystem: "engine": self.engine_key, } + def _detect_agent_intent(self, query: str) -> Optional[str]: + """Detect if a query should be routed to a specific agent. + + Uses lightweight pattern matching for known intent triggers. + Returns the agent name or None to use the default. + """ + import re + + from openjarvis.core.registry import AgentRegistry + + # Morning digest triggers + if re.search( + r"\b(good\s+morning|morning\s+digest|daily\s+briefing|morning\s+briefing)\b", + query, + re.IGNORECASE, + ): + if AgentRegistry.contains("morning_digest"): + return "morning_digest" + + return None + def _run_agent( self, query, diff --git a/tests/core/test_intent_routing.py b/tests/core/test_intent_routing.py new file mode 100644 index 00000000..c63d0750 --- /dev/null +++ b/tests/core/test_intent_routing.py @@ -0,0 +1,66 @@ +"""Tests for intent-based agent routing in JarvisSystem.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestDetectAgentIntent: + """Test _detect_agent_intent pattern matching.""" + + @pytest.fixture() + def system(self): + """Create a minimal JarvisSystem instance for testing _detect_agent_intent.""" + from openjarvis.system import JarvisSystem + + mock_engine = MagicMock() + mock_engine.engine_name = "mock" + sys = JarvisSystem.__new__(JarvisSystem) + sys.engine = mock_engine + sys.model = "test-model" + sys.agent_name = "simple" + sys.tools = [] + sys.bus = MagicMock() + yield sys + + def test_good_morning_triggers_digest(self, system): + with patch("openjarvis.core.registry.AgentRegistry") as reg: + reg.contains.return_value = True + assert system._detect_agent_intent("Good morning!") == "morning_digest" + + def test_good_morning_jarvis_triggers_digest(self, system): + with patch("openjarvis.core.registry.AgentRegistry") as reg: + reg.contains.return_value = True + result = system._detect_agent_intent("Good morning Jarvis") + assert result == "morning_digest" + + def test_morning_digest_triggers(self, system): + with patch("openjarvis.core.registry.AgentRegistry") as reg: + reg.contains.return_value = True + query = "Show me my morning digest" + assert system._detect_agent_intent(query) == "morning_digest" + + def test_daily_briefing_triggers(self, system): + with patch("openjarvis.core.registry.AgentRegistry") as reg: + reg.contains.return_value = True + query = "Give me my daily briefing" + assert system._detect_agent_intent(query) == "morning_digest" + + def test_morning_briefing_triggers(self, system): + with patch("openjarvis.core.registry.AgentRegistry") as reg: + reg.contains.return_value = True + query = "morning briefing please" + assert system._detect_agent_intent(query) == "morning_digest" + + def test_regular_question_no_trigger(self, system): + assert system._detect_agent_intent("What is the weather?") is None + + def test_good_afternoon_no_trigger(self, system): + assert system._detect_agent_intent("Good afternoon") is None + + def test_no_agent_registered_returns_none(self, system): + with patch("openjarvis.core.registry.AgentRegistry") as reg: + reg.contains.return_value = False + assert system._detect_agent_intent("Good morning!") is None diff --git a/tests/scheduler/test_digest_schedule.py b/tests/scheduler/test_digest_schedule.py new file mode 100644 index 00000000..d87d50ca --- /dev/null +++ b/tests/scheduler/test_digest_schedule.py @@ -0,0 +1,193 @@ +"""Tests for digest schedule integration — CLI and API endpoints.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from openjarvis.cli.digest_cmd import digest + +# --------------------------------------------------------------------------- +# CLI tests +# --------------------------------------------------------------------------- + + +class TestDigestScheduleCLI: + """Tests for the ``jarvis digest --schedule`` flag.""" + + def test_schedule_show_status(self): + """``--schedule ""`` shows the current schedule from config.""" + mock_cfg = MagicMock() + mock_cfg.digest.enabled = True + mock_cfg.digest.schedule = "0 6 * * *" + mock_cfg.digest.timezone = "America/Los_Angeles" + + runner = CliRunner() + with patch("openjarvis.cli.digest_cmd.load_config", return_value=mock_cfg): + result = runner.invoke(digest, ["--schedule", ""]) + + assert result.exit_code == 0 + assert "enabled" in result.output + assert "0 6 * * *" in result.output + + def test_schedule_set_cron(self, tmp_path): + """``--schedule "0 7 * * *"`` saves the schedule and creates a task.""" + mock_cfg = MagicMock() + mock_cfg.digest.enabled = False + mock_cfg.digest.schedule = "0 6 * * *" + mock_cfg.digest.timezone = "America/Los_Angeles" + + config_path = tmp_path / "config.toml" + config_path.write_text("") + + runner = CliRunner() + with ( + patch( + "openjarvis.cli.digest_cmd.load_config", + return_value=mock_cfg, + ), + patch( + "openjarvis.cli.digest_cmd.DEFAULT_CONFIG_PATH", + config_path, + ), + patch( + "openjarvis.cli.digest_cmd._create_scheduler_task", + return_value="abc123", + ) as mock_create, + ): + result = runner.invoke(digest, ["--schedule", "0 7 * * *"]) + + assert result.exit_code == 0 + assert "0 7 * * *" in result.output + assert "abc123" in result.output + mock_create.assert_called_once_with("0 7 * * *") + + # Verify config was written + content = config_path.read_text() + assert "enabled = true" in content + assert '"0 7 * * *"' in content + + def test_schedule_off(self): + """``--schedule off`` disables the schedule.""" + mock_cfg = MagicMock() + mock_cfg.digest.enabled = True + mock_cfg.digest.schedule = "0 6 * * *" + mock_cfg.digest.timezone = "America/Los_Angeles" + + runner = CliRunner() + with ( + patch( + "openjarvis.cli.digest_cmd.load_config", + return_value=mock_cfg, + ), + patch("openjarvis.cli.digest_cmd._save_digest_schedule") as mock_save, + patch( + "openjarvis.cli.digest_cmd._cancel_scheduler_tasks", + return_value=1, + ), + ): + result = runner.invoke(digest, ["--schedule", "off"]) + + assert result.exit_code == 0 + assert "disabled" in result.output.lower() + mock_save.assert_called_once_with(enabled=False, cron="0 6 * * *") + + +# --------------------------------------------------------------------------- +# API endpoint tests +# --------------------------------------------------------------------------- + +_skip_no_fastapi = pytest.importorskip("fastapi") + + +class TestDigestScheduleEndpoints: + """Tests for GET/POST /api/digest/schedule.""" + + @pytest.fixture() + def client(self): + """Create a test client with digest routes.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from openjarvis.server.digest_routes import create_digest_router + + app = FastAPI() + app.include_router(create_digest_router()) + return TestClient(app) + + def test_schedule_endpoint_returns_config(self, client): + """GET /api/digest/schedule returns enabled and cron from config.""" + mock_cfg = MagicMock() + mock_cfg.digest.enabled = True + mock_cfg.digest.schedule = "0 6 * * *" + + with patch( + "openjarvis.server.digest_routes.load_config", + return_value=mock_cfg, + ): + resp = client.get("/api/digest/schedule") + + assert resp.status_code == 200 + data = resp.json() + assert data["enabled"] is True + assert data["cron"] == "0 6 * * *" + + def test_schedule_endpoint_update(self, client): + """POST /api/digest/schedule updates the config.""" + mock_cfg = MagicMock() + mock_cfg.digest.enabled = False + mock_cfg.digest.schedule = "0 6 * * *" + + with ( + patch( + "openjarvis.server.digest_routes.load_config", + return_value=mock_cfg, + ), + patch("openjarvis.server.digest_routes._save_digest_schedule") as mock_save, + patch( + "openjarvis.server.digest_routes._create_scheduler_task", + return_value="task123", + ) as mock_create, + ): + resp = client.post( + "/api/digest/schedule", + json={"enabled": True, "cron": "30 7 * * 1-5"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["enabled"] is True + assert data["cron"] == "30 7 * * 1-5" + mock_save.assert_called_once_with(enabled=True, cron="30 7 * * 1-5") + mock_create.assert_called_once_with("30 7 * * 1-5") + + def test_schedule_endpoint_disable(self, client): + """POST /api/digest/schedule with enabled=false cancels tasks.""" + mock_cfg = MagicMock() + mock_cfg.digest.enabled = True + mock_cfg.digest.schedule = "0 6 * * *" + + with ( + patch( + "openjarvis.server.digest_routes.load_config", + return_value=mock_cfg, + ), + patch("openjarvis.server.digest_routes._save_digest_schedule") as mock_save, + patch( + "openjarvis.server.digest_routes._cancel_scheduler_tasks", + return_value=1, + ) as mock_cancel, + ): + resp = client.post( + "/api/digest/schedule", + json={"enabled": False}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["enabled"] is False + assert data["cron"] == "0 6 * * *" + mock_save.assert_called_once_with(enabled=False, cron="0 6 * * *") + mock_cancel.assert_called_once()