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}