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) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-04-02 12:50:34 -07:00
co-authored by Claude Opus 4.6
parent a5a9171e83
commit fd354e229c
14 changed files with 652 additions and 3 deletions
@@ -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<HTMLAudioElement>(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<HTMLDivElement>) => {
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 (
<div
className="flex items-center gap-3 px-4 py-3 rounded-xl mb-3"
style={{
background: 'var(--color-surface, #f5f5f5)',
border: '1px solid var(--color-border, #e0e0e0)',
}}
>
<audio ref={audioRef} src={src} preload="metadata" />
<button
onClick={toggle}
className="flex items-center justify-center w-9 h-9 rounded-full transition-colors shrink-0"
style={{
background: 'var(--color-accent, #6366f1)',
color: 'white',
cursor: 'pointer',
}}
>
{playing ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
</button>
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<Volume2 size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<span
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Morning Digest
</span>
</div>
<div
className="h-1.5 rounded-full cursor-pointer"
style={{ background: 'var(--color-bg-tertiary, #e0e0e0)' }}
onClick={seek}
>
<div
className="h-full rounded-full transition-all"
style={{
width: `${progress}%`,
background: 'var(--color-accent, #6366f1)',
}}
/>
</div>
<div
className="flex justify-between text-xs"
style={{ color: 'var(--color-text-tertiary)' }}
>
<span>{formatTime(currentTime)}</span>
<span>{duration > 0 ? formatTime(duration) : '--:--'}</span>
</div>
</div>
</div>
);
}
+16 -1
View File
@@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import { Send, Square, Paperclip } from 'lucide-react';
import { useAppStore, generateId } from '../../lib/store';
import { streamChat } from '../../lib/sse';
import { fetchSavings } from '../../lib/api';
import { fetchSavings, getBase } from '../../lib/api';
import { MicButton } from './MicButton';
import { useSpeech } from '../../hooks/useSpeech';
import type { ChatMessage, ToolCallInfo, TokenUsage, MessageTelemetry } from '../../types';
@@ -256,12 +256,27 @@ export function InputArea() {
complexity_tier: complexity?.tier,
suggested_max_tokens: complexity?.suggested_max_tokens,
};
// Check if the response has digest audio available
let audioMeta: { url: string } | undefined;
try {
const digestRes = await fetch(`${getBase()}/api/digest`);
if (digestRes.ok) {
const digest = await digestRes.json();
if (digest.audio_available) {
audioMeta = { url: `${getBase()}/api/digest/audio` };
}
}
} catch {
// Not a digest response or server unavailable — skip
}
updateLastAssistant(
convId,
accumulatedContent,
toolCalls.length > 0 ? toolCalls : undefined,
usage,
telemetry,
audioMeta,
);
if (timerRef.current) {
clearInterval(timerRef.current);
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import 'katex/dist/katex.min.css';
import { Copy, Check } from 'lucide-react';
import { AudioPlayer } from './AudioPlayer';
import { ToolCallCard } from './ToolCallCard';
import { XRayFooter } from './XRayFooter';
import type { ChatMessage } from '../../types';
@@ -131,6 +132,9 @@ export function MessageBubble({ message }: Props) {
</div>
)}
{/* Audio player (e.g. morning digest) */}
{message.audio?.url && <AudioPlayer src={message.audio.url} />}
{/* Assistant message */}
{cleanContent && (
<div className="prose max-w-none">
+3
View File
@@ -149,6 +149,7 @@ interface AppState {
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
telemetry?: MessageTelemetry,
audio?: { url: string },
) => void;
setStreamState: (state: Partial<StreamState>) => void;
resetStream: () => void;
@@ -337,6 +338,7 @@ export const useAppStore = create<AppState>((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<AppState>((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] });
+1
View File
@@ -69,6 +69,7 @@ export interface ChatMessage {
toolCalls?: ToolCallInfo[];
usage?: TokenUsage;
telemetry?: MessageTelemetry;
audio?: { url: string };
}
export interface Conversation {
+1 -1
View File
@@ -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(
"""
+151
View File
@@ -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]"
)
+1
View File
@@ -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:
+50
View File
@@ -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
+5
View File
@@ -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):
+16 -1
View File
@@ -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",
)
],
+26
View File
@@ -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,
+66
View File
@@ -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
+193
View File
@@ -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()