diff --git a/app/src/components/notifications/NotificationCard.test.tsx b/app/src/components/notifications/NotificationCard.test.tsx new file mode 100644 index 000000000..247227687 --- /dev/null +++ b/app/src/components/notifications/NotificationCard.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { IntegrationNotification } from '../../types/notifications'; +import NotificationCard, { formatNotificationBodyPreview } from './NotificationCard'; + +function notification(overrides: Partial = {}): IntegrationNotification { + return { + id: 'notif-1', + provider: 'cron', + account_id: 'morning_briefing', + title: 'morning_briefing', + body: 'Morning briefing ready.', + raw_payload: {}, + importance_score: 0.65, + triage_action: 'react', + triage_reason: 'Scheduled delivery', + status: 'unread', + received_at: new Date().toISOString(), + scored_at: new Date().toISOString(), + ...overrides, + }; +} + +describe('formatNotificationBodyPreview', () => { + it('strips openhuman-link markup while preserving the label', () => { + expect( + formatNotificationBodyPreview( + 'You can Report on Discord.' + ) + ).toBe('You can Report on Discord.'); + }); +}); + +describe('NotificationCard', () => { + it('does not render raw openhuman-link markup in the body preview', () => { + const rendered = render( + Report on Discord', + })} + onMarkRead={vi.fn()} + /> + ); + + expect(screen.getByText(/Report on Discord/)).toBeInTheDocument(); + expect(rendered.container.textContent).not.toContain(''); + }); +}); diff --git a/app/src/components/notifications/NotificationCard.tsx b/app/src/components/notifications/NotificationCard.tsx index be5757d7c..e4944cabb 100644 --- a/app/src/components/notifications/NotificationCard.tsx +++ b/app/src/components/notifications/NotificationCard.tsx @@ -44,6 +44,17 @@ function scoreBadgeClass(score: number): string { return 'bg-sage-500/20 text-green-700 border-green-200'; } +const OPENHUMAN_LINK_TAG = + /([\s\S]*?)<\/openhuman-link>/gi; + +export function formatNotificationBodyPreview(body: string): string { + return body + .replace(OPENHUMAN_LINK_TAG, '$1') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + // ───────────────────────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────────────────────── @@ -58,6 +69,7 @@ interface Props { const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => { const { t } = useT(); const isUnread = n.status === 'unread'; + const bodyPreview = n.body ? formatNotificationBodyPreview(n.body) : ''; const handleBodyClick = () => { if (onNavigate) { @@ -118,9 +130,9 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }

{/* Body preview */} - {n.body && ( + {bodyPreview && (

- {n.body} + {bodyPreview}

)} diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 6085eb249..c28ca3380 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -16,6 +16,8 @@ use tokio::time::{self, Duration}; const MIN_POLL_SECONDS: u64 = 5; const SHELL_JOB_TIMEOUT_SECS: u64 = 120; const AGENT_JOB_USER_FAILURE_MESSAGE: &str = "Something went wrong. Please try again.\nThis error has been reported. You can also report it on Discord.\nReport on Discord"; +const MORNING_BRIEFING_AGENT_ID: &str = "morning_briefing"; +const MORNING_BRIEFING_FAILURE_NOTIFICATION: &str = "Morning briefing could not run. Check your AI provider, API key, and connected apps, then run it again from Settings > Cron Jobs."; fn agent_session_target_tag(target: &SessionTarget) -> &'static str { match target { @@ -24,6 +26,54 @@ fn agent_session_target_tag(target: &SessionTarget) -> &'static str { } } +fn is_morning_briefing_job(job: &CronJob) -> bool { + job.name.as_deref() == Some(MORNING_BRIEFING_AGENT_ID) + || job.agent_id.as_deref() == Some(MORNING_BRIEFING_AGENT_ID) +} + +fn strip_openhuman_link_markup(input: &str) -> String { + const OPEN_TAG: &str = "') else { + output.push_str(tag_and_after); + return output; + }; + let label_and_after = &tag_and_after[open_end + 1..]; + + let Some(close_start) = label_and_after.find(CLOSE_TAG) else { + output.push_str(tag_and_after); + return output; + }; + + output.push_str(&label_and_after[..close_start]); + rest = &label_and_after[close_start + CLOSE_TAG.len()..]; + } + + output.push_str(rest); + output +} + +fn cron_alert_body(job: &CronJob, output: &str) -> String { + let trimmed = output.trim(); + if matches!(job.job_type, JobType::Agent) + && trimmed == AGENT_JOB_USER_FAILURE_MESSAGE + && is_morning_briefing_job(job) + { + return MORNING_BRIEFING_FAILURE_NOTIFICATION.to_string(); + } + + let body = strip_openhuman_link_markup(output); + crate::openhuman::util::truncate_with_ellipsis(&body, 512) +} + pub async fn run(config: Config) -> Result<()> { // Ensure the global event bus is initialized so cron delivery events // are not silently dropped. This is a no-op if already initialized. @@ -490,14 +540,14 @@ fn push_cron_alert(config: &Config, job: &CronJob, output: &str) { use crate::openhuman::notifications::types::{IntegrationNotification, NotificationStatus}; let name = job.name.as_deref().unwrap_or("Cron job"); - let truncated = crate::openhuman::util::truncate_with_ellipsis(output, 512); + let body = cron_alert_body(job, output); let notification = IntegrationNotification { id: uuid::Uuid::new_v4().to_string(), provider: "cron".to_string(), account_id: Some(job.id.clone()), title: name.to_string(), - body: truncated, + body, raw_payload: serde_json::json!({ "job_id": job.id, "job_name": job.name, @@ -511,17 +561,26 @@ fn push_cron_alert(config: &Config, job: &CronJob, output: &str) { scored_at: Some(Utc::now()), }; - if let Err(e) = notif_store::insert(config, ¬ification) { - tracing::warn!( - job_id = %job.id, - error = %e, - "[cron] failed to push notification alert" - ); - } else { - tracing::debug!( - job_id = %job.id, - "[cron] pushed notification alert to alerts tab" - ); + match notif_store::insert_if_not_recent(config, ¬ification) { + Ok(true) => { + tracing::debug!( + job_id = %job.id, + "[cron] pushed notification alert to alerts tab" + ); + } + Ok(false) => { + tracing::debug!( + job_id = %job.id, + "[cron] skipped duplicate notification alert" + ); + } + Err(e) => { + tracing::warn!( + job_id = %job.id, + error = %e, + "[cron] failed to push notification alert" + ); + } } } diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index cfd879f33..7d3f70630 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -54,6 +54,50 @@ fn agent_failure_copy_mentions_retry_reporting_and_discord() { assert!(AGENT_JOB_USER_FAILURE_MESSAGE.contains("Report on Discord")); } +#[test] +fn cron_alert_body_rewrites_morning_briefing_failure() { + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.name = Some("morning_briefing".into()); + job.agent_id = Some("morning_briefing".into()); + + let body = cron_alert_body(&job, AGENT_JOB_USER_FAILURE_MESSAGE); + + assert_eq!(body, MORNING_BRIEFING_FAILURE_NOTIFICATION); + assert!(!body.contains("Something went wrong")); + assert!(!body.contains("notification settings before tomorrow.", + ); + + assert_eq!(body, "Read notification settings before tomorrow."); + assert!(!body.contains("