Skip to main content

agent_reel_story/
lib.rs

1use agent_reel_core::{
2    AgentEvent, Bulletin, BulletinChip, BulletinId, BulletinMode, EventId, EventKind, PrivacyClass,
3    Severity, TickerItem, VisualKind,
4};
5use agent_reel_highlight::score_event;
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, VecDeque};
8use time::OffsetDateTime;
9
10const DEFAULT_DWELL_MS: u64 = 14_000;
11const URGENT_DWELL_MS: u64 = 20_000;
12const DEFAULT_MIN_SCORE: u8 = 65;
13const DEFAULT_MIN_CONTEXT_SCORE: u8 = 70;
14const DEFAULT_DEDUPE_WINDOW: usize = 32;
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct StoryCompilerConfig {
18    pub min_score: u8,
19    pub min_context_score: u8,
20    pub per_agent_cooldown_events: usize,
21    pub dedupe_window_events: usize,
22}
23
24impl Default for StoryCompilerConfig {
25    fn default() -> Self {
26        Self {
27            min_score: DEFAULT_MIN_SCORE,
28            min_context_score: DEFAULT_MIN_CONTEXT_SCORE,
29            per_agent_cooldown_events: 0,
30            dedupe_window_events: DEFAULT_DEDUPE_WINDOW,
31        }
32    }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct StoryKey {
37    pub feed_id: Option<String>,
38    pub agent: String,
39    pub project_hash: Option<String>,
40    pub session_id: Option<String>,
41    pub turn_id: Option<String>,
42    pub family: StoryFamily,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum StoryFamily {
48    Turn,
49    Plan,
50    Test,
51    Permission,
52    Command,
53    FileChange,
54    Mcp,
55    Incident,
56    IdleRecap,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum StoryWindowState {
62    Open,
63    Settled,
64}
65
66#[derive(Clone, Debug, Default, Serialize, Deserialize)]
67pub struct StoryCounters {
68    pub events: usize,
69    pub commands: usize,
70    pub files_changed: usize,
71    pub tool_failures: usize,
72    pub tests_passed: usize,
73    pub tests_failed: usize,
74    pub permissions: usize,
75    pub mcp_failures: usize,
76}
77
78#[derive(Clone, Debug, Default, Serialize, Deserialize)]
79pub struct StorySignals {
80    pub highest_score: u8,
81    pub highest_severity: Severity,
82    pub latest_kind: Option<EventKind>,
83    pub latest_title: Option<String>,
84    pub latest_summary: Option<String>,
85    pub latest_tool: Option<String>,
86    pub latest_command_class: Option<String>,
87    pub files: Vec<String>,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize)]
91pub struct StoryWindow {
92    pub key: StoryKey,
93    #[serde(with = "time::serde::rfc3339")]
94    pub opened_at: OffsetDateTime,
95    #[serde(with = "time::serde::rfc3339")]
96    pub last_event_at: OffsetDateTime,
97    pub events: Vec<EventId>,
98    pub counters: StoryCounters,
99    pub signals: StorySignals,
100    pub state: StoryWindowState,
101}
102
103impl StoryWindow {
104    fn new(key: StoryKey, now: OffsetDateTime) -> Self {
105        Self {
106            key,
107            opened_at: now,
108            last_event_at: now,
109            events: Vec::new(),
110            counters: StoryCounters::default(),
111            signals: StorySignals::default(),
112            state: StoryWindowState::Open,
113        }
114    }
115
116    fn observe(&mut self, event: &AgentEvent) {
117        self.last_event_at = event.occurred_at.unwrap_or(event.received_at);
118        self.events.push(event.id.clone());
119        self.counters.events += 1;
120        self.signals.highest_score = self.signals.highest_score.max(score_event(event));
121        if severity_rank(event.severity) > severity_rank(self.signals.highest_severity) {
122            self.signals.highest_severity = event.severity;
123        }
124        self.signals.latest_kind = Some(event.kind);
125        self.signals.latest_title = Some(event.title.clone());
126        self.signals.latest_summary = event.summary.clone();
127        self.signals.latest_tool.clone_from(&event.tool);
128        if let Some(command) = event.command.as_deref().and_then(command_class) {
129            self.signals.latest_command_class = Some(command);
130        }
131        for file in event.files.iter().take(8) {
132            if !self.signals.files.iter().any(|existing| existing == file) {
133                self.signals.files.push(file.clone());
134            }
135        }
136
137        match event.kind {
138            EventKind::CommandExec | EventKind::ToolComplete => self.counters.commands += 1,
139            EventKind::ToolFail => self.counters.tool_failures += 1,
140            EventKind::FileChanged | EventKind::DiffCreated => {
141                self.counters.files_changed += event.files.len().max(1);
142            }
143            EventKind::TestPass => self.counters.tests_passed += 1,
144            EventKind::TestFail => self.counters.tests_failed += 1,
145            EventKind::PermissionRequest | EventKind::PermissionDenied => {
146                self.counters.permissions += 1;
147            }
148            EventKind::McpFail => self.counters.mcp_failures += 1,
149            _ => {}
150        }
151    }
152}
153
154#[derive(Clone, Debug, Serialize, Deserialize)]
155pub struct CompiledStory {
156    pub key: StoryKey,
157    #[serde(with = "time::serde::rfc3339")]
158    pub created_at: OffsetDateTime,
159    pub family: StoryFamily,
160    pub agent: String,
161    pub project: Option<String>,
162    pub headline: String,
163    pub deck: String,
164    pub lower_third: String,
165    pub chips: Vec<String>,
166    pub severity: Severity,
167    pub score: u8,
168    pub context_score: u8,
169    pub privacy: PrivacyClass,
170    pub evidence_event_ids: Vec<String>,
171}
172
173impl CompiledStory {
174    #[must_use]
175    pub fn to_bulletin(&self) -> Bulletin {
176        let mode = match self.family {
177            StoryFamily::Permission | StoryFamily::Incident => BulletinMode::Breaking,
178            StoryFamily::FileChange => BulletinMode::DiffAtlas,
179            StoryFamily::Command => BulletinMode::CommandDesk,
180            StoryFamily::Mcp => BulletinMode::McpWire,
181            StoryFamily::IdleRecap => BulletinMode::Recap,
182            _ => BulletinMode::Dispatch,
183        };
184        let dwell_ms = if matches!(mode, BulletinMode::Breaking) {
185            URGENT_DWELL_MS
186        } else {
187            DEFAULT_DWELL_MS
188        };
189        Bulletin {
190            id: BulletinId::new(),
191            created_at: self.created_at,
192            mode,
193            priority: self.score,
194            dwell_ms,
195            eyebrow: self.eyebrow(),
196            headline: clamp_words(&self.headline, 16),
197            deck: clamp_words(&self.deck, 34),
198            lower_third: self.lower_third.clone(),
199            chips: self
200                .chips
201                .iter()
202                .take(5)
203                .cloned()
204                .map(BulletinChip::new)
205                .collect(),
206            ticker: Vec::new(),
207            image: None,
208            visual: VisualKind::Stage,
209            privacy: self.privacy,
210        }
211    }
212
213    #[must_use]
214    pub fn ticker_item(&self) -> TickerItem {
215        TickerItem::new(format!("{}: {}", self.agent, self.headline))
216    }
217
218    fn eyebrow(&self) -> String {
219        let project = self.project.as_deref().unwrap_or("local");
220        format!(
221            "{} / {} / {:?}",
222            self.agent.to_ascii_uppercase(),
223            project.to_ascii_uppercase(),
224            self.family
225        )
226    }
227}
228
229#[derive(Clone, Debug)]
230pub struct StoryCompiler {
231    config: StoryCompilerConfig,
232    windows: BTreeMap<StoryKey, StoryWindow>,
233    recent_fingerprints: VecDeque<String>,
234}
235
236impl Default for StoryCompiler {
237    fn default() -> Self {
238        Self::new(StoryCompilerConfig::default())
239    }
240}
241
242impl StoryCompiler {
243    #[must_use]
244    pub fn new(config: StoryCompilerConfig) -> Self {
245        Self {
246            config,
247            windows: BTreeMap::new(),
248            recent_fingerprints: VecDeque::new(),
249        }
250    }
251
252    #[must_use]
253    pub fn ingest(&mut self, event: AgentEvent) -> Vec<CompiledStory> {
254        if is_never_publish_event(&event) {
255            self.touch_window(event);
256            return Vec::new();
257        }
258
259        let key = story_key(&event);
260        let now = event.occurred_at.unwrap_or(event.received_at);
261        let mut window = self
262            .windows
263            .remove(&key)
264            .unwrap_or_else(|| StoryWindow::new(key.clone(), now));
265        window.observe(&event);
266
267        if !should_settle(&event, &window) {
268            self.windows.insert(key, window);
269            return Vec::new();
270        }
271
272        window.state = StoryWindowState::Settled;
273        self.compile_window(window)
274            .into_iter()
275            .filter(|story| self.accept_story(story))
276            .collect()
277    }
278
279    #[must_use]
280    pub fn flush(&mut self) -> Vec<CompiledStory> {
281        let windows = std::mem::take(&mut self.windows);
282        let mut stories = Vec::new();
283        for mut window in windows.into_values() {
284            if window.signals.highest_score < self.config.min_score {
285                continue;
286            }
287            window.state = StoryWindowState::Settled;
288            let Some(story) = self.compile_window(window) else {
289                continue;
290            };
291            if self.accept_story(&story) {
292                stories.push(story);
293            }
294        }
295        stories
296    }
297
298    fn touch_window(&mut self, event: AgentEvent) {
299        let key = story_key(&event);
300        let now = event.occurred_at.unwrap_or(event.received_at);
301        self.windows
302            .entry(key.clone())
303            .or_insert_with(|| StoryWindow::new(key, now))
304            .observe(&event);
305    }
306
307    fn compile_window(&self, window: StoryWindow) -> Option<CompiledStory> {
308        let score = window.signals.highest_score;
309        let context_score = context_score(&window);
310        if score < self.config.min_score || context_score < self.config.min_context_score {
311            return None;
312        }
313
314        let headline = headline(&window);
315        let deck = deck(&window);
316        let project = window.key.project_hash.clone();
317        let mut chips = vec![
318            window.key.agent.clone(),
319            story_family_label(window.key.family).to_string(),
320            format!("score {score}"),
321            "redacted".to_string(),
322        ];
323        if let Some(project) = &project {
324            chips.insert(1, project.clone());
325        }
326        if window.counters.files_changed > 0 {
327            chips.insert(2, format!("{} files", window.counters.files_changed));
328        }
329        chips.truncate(5);
330
331        let lower_third = lower_third(&window, score);
332        Some(CompiledStory {
333            key: window.key.clone(),
334            created_at: OffsetDateTime::now_utc(),
335            family: window.key.family,
336            agent: window.key.agent.clone(),
337            project,
338            headline,
339            deck,
340            lower_third,
341            chips,
342            severity: window.signals.highest_severity,
343            score,
344            context_score,
345            privacy: PrivacyClass::Redacted,
346            evidence_event_ids: window
347                .events
348                .iter()
349                .take(12)
350                .map(ToString::to_string)
351                .collect(),
352        })
353    }
354
355    fn accept_story(&mut self, story: &CompiledStory) -> bool {
356        let fingerprint = format!(
357            "{}:{}:{}:{}:{:?}",
358            story.agent,
359            story.project.as_deref().unwrap_or("local"),
360            story.key.session_id.as_deref().unwrap_or("session"),
361            story.key.turn_id.as_deref().unwrap_or("turn"),
362            story.family
363        );
364        if self
365            .recent_fingerprints
366            .iter()
367            .any(|existing| existing == &fingerprint)
368        {
369            return false;
370        }
371        self.recent_fingerprints.push_back(fingerprint);
372        while self.recent_fingerprints.len() > self.config.dedupe_window_events.max(1) {
373            self.recent_fingerprints.pop_front();
374        }
375        true
376    }
377}
378
379#[must_use]
380pub fn compile_events(events: impl IntoIterator<Item = AgentEvent>) -> Vec<CompiledStory> {
381    let mut compiler = StoryCompiler::default();
382    let mut stories = Vec::new();
383    for event in events {
384        stories.extend(compiler.ingest(event));
385    }
386    stories.extend(compiler.flush());
387    stories
388}
389
390fn story_key(event: &AgentEvent) -> StoryKey {
391    StoryKey {
392        feed_id: None,
393        agent: event.agent.clone(),
394        project_hash: event.project.clone(),
395        session_id: event.session_id.clone(),
396        turn_id: event.turn_id.clone(),
397        family: family_for(event.kind),
398    }
399}
400
401fn family_for(kind: EventKind) -> StoryFamily {
402    match kind {
403        EventKind::PlanUpdate => StoryFamily::Plan,
404        EventKind::TestPass | EventKind::TestFail => StoryFamily::Test,
405        EventKind::PermissionRequest | EventKind::PermissionDenied => StoryFamily::Permission,
406        EventKind::CommandExec | EventKind::ToolStart | EventKind::ToolComplete => {
407            StoryFamily::Command
408        }
409        EventKind::FileChanged | EventKind::DiffCreated => StoryFamily::FileChange,
410        EventKind::McpCall | EventKind::McpFail => StoryFamily::Mcp,
411        EventKind::ToolFail | EventKind::TurnFail | EventKind::Error => StoryFamily::Incident,
412        EventKind::SummaryCreated => StoryFamily::IdleRecap,
413        _ => StoryFamily::Turn,
414    }
415}
416
417fn should_settle(event: &AgentEvent, window: &StoryWindow) -> bool {
418    matches!(
419        event.kind,
420        EventKind::TurnComplete
421            | EventKind::TurnFail
422            | EventKind::PlanUpdate
423            | EventKind::TestPass
424            | EventKind::TestFail
425            | EventKind::PermissionRequest
426            | EventKind::PermissionDenied
427            | EventKind::FileChanged
428            | EventKind::ToolFail
429            | EventKind::McpFail
430            | EventKind::SummaryCreated
431    ) || (event.kind.is_urgent() && window.signals.highest_score >= 90)
432}
433
434fn is_never_publish_event(event: &AgentEvent) -> bool {
435    matches!(
436        event.kind,
437        EventKind::SessionStart
438            | EventKind::SessionEnd
439            | EventKind::TurnStart
440            | EventKind::CommandExec
441            | EventKind::ToolStart
442            | EventKind::McpCall
443            | EventKind::WebSearch
444            | EventKind::AgentMessage
445    )
446}
447
448fn context_score(window: &StoryWindow) -> u8 {
449    let mut score = 0u8;
450    if !window.key.agent.is_empty() {
451        score = score.saturating_add(18);
452    }
453    if window.signals.latest_kind.is_some() {
454        score = score.saturating_add(18);
455    }
456    if window.key.project_hash.is_some()
457        || !window.signals.files.is_empty()
458        || window.signals.latest_tool.is_some()
459        || window.signals.latest_command_class.is_some()
460    {
461        score = score.saturating_add(18);
462    }
463    if outcome_label(window).is_some() {
464        score = score.saturating_add(22);
465    }
466    if window.signals.highest_score >= DEFAULT_MIN_SCORE {
467        score = score.saturating_add(16);
468    }
469    if matches!(
470        window.signals.highest_severity,
471        Severity::Warning | Severity::Critical
472    ) {
473        score = score.saturating_add(8);
474    }
475    score.min(100)
476}
477
478fn headline(window: &StoryWindow) -> String {
479    let agent = &window.key.agent;
480    let object = object_label(window);
481    match window.key.family {
482        StoryFamily::Test => {
483            if window.counters.tests_failed > 0 {
484                format!("{agent} found failing tests")
485            } else {
486                format!("{agent} verified tests")
487            }
488        }
489        StoryFamily::Permission => {
490            if window
491                .signals
492                .latest_kind
493                .is_some_and(|kind| kind == EventKind::PermissionDenied)
494            {
495                format!("{agent} hit a permission denial")
496            } else {
497                format!("{agent} requested permission")
498            }
499        }
500        StoryFamily::FileChange => format!("{agent} changed {object}"),
501        StoryFamily::Incident => format!("{agent} hit {}", object),
502        StoryFamily::Mcp => format!("{agent} saw mcp degradation"),
503        StoryFamily::Plan => format!("{agent} updated the plan"),
504        StoryFamily::Command => format!("{agent} completed {object}"),
505        StoryFamily::IdleRecap => format!("{agent} activity settled"),
506        StoryFamily::Turn => format!("{agent} completed {object}"),
507    }
508}
509
510fn deck(window: &StoryWindow) -> String {
511    let mut parts = Vec::new();
512    if window.counters.files_changed > 0 {
513        parts.push(format!(
514            "{} changed files",
515            window.counters.files_changed.min(99)
516        ));
517    }
518    if window.counters.tests_failed > 0 {
519        parts.push("tests are red".to_string());
520    } else if window.counters.tests_passed > 0 {
521        parts.push("tests passed".to_string());
522    }
523    if window.counters.tool_failures > 0 {
524        parts.push(format!("{} tool failures", window.counters.tool_failures));
525    }
526    if window.counters.permissions > 0 {
527        parts.push(format!("{} permission events", window.counters.permissions));
528    }
529    if window.counters.mcp_failures > 0 {
530        parts.push("mcp failed".to_string());
531    }
532    if let Some(summary) = &window.signals.latest_summary
533        && !summary.is_empty()
534        && parts.len() < 2
535        && !summary_is_redundant(summary)
536    {
537        parts.push(safe_sentence(summary));
538    }
539    if parts.is_empty() {
540        if let Some(outcome) = outcome_label(window) {
541            parts.push(outcome.to_string());
542        }
543        parts.push("raw prompts, command output, and diffs omitted".to_string());
544    } else {
545        parts.push("raw detail omitted".to_string());
546    }
547    format!("{}.", parts.join(". "))
548}
549
550fn lower_third(window: &StoryWindow, score: u8) -> String {
551    let mut parts = vec![window.key.agent.clone()];
552    if let Some(project) = &window.key.project_hash {
553        parts.push(project.clone());
554    } else {
555        parts.push("local".to_string());
556    }
557    parts.push(story_family_label(window.key.family).to_string());
558    parts.push(format!("score {score}"));
559    parts.push("redacted".to_string());
560    parts.join(" ยท ")
561}
562
563fn object_label(window: &StoryWindow) -> String {
564    if window.counters.files_changed > 0 {
565        return format!("{} files", window.counters.files_changed.min(99));
566    }
567    if let Some(tool) = &window.signals.latest_tool {
568        return format!("{tool} tool");
569    }
570    if let Some(command) = &window.signals.latest_command_class {
571        return command.clone();
572    }
573    if let Some(project) = &window.key.project_hash {
574        return project.clone();
575    }
576    story_family_label(window.key.family).to_string()
577}
578
579fn outcome_label(window: &StoryWindow) -> Option<&'static str> {
580    match window.signals.latest_kind {
581        Some(EventKind::TurnComplete) => Some("turn completed"),
582        Some(EventKind::TurnFail) => Some("turn failed"),
583        Some(EventKind::ToolComplete) => Some("tool completed"),
584        Some(EventKind::ToolFail) => Some("tool failed"),
585        Some(EventKind::PermissionDenied) => Some("permission denied"),
586        Some(EventKind::PermissionRequest) => Some("permission requested"),
587        Some(EventKind::TestPass) => Some("test passed"),
588        Some(EventKind::TestFail) => Some("test failed"),
589        Some(EventKind::FileChanged) => Some("files changed"),
590        Some(EventKind::DiffCreated) => Some("diff created"),
591        Some(EventKind::McpFail) => Some("mcp failed"),
592        Some(EventKind::PlanUpdate) => Some("plan updated"),
593        Some(EventKind::SummaryCreated) => Some("summary created"),
594        _ => None,
595    }
596}
597
598fn story_family_label(family: StoryFamily) -> &'static str {
599    match family {
600        StoryFamily::Turn => "turn",
601        StoryFamily::Plan => "plan",
602        StoryFamily::Test => "test",
603        StoryFamily::Permission => "permission",
604        StoryFamily::Command => "command",
605        StoryFamily::FileChange => "file-change",
606        StoryFamily::Mcp => "mcp",
607        StoryFamily::Incident => "incident",
608        StoryFamily::IdleRecap => "recap",
609    }
610}
611
612fn command_class(command: &str) -> Option<String> {
613    let first = command.split_whitespace().next()?;
614    if first.is_empty() {
615        None
616    } else {
617        Some(format!("{first} command"))
618    }
619}
620
621fn severity_rank(severity: Severity) -> u8 {
622    match severity {
623        Severity::Debug => 0,
624        Severity::Info => 1,
625        Severity::Notice => 2,
626        Severity::Warning => 3,
627        Severity::Critical => 4,
628    }
629}
630
631fn safe_sentence(input: &str) -> String {
632    let lowered = input.to_ascii_lowercase();
633    if [
634        "secret",
635        "token",
636        "password",
637        "stdout",
638        "stderr",
639        "diff --git",
640    ]
641    .iter()
642    .any(|needle| lowered.contains(needle))
643    {
644        return "display-safe summary recorded".to_string();
645    }
646    clamp_words(input.trim_end_matches(['.', '!', '?']), 20)
647}
648
649fn summary_is_redundant(input: &str) -> bool {
650    let lowered = input.to_ascii_lowercase();
651    [
652        "changed files",
653        "raw diff omitted",
654        "raw output omitted",
655        "status ",
656        "exit ",
657    ]
658    .iter()
659    .any(|needle| lowered.contains(needle))
660}
661
662fn clamp_words(input: &str, max_words: usize) -> String {
663    let mut words = input.split_whitespace();
664    let mut output = Vec::new();
665    for _ in 0..max_words {
666        if let Some(word) = words.next() {
667            output.push(word);
668        }
669    }
670    if words.next().is_some() {
671        format!("{}...", output.join(" "))
672    } else {
673        output.join(" ")
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use agent_reel_core::{SourceKind, TickerItem};
681
682    fn event(kind: EventKind, title: &str) -> AgentEvent {
683        let mut event = AgentEvent::new(SourceKind::Codex, kind, title);
684        event.agent = "codex".to_string();
685        event.project = Some("agent_reel".to_string());
686        event.session_id = Some("session".to_string());
687        event.turn_id = Some("turn".to_string());
688        event
689    }
690
691    #[test]
692    fn low_context_burst_does_not_publish() {
693        let mut compiler = StoryCompiler::default();
694        let mut message = event(EventKind::AgentMessage, "partial token stream");
695        message.summary = Some("assistant message recorded without raw content.".to_string());
696        assert!(compiler.ingest(message).is_empty());
697        assert!(compiler.flush().is_empty());
698    }
699
700    #[test]
701    fn file_change_settles_contextual_story() {
702        let mut compiler = StoryCompiler::default();
703        let mut start = event(EventKind::CommandExec, "codex started a command");
704        start.command = Some("cargo test --all".to_string());
705        assert!(compiler.ingest(start).is_empty());
706
707        let mut changed = event(EventKind::FileChanged, "codex patch applied");
708        changed.files = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
709        changed.summary = Some("2 changed files. raw diff omitted.".to_string());
710        changed.score_hint = Some(82);
711        let stories = compiler.ingest(changed);
712
713        assert_eq!(stories.len(), 1);
714        assert!(stories[0].headline.contains("codex changed"));
715        assert!(stories[0].deck.contains("raw detail omitted"));
716        assert!(!stories[0].deck.contains("cargo test --all"));
717    }
718
719    #[test]
720    fn severe_tool_failure_publishes_breaking_story() {
721        let mut compiler = StoryCompiler::default();
722        let mut failed = event(EventKind::ToolFail, "codex command failed");
723        failed.summary = Some("exit 1. raw output omitted.".to_string());
724        failed.score_hint = Some(92);
725        failed.severity = Severity::Warning;
726
727        let stories = compiler.ingest(failed);
728
729        assert_eq!(stories.len(), 1);
730        assert_eq!(stories[0].family, StoryFamily::Incident);
731        assert!(stories[0].score >= 90);
732        assert_eq!(stories[0].to_bulletin().mode, BulletinMode::Breaking);
733    }
734
735    #[test]
736    fn compiled_story_ticker_is_display_safe() {
737        let mut compiler = StoryCompiler::default();
738        let mut pass = event(EventKind::TestPass, "tests passed");
739        pass.summary = Some("cargo test passed after edit".to_string());
740        pass.score_hint = Some(72);
741        let stories = compiler.ingest(pass);
742        let ticker: TickerItem = stories[0].ticker_item();
743
744        assert!(ticker.text.contains("codex"));
745        assert!(!ticker.text.contains("raw"));
746    }
747}