1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use chrono::Utc;
6use tokio::sync::{broadcast, mpsc, RwLock};
7
8use bamboo_agent_core::tools::ToolExecutor;
9use bamboo_agent_core::{AgentEvent, Message, Role};
10use bamboo_domain::reasoning::ReasoningEffort;
11use bamboo_engine::config::GoldConfig;
12use bamboo_engine::execution::{
13 create_event_forwarder, get_or_create_event_sender, spawn_session_execution,
14 try_reserve_runner, AgentRunner, RunnerReservation, SessionCompletionHook,
15 SessionExecutionArgs,
16};
17use bamboo_engine::{AuxiliaryModelConfig, ModelRoster};
18use bamboo_storage::LockedSessionStore;
19
20use super::store::{ClaimedScheduleRun, ScheduleStore};
21use super::trigger_engine::DynTriggerEngine;
22use bamboo_domain::{ScheduleRunConfig, ScheduleRunStatus};
23
24#[derive(Debug, Clone)]
25pub struct ScheduleRunJob {
26 pub run_id: String,
27 pub schedule_id: String,
28 pub schedule_name: String,
29 pub run_config: ScheduleRunConfig,
30 pub scheduled_for: chrono::DateTime<chrono::Utc>,
31 pub claimed_at: chrono::DateTime<chrono::Utc>,
32 pub was_catch_up: bool,
33}
34
35#[derive(Clone)]
41pub struct ResolvedRunConfig {
42 pub model_roster: ModelRoster,
45 pub reasoning_effort: Option<ReasoningEffort>,
46 pub gold_config: Option<GoldConfig>,
47 pub system_prompt: String,
48 pub base_system_prompt: String,
49 pub workspace_path: Option<String>,
50}
51
52#[derive(Clone)]
53pub struct ScheduleContext {
54 pub schedule_store: Arc<ScheduleStore>,
55 pub agent: Arc<bamboo_engine::Agent>,
56 pub persistence: Arc<LockedSessionStore>,
57 pub tools: Arc<dyn ToolExecutor>,
58 pub sessions_cache: bamboo_engine::SessionCache,
59 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
60 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
61 pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
63 pub app_data_dir: Option<std::path::PathBuf>,
64 pub trigger_engine: DynTriggerEngine,
65 pub notification_relay: crate::app_state::session_events::NotificationRelayDeps,
71 pub resolve_run_config: Arc<dyn Fn(&ScheduleRunJob) -> ResolvedRunConfig + Send + Sync>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum ScheduleRunLifecycleResult {
78 Terminal(ScheduleRunStatus),
79 BackgroundExecutionInProgress,
80}
81
82#[derive(Clone)]
83pub struct ScheduleManager {
84 tx: mpsc::Sender<ScheduleRunJob>,
85}
86
87impl ScheduleManager {
88 pub fn new(ctx: ScheduleContext) -> Self {
89 let (tx, mut rx) = mpsc::channel::<ScheduleRunJob>(128);
90
91 tokio::spawn({
93 let ctx = ctx.clone();
94 async move {
95 while let Some(job) = rx.recv().await {
96 if let Err(error) = ctx
97 .schedule_store
98 .mark_run_started(&job.schedule_id, &job.run_id)
99 .await
100 {
101 tracing::warn!(
102 "failed to mark schedule run started for {} / {}: {}",
103 job.schedule_id,
104 job.run_id,
105 error
106 );
107 }
108 let schedule_id = job.schedule_id.clone();
109 let run_id = job.run_id.clone();
110 match run_schedule_job(ctx.clone(), job).await {
111 Ok(ScheduleRunLifecycleResult::Terminal(status)) => {
112 if let Err(error) = ctx
113 .schedule_store
114 .mark_run_terminal(&schedule_id, &run_id, status, None)
115 .await
116 {
117 tracing::warn!(
118 "failed to mark schedule run terminal state for {} / {}: {}",
119 schedule_id,
120 run_id,
121 error
122 );
123 }
124 }
125 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress) => {}
126 Err(e) => {
127 tracing::warn!("schedule job failed: {e}");
128 if let Err(error) = ctx
129 .schedule_store
130 .mark_run_terminal(
131 &schedule_id,
132 &run_id,
133 ScheduleRunStatus::Failed,
134 Some(e.clone()),
135 )
136 .await
137 {
138 tracing::warn!(
139 "failed to mark schedule run failed state for {} / {}: {}",
140 schedule_id,
141 run_id,
142 error
143 );
144 }
145 }
146 }
147 }
148 }
149 });
150
151 tokio::spawn({
153 let tx = tx.clone();
154 let store = ctx.schedule_store.clone();
155 let trigger_engine = ctx.trigger_engine.clone();
156 async move {
157 let mut ticker = tokio::time::interval(Duration::from_secs(15));
158 loop {
159 ticker.tick().await;
160 let now = Utc::now();
161 let claimed: Vec<ClaimedScheduleRun> = match store
162 .claim_due_runs_with_engine(now, trigger_engine.as_ref())
163 .await
164 {
165 Ok(v) => v,
166 Err(e) => {
167 tracing::warn!("claim_due_runs failed: {e}");
168 continue;
169 }
170 };
171 for c in claimed {
172 let schedule_id = c.schedule_id.clone();
173 let run_id = c.run_id.clone();
174 if tx
175 .send(ScheduleRunJob {
176 run_id: c.run_id,
177 schedule_id: c.schedule_id,
178 schedule_name: c.schedule_name,
179 run_config: c.run_config,
180 scheduled_for: c.scheduled_for,
181 claimed_at: c.claimed_at,
182 was_catch_up: c.was_catch_up,
183 })
184 .await
185 .is_err()
186 {
187 let _ = store
188 .mark_run_dequeued_without_start(
189 &schedule_id,
190 &run_id,
191 Some("schedule manager is not running".to_string()),
192 )
193 .await;
194 }
195 }
196 }
197 }
198 });
199
200 Self { tx }
201 }
202
203 pub async fn enqueue_run_now(&self, job: ScheduleRunJob) -> Result<(), String> {
204 self.tx
205 .send(job)
206 .await
207 .map_err(|_| "schedule manager is not running".to_string())
208 }
209}
210
211const SCHEDULE_NOTIFY_BODY_MAX: usize = 200;
216
217fn truncate_chars(text: &str, max: usize) -> String {
220 if text.chars().count() <= max {
221 return text.to_string();
222 }
223 let mut out: String = text.chars().take(max).collect();
224 out.push('…');
225 out
226}
227
228fn schedule_run_title(schedule_name: &str, success: bool) -> String {
230 if success {
231 format!("Schedule '{schedule_name}' completed")
232 } else {
233 format!("Schedule '{schedule_name}' failed")
234 }
235}
236
237fn final_assistant_excerpt(messages: &[Message]) -> Option<String> {
244 messages
245 .iter()
246 .rev()
247 .find(|m| matches!(m.role, Role::Assistant) && !m.content.trim().is_empty())
248 .map(|m| truncate_chars(m.content.trim(), SCHEDULE_NOTIFY_BODY_MAX))
249}
250
251async fn notify_schedule_run_outcome(
267 relay: &crate::app_state::session_events::NotificationRelayDeps,
268 session_id: &str,
269 success: bool,
270 title: String,
271 body: String,
272) {
273 let Some(notification) = relay
274 .notification_service
275 .notify_schedule_run(session_id, success, title, body)
276 else {
277 return;
280 };
281
282 let sink_notification = crate::notify_sinks::SinkNotification::from_event(¬ification);
285
286 let tx = relay
287 .session_event_senders
288 .read()
289 .await
290 .get(session_id)
291 .cloned();
292 if let Some(tx) = tx {
293 let _ = tx.send(notification);
294 }
295
296 if let Some(sink_notification) = sink_notification {
297 let has_watcher = relay.session_watchers.has_watcher(session_id);
298 let config_snapshot = relay.config.read().await.clone();
299 crate::AppState::dispatch_to_sinks(&config_snapshot, has_watcher, &sink_notification);
300 }
301}
302
303async fn run_schedule_job(
304 ctx: ScheduleContext,
305 job: ScheduleRunJob,
306) -> Result<ScheduleRunLifecycleResult, String> {
307 let resolved = (ctx.resolve_run_config)(&job);
308 let resolved_model = resolved.model_roster.model.clone().unwrap_or_default();
312
313 if resolved_model.trim().is_empty() {
315 tracing::warn!(
316 "[schedule:{}] skipping run: resolved model is empty",
317 job.schedule_id
318 );
319 return Ok(ScheduleRunLifecycleResult::Terminal(
320 ScheduleRunStatus::Skipped,
321 ));
322 }
323
324 let requested_model = job
325 .run_config
326 .model
327 .as_deref()
328 .map(str::trim)
329 .filter(|v| !v.is_empty())
330 .map(|v| v.to_string());
331 let requested_reasoning_effort = job.run_config.reasoning_effort;
332
333 let mut session = super::session_factory::create_schedule_session(
334 &job,
335 &resolved_model,
336 &resolved.system_prompt,
337 &resolved.base_system_prompt,
338 resolved.workspace_path.as_deref(),
339 resolved.reasoning_effort,
340 );
341 let session_id = session.id.clone();
342
343 session
348 .agent_runtime_state
349 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
350 .no_human_approver = true;
351
352 ctx.persistence
354 .merge_save_runtime(&mut session)
355 .await
356 .map_err(|e| format!("failed to save scheduled session: {e}"))?;
357 if let Err(error) = ctx
358 .schedule_store
359 .bind_run_session(&job.schedule_id, &job.run_id, &session_id)
360 .await
361 {
362 tracing::warn!(
363 "failed to bind session {} to schedule run {} / {}: {}",
364 session_id,
365 job.schedule_id,
366 job.run_id,
367 error
368 );
369 }
370 ctx.sessions_cache.insert(
371 session_id.clone(),
372 Arc::new(parking_lot::RwLock::new(session.clone())),
373 );
374
375 let should_execute = job.run_config.auto_execute
377 && session
378 .messages
379 .last()
380 .map(|m| matches!(m.role, Role::User))
381 .unwrap_or(false);
382
383 tracing::info!(
384 "[schedule:{}] created session {} (auto_execute={}, model={}, model_source={}, reasoning_effort={}, reasoning_source={})",
385 job.schedule_id,
386 session_id,
387 job.run_config.auto_execute,
388 resolved_model,
389 if requested_model.is_some() {
390 "schedule.run_config.model"
391 } else {
392 "resolved"
393 },
394 resolved.reasoning_effort.map(|value| value.as_str()).unwrap_or("none"),
395 if requested_reasoning_effort.is_some() {
396 "schedule.run_config.reasoning_effort"
397 } else {
398 "resolved"
399 }
400 );
401 if !should_execute {
402 return Ok(ScheduleRunLifecycleResult::Terminal(
403 ScheduleRunStatus::Success,
404 ));
405 }
406
407 if resolved_model.trim().is_empty() {
409 let msg = "resolved model is empty".to_string();
410 session.add_message(Message::assistant(format!("❌ {msg}"), None));
411 let _ = ctx.persistence.merge_save_runtime(&mut session).await;
412 return Err(msg);
413 }
414
415 let session_tx = get_or_create_event_sender(&ctx.session_event_senders, &session_id).await;
416
417 crate::app_state::session_events::ensure_notification_relay(
424 &ctx.notification_relay,
425 &session_id,
426 session_tx.clone(),
427 );
428
429 let Some(RunnerReservation { cancel_token, .. }) = try_reserve_runner(
431 &ctx.agent_runners,
432 &ctx.session_event_senders,
433 &session_id,
434 &session_tx,
435 )
436 .await
437 else {
438 return Ok(ScheduleRunLifecycleResult::Terminal(
439 ScheduleRunStatus::Skipped,
440 ));
441 };
442
443 let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
444 session_id.clone(),
445 session_tx.clone(),
446 ctx.agent_runners.clone(),
447 ctx.account_feed_inbox.clone(),
448 );
449
450 let aux_fast_model = resolved.model_roster.fast_model();
457 let aux_fast_provider = resolved.model_roster.fast_model_provider();
458 let aux_background_model = resolved.model_roster.background_model();
459 let aux_background_provider = resolved.model_roster.background_model_provider();
460 let aux_summarization_model = resolved.model_roster.summarization_model();
461 let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
462 let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
463 fast_model_name: aux_fast_model.clone(),
464 fast_model_provider: aux_fast_provider.clone(),
465 background_model_name: aux_background_model.clone(),
466 planning_model_name: None,
467 search_model_name: None,
468 summarization_model_name: aux_summarization_model.clone(),
469 background_model_provider: aux_background_provider.clone(),
470 summarization_model_provider: aux_summarization_provider.clone(),
471 });
472
473 let schedule_store = ctx.schedule_store.clone();
474 let schedule_id_for_state = job.schedule_id.clone();
475 let run_id_for_state = job.run_id.clone();
476 let log_session_id = session_id.clone();
477 let schedule_name_for_notify = job.schedule_name.clone();
478 let notification_relay_for_hook = ctx.notification_relay.clone();
479
480 let on_complete: SessionCompletionHook = Box::new(move |outcome, session| {
481 Box::pin(async move {
482 let terminal_status = if outcome.success {
483 tracing::info!(
484 "[schedule:{}][run:{}][session:{}] scheduled run completed",
485 schedule_id_for_state,
486 run_id_for_state,
487 log_session_id
488 );
489 ScheduleRunStatus::Success
490 } else {
491 let detail = outcome.error.as_deref().unwrap_or("unknown error");
492 session.add_message(Message::assistant(
495 format!("❌ Scheduled run failed: {detail}"),
496 None,
497 ));
498 tracing::warn!(
499 "[schedule:{}][run:{}][session:{}] scheduled run failed: {}",
500 schedule_id_for_state,
501 run_id_for_state,
502 log_session_id,
503 detail
504 );
505 if outcome.cancelled {
506 ScheduleRunStatus::Cancelled
507 } else {
508 ScheduleRunStatus::Failed
509 }
510 };
511
512 let notify_title = schedule_run_title(&schedule_name_for_notify, outcome.success);
520 let notify_body = final_assistant_excerpt(&session.messages).unwrap_or_else(|| {
521 if outcome.success {
522 "Run completed.".to_string()
523 } else {
524 format!(
525 "Run failed: {}",
526 outcome.error.as_deref().unwrap_or("unknown error")
527 )
528 }
529 });
530 notify_schedule_run_outcome(
531 ¬ification_relay_for_hook,
532 &log_session_id,
533 outcome.success,
534 notify_title,
535 notify_body,
536 )
537 .await;
538
539 if let Err(error) = schedule_store
540 .mark_run_terminal(
541 &schedule_id_for_state,
542 &run_id_for_state,
543 terminal_status,
544 None,
545 )
546 .await
547 {
548 tracing::warn!(
549 "failed to mark schedule run terminal state for {} / {}: {}",
550 schedule_id_for_state,
551 run_id_for_state,
552 error
553 );
554 }
555 })
556 });
557
558 spawn_session_execution(SessionExecutionArgs {
559 agent: ctx.agent.clone(),
560 session_id,
561 session,
562 tools_override: Some(ctx.tools.clone()),
563 provider_override: None,
564 model_roster: resolved.model_roster.clone(),
565 reasoning_effort: resolved.reasoning_effort,
566 reasoning_effort_source: "schedule".to_string(),
567 auxiliary_model_resolver: Some(auxiliary_model_resolver),
568 disabled_filter_resolver: None,
571 disabled_tools: None,
572 disabled_skill_ids: None,
573 selected_skill_ids: None,
574 selected_skill_mode: None,
575 cancel_token,
576 mpsc_tx,
577 image_fallback: None,
578 gold_config: resolved.gold_config.clone(),
579 guardian_config: None,
581 guardian_spawner: None,
582 bash_resume_hook: None,
594 bash_completion_sink: None,
597 app_data_dir: ctx.app_data_dir.clone(),
598 runners: ctx.agent_runners.clone(),
599 sessions_cache: ctx.sessions_cache.clone(),
600 on_complete: Some(on_complete),
601 });
602
603 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
604}
605
606pub fn build_schedule_context(
612 base: ScheduleContext,
613 config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
614 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
615) -> ScheduleContext {
616 ScheduleContext {
617 schedule_store: base.schedule_store,
618 agent: base.agent,
619 tools: base.tools,
620 sessions_cache: base.sessions_cache,
621 agent_runners: base.agent_runners,
622 session_event_senders: base.session_event_senders,
623 account_feed_inbox: base.account_feed_inbox,
624 app_data_dir: base.app_data_dir,
625 trigger_engine: base.trigger_engine,
626 persistence: base.persistence,
627 notification_relay: base.notification_relay,
628 resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
629 resolve_run_config_from_config(job, &config, &provider_registry)
630 }),
631 }
632}
633
634fn resolve_run_config_from_config(
635 job: &ScheduleRunJob,
636 config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
637 provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
638) -> ResolvedRunConfig {
639 let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
640
641 let requested_model = job
642 .run_config
643 .model
644 .as_deref()
645 .map(str::trim)
646 .filter(|v| !v.is_empty())
647 .map(|v| v.to_string());
648
649 let model = if let Some(m) = requested_model {
650 m
651 } else {
652 bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
653 .unwrap_or_default()
654 };
655
656 let provider_name = Some(config_snapshot.effective_default_provider().to_string());
657 let provider_type = provider_name.as_deref().and_then(|name| {
658 bamboo_engine::model_config_helper::resolve_provider_type(
659 &config_snapshot,
660 name,
661 provider_registry,
662 )
663 });
664
665 let capability_provider_name = provider_name
666 .as_deref()
667 .unwrap_or(config_snapshot.effective_default_provider());
668 let areas = bamboo_engine::model_areas::resolve_global_area_models(
670 &config_snapshot,
671 capability_provider_name,
672 provider_registry,
673 );
674
675 let requested_reasoning_effort = job.run_config.reasoning_effort;
676 let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
677
678 let global_default_prompt =
679 bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
680 let base_system_prompt = job
681 .run_config
682 .system_prompt
683 .as_deref()
684 .map(str::trim)
685 .filter(|v| !v.is_empty())
686 .unwrap_or(global_default_prompt.as_str());
687
688 let workspace_path = job
689 .run_config
690 .workspace_path
691 .as_deref()
692 .map(str::trim)
693 .filter(|v| !v.is_empty())
694 .map(ToString::to_string)
695 .or_else(|| {
696 config_snapshot
697 .get_default_work_area_path()
698 .map(|path| bamboo_config::paths::path_to_display_string(&path))
699 });
700
701 let enhance_prompt = job
702 .run_config
703 .enhance_prompt
704 .as_deref()
705 .map(str::trim)
706 .filter(|v| !v.is_empty());
707
708 let system_prompt = bamboo_engine::context::assemble_system_prompt(
709 base_system_prompt,
710 enhance_prompt,
711 workspace_path.as_deref(),
712 );
713
714 let model_roster =
715 bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
716
717 ResolvedRunConfig {
718 model_roster,
719 reasoning_effort,
720 gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
721 &config_snapshot,
722 None,
723 ),
724 system_prompt,
725 base_system_prompt: base_system_prompt.to_string(),
726 workspace_path,
727 }
728}
729
730#[cfg(test)]
731mod build_context_tests {
732 use super::resolve_run_config_from_config;
733 use super::ScheduleRunJob;
734 use bamboo_config::DefaultsConfig;
735 use bamboo_config::{OpenAIConfig, ProviderConfigs};
736 use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
737 use bamboo_llm::{Config, ProviderRegistry};
738 use std::collections::HashMap;
739 use std::sync::Arc;
740 use tokio::sync::RwLock;
741
742 fn test_job() -> ScheduleRunJob {
743 ScheduleRunJob {
744 run_id: "run-1".to_string(),
745 schedule_id: "schedule-1".to_string(),
746 schedule_name: "nightly".to_string(),
747 run_config: ScheduleRunConfig::default(),
748 scheduled_for: chrono::Utc::now(),
749 claimed_at: chrono::Utc::now(),
750 was_catch_up: false,
751 }
752 }
753
754 #[test]
755 fn resolve_run_config_from_config_prefers_fast_model() {
756 let config = Config {
757 provider: "openai".to_string(),
758 defaults: None,
759 features: bamboo_config::FeatureFlags {
760 provider_model_ref: false,
761 ..Default::default()
762 },
763 providers: ProviderConfigs {
764 openai: Some(OpenAIConfig {
765 api_key: "test".to_string(),
766 api_key_from_env: false,
767 api_key_encrypted: None,
768 base_url: None,
769 model: Some("gpt-4o".to_string()),
770 fast_model: Some("gpt-4o-mini".to_string()),
771 vision_model: None,
772 reasoning_effort: None,
773 responses_only_models: vec![],
774 request_overrides: None,
775 extra: Default::default(),
776 }),
777 ..ProviderConfigs::default()
778 },
779 ..Config::default()
780 };
781
782 let registry = Arc::new(ProviderRegistry::new(
783 Default::default(),
784 "openai".to_string(),
785 ));
786 let resolved =
787 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
788 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
789 }
790
791 #[test]
792 fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
793 let config = Config {
794 provider: "openai".to_string(),
795 defaults: Some(DefaultsConfig {
796 chat: ProviderModelRef::new("openai", "gpt-chat"),
797 fast: None,
798 task_summary: None,
799 vision: None,
800 memory_background: None,
801 planning: None,
802 search: None,
803 code_review: None,
804 sub_agent: None,
805 subagent_models: HashMap::new(),
806 }),
807 features: bamboo_config::FeatureFlags {
808 provider_model_ref: true,
809 ..Default::default()
810 },
811 providers: ProviderConfigs::default(),
812 ..Config::default()
813 };
814
815 let registry = Arc::new(ProviderRegistry::new(
816 Default::default(),
817 "openai".to_string(),
818 ));
819 let resolved =
820 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
821 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
822 }
823}
824
825#[cfg(test)]
826mod notify_outcome_tests {
827 use super::*;
828 use crate::app_state::session_events::NotificationRelayDeps;
829 use crate::app_state::watchers::SessionWatchers;
830 use std::collections::HashMap;
831 use std::time::Duration;
832 use tokio::sync::RwLock as TokioRwLock;
833
834 fn relay_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
835 let dir = tempfile::tempdir().unwrap();
836 let notification_service = Arc::new(bamboo_notification::NotificationService::new(
837 dir.path().join("prefs.json"),
838 ));
839 let deps = NotificationRelayDeps {
840 notification_service,
841 session_event_senders: Arc::new(TokioRwLock::new(HashMap::new())),
842 session_watchers: SessionWatchers::new(),
843 config: Arc::new(TokioRwLock::new(bamboo_llm::Config::default())),
844 };
845 (deps, dir)
846 }
847
848 #[test]
849 fn truncate_chars_appends_ellipsis_only_when_cut() {
850 assert_eq!(truncate_chars("short", 10), "short");
851 let truncated = truncate_chars(&"x".repeat(300), SCHEDULE_NOTIFY_BODY_MAX);
852 assert_eq!(truncated.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
853 assert!(truncated.ends_with('…'));
854 }
855
856 #[test]
857 fn schedule_run_title_names_the_schedule_and_outcome() {
858 assert_eq!(
859 schedule_run_title("nightly", true),
860 "Schedule 'nightly' completed"
861 );
862 assert_eq!(
863 schedule_run_title("nightly", false),
864 "Schedule 'nightly' failed"
865 );
866 }
867
868 #[test]
869 fn final_assistant_excerpt_finds_the_last_non_empty_assistant_message() {
870 let messages = vec![
871 Message::user("hi"),
872 Message::assistant("first reply", None),
873 Message::assistant(" ", None), Message::assistant("final reply", None),
875 ];
876 assert_eq!(
877 final_assistant_excerpt(&messages).as_deref(),
878 Some("final reply")
879 );
880 }
881
882 #[test]
883 fn final_assistant_excerpt_truncates_long_content() {
884 let long = "x".repeat(300);
885 let messages = vec![Message::assistant(long, None)];
886 let excerpt = final_assistant_excerpt(&messages).unwrap();
887 assert_eq!(excerpt.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
888 assert!(excerpt.ends_with('…'));
889 }
890
891 #[test]
892 fn final_assistant_excerpt_none_when_no_assistant_message() {
893 let messages = vec![Message::user("hi")];
894 assert!(final_assistant_excerpt(&messages).is_none());
895 }
896
897 #[tokio::test]
907 async fn notify_schedule_run_outcome_is_deduped_by_a_prior_generic_complete() {
908 let (deps, _dir) = relay_deps();
909 let (tx, mut rx) = broadcast::channel(16);
910 deps.session_event_senders
911 .write()
912 .await
913 .insert("sess-1".to_string(), tx.clone());
914
915 let relay_fired = deps.notification_service.notify(
919 "sess-1",
920 &AgentEvent::Complete {
921 usage: bamboo_agent_core::TokenUsage {
922 prompt_tokens: 1,
923 completion_tokens: 1,
924 total_tokens: 2,
925 },
926 },
927 );
928 assert!(relay_fired.is_some());
929
930 notify_schedule_run_outcome(
931 &deps,
932 "sess-1",
933 true,
934 "Schedule 'nightly' completed".to_string(),
935 "All done.".to_string(),
936 )
937 .await;
938
939 let outcome = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
942 assert!(
943 outcome.is_err(),
944 "deduped schedule-level notification must not broadcast a second event"
945 );
946 }
947
948 #[tokio::test]
952 async fn notify_schedule_run_outcome_first_dedups_a_later_generic_complete() {
953 let (deps, _dir) = relay_deps();
954
955 notify_schedule_run_outcome(
956 &deps,
957 "sess-2",
958 true,
959 "Schedule 'nightly' completed".to_string(),
960 "All done.".to_string(),
961 )
962 .await;
963
964 let relay_fired = deps.notification_service.notify(
965 "sess-2",
966 &AgentEvent::Complete {
967 usage: bamboo_agent_core::TokenUsage {
968 prompt_tokens: 1,
969 completion_tokens: 1,
970 total_tokens: 2,
971 },
972 },
973 );
974 assert!(
975 relay_fired.is_none(),
976 "the later generic classification must be deduped away"
977 );
978 }
979}