mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
a5a9171e83
commit
fd354e229c
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface ChatMessage {
|
||||
toolCalls?: ToolCallInfo[];
|
||||
usage?: TokenUsage;
|
||||
telemetry?: MessageTelemetry;
|
||||
audio?: { url: string };
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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]"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user