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 run_budget: None,
601 runners: ctx.agent_runners.clone(),
602 sessions_cache: ctx.sessions_cache.clone(),
603 on_complete: Some(on_complete),
604 child_completion_handler: None,
606 });
607
608 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
609}
610
611pub fn build_schedule_context(
617 base: ScheduleContext,
618 config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
619 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
620) -> ScheduleContext {
621 ScheduleContext {
622 schedule_store: base.schedule_store,
623 agent: base.agent,
624 tools: base.tools,
625 sessions_cache: base.sessions_cache,
626 agent_runners: base.agent_runners,
627 session_event_senders: base.session_event_senders,
628 account_feed_inbox: base.account_feed_inbox,
629 app_data_dir: base.app_data_dir,
630 trigger_engine: base.trigger_engine,
631 persistence: base.persistence,
632 notification_relay: base.notification_relay,
633 resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
634 resolve_run_config_from_config(job, &config, &provider_registry)
635 }),
636 }
637}
638
639fn resolve_run_config_from_config(
640 job: &ScheduleRunJob,
641 config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
642 provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
643) -> ResolvedRunConfig {
644 let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
645
646 let requested_model = job
647 .run_config
648 .model
649 .as_deref()
650 .map(str::trim)
651 .filter(|v| !v.is_empty())
652 .map(|v| v.to_string());
653
654 let model = if let Some(m) = requested_model {
655 m
656 } else {
657 bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
658 .unwrap_or_default()
659 };
660
661 let provider_name = Some(config_snapshot.effective_default_provider().to_string());
662 let provider_type = provider_name.as_deref().and_then(|name| {
663 bamboo_engine::model_config_helper::resolve_provider_type(
664 &config_snapshot,
665 name,
666 provider_registry,
667 )
668 });
669
670 let capability_provider_name = provider_name
671 .as_deref()
672 .unwrap_or(config_snapshot.effective_default_provider());
673 let areas = bamboo_engine::model_areas::resolve_global_area_models(
675 &config_snapshot,
676 capability_provider_name,
677 provider_registry,
678 );
679
680 let requested_reasoning_effort = job.run_config.reasoning_effort;
681 let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
682
683 let global_default_prompt =
684 bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
685 let base_system_prompt = job
686 .run_config
687 .system_prompt
688 .as_deref()
689 .map(str::trim)
690 .filter(|v| !v.is_empty())
691 .unwrap_or(global_default_prompt.as_str());
692
693 let workspace_path = job
694 .run_config
695 .workspace_path
696 .as_deref()
697 .map(str::trim)
698 .filter(|v| !v.is_empty())
699 .map(ToString::to_string)
700 .or_else(|| {
701 config_snapshot
702 .get_default_work_area_path()
703 .map(|path| bamboo_config::paths::path_to_display_string(&path))
704 });
705
706 let enhance_prompt = job
707 .run_config
708 .enhance_prompt
709 .as_deref()
710 .map(str::trim)
711 .filter(|v| !v.is_empty());
712
713 let system_prompt = bamboo_engine::context::assemble_system_prompt(
714 base_system_prompt,
715 enhance_prompt,
716 workspace_path.as_deref(),
717 );
718
719 let model_roster =
720 bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
721
722 ResolvedRunConfig {
723 model_roster,
724 reasoning_effort,
725 gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
726 &config_snapshot,
727 None,
728 ),
729 system_prompt,
730 base_system_prompt: base_system_prompt.to_string(),
731 workspace_path,
732 }
733}
734
735#[cfg(test)]
736mod build_context_tests {
737 use super::resolve_run_config_from_config;
738 use super::ScheduleRunJob;
739 use bamboo_config::DefaultsConfig;
740 use bamboo_config::{OpenAIConfig, ProviderConfigs};
741 use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
742 use bamboo_llm::{Config, ProviderRegistry};
743 use std::collections::HashMap;
744 use std::sync::Arc;
745 use tokio::sync::RwLock;
746
747 fn test_job() -> ScheduleRunJob {
748 ScheduleRunJob {
749 run_id: "run-1".to_string(),
750 schedule_id: "schedule-1".to_string(),
751 schedule_name: "nightly".to_string(),
752 run_config: ScheduleRunConfig::default(),
753 scheduled_for: chrono::Utc::now(),
754 claimed_at: chrono::Utc::now(),
755 was_catch_up: false,
756 }
757 }
758
759 #[test]
760 fn resolve_run_config_from_config_prefers_fast_model() {
761 let config = Config {
762 provider: "openai".to_string(),
763 defaults: None,
764 features: bamboo_config::FeatureFlags {
765 provider_model_ref: false,
766 ..Default::default()
767 },
768 providers: ProviderConfigs {
769 openai: Some(OpenAIConfig {
770 api_key: "test".to_string(),
771 api_key_from_env: false,
772 api_key_encrypted: None,
773 base_url: None,
774 model: Some("gpt-4o".to_string()),
775 fast_model: Some("gpt-4o-mini".to_string()),
776 vision_model: None,
777 reasoning_effort: None,
778 responses_only_models: vec![],
779 request_overrides: None,
780 extra: Default::default(),
781 }),
782 ..ProviderConfigs::default()
783 },
784 ..Config::default()
785 };
786
787 let registry = Arc::new(ProviderRegistry::new(
788 Default::default(),
789 "openai".to_string(),
790 ));
791 let resolved =
792 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
793 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
794 }
795
796 #[test]
797 fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
798 let config = Config {
799 provider: "openai".to_string(),
800 defaults: Some(DefaultsConfig {
801 chat: ProviderModelRef::new("openai", "gpt-chat"),
802 fast: None,
803 task_summary: None,
804 vision: None,
805 memory_background: None,
806 planning: None,
807 search: None,
808 code_review: None,
809 sub_agent: None,
810 subagent_models: HashMap::new(),
811 }),
812 features: bamboo_config::FeatureFlags {
813 provider_model_ref: true,
814 ..Default::default()
815 },
816 providers: ProviderConfigs::default(),
817 ..Config::default()
818 };
819
820 let registry = Arc::new(ProviderRegistry::new(
821 Default::default(),
822 "openai".to_string(),
823 ));
824 let resolved =
825 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
826 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
827 }
828}
829
830#[cfg(test)]
831mod notify_outcome_tests {
832 use super::*;
833 use crate::app_state::session_events::NotificationRelayDeps;
834 use crate::app_state::watchers::SessionWatchers;
835 use std::collections::HashMap;
836 use std::time::Duration;
837 use tokio::sync::RwLock as TokioRwLock;
838
839 fn relay_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
840 let dir = tempfile::tempdir().unwrap();
841 let notification_service = Arc::new(bamboo_notification::NotificationService::new(
842 dir.path().join("prefs.json"),
843 ));
844 let deps = NotificationRelayDeps {
845 notification_service,
846 session_event_senders: Arc::new(TokioRwLock::new(HashMap::new())),
847 session_watchers: SessionWatchers::new(),
848 config: Arc::new(TokioRwLock::new(bamboo_llm::Config::default())),
849 };
850 (deps, dir)
851 }
852
853 #[test]
854 fn truncate_chars_appends_ellipsis_only_when_cut() {
855 assert_eq!(truncate_chars("short", 10), "short");
856 let truncated = truncate_chars(&"x".repeat(300), SCHEDULE_NOTIFY_BODY_MAX);
857 assert_eq!(truncated.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
858 assert!(truncated.ends_with('…'));
859 }
860
861 #[test]
862 fn schedule_run_title_names_the_schedule_and_outcome() {
863 assert_eq!(
864 schedule_run_title("nightly", true),
865 "Schedule 'nightly' completed"
866 );
867 assert_eq!(
868 schedule_run_title("nightly", false),
869 "Schedule 'nightly' failed"
870 );
871 }
872
873 #[test]
874 fn final_assistant_excerpt_finds_the_last_non_empty_assistant_message() {
875 let messages = vec![
876 Message::user("hi"),
877 Message::assistant("first reply", None),
878 Message::assistant(" ", None), Message::assistant("final reply", None),
880 ];
881 assert_eq!(
882 final_assistant_excerpt(&messages).as_deref(),
883 Some("final reply")
884 );
885 }
886
887 #[test]
888 fn final_assistant_excerpt_truncates_long_content() {
889 let long = "x".repeat(300);
890 let messages = vec![Message::assistant(long, None)];
891 let excerpt = final_assistant_excerpt(&messages).unwrap();
892 assert_eq!(excerpt.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
893 assert!(excerpt.ends_with('…'));
894 }
895
896 #[test]
897 fn final_assistant_excerpt_none_when_no_assistant_message() {
898 let messages = vec![Message::user("hi")];
899 assert!(final_assistant_excerpt(&messages).is_none());
900 }
901
902 #[tokio::test]
912 async fn notify_schedule_run_outcome_is_deduped_by_a_prior_generic_complete() {
913 let (deps, _dir) = relay_deps();
914 let (tx, mut rx) = broadcast::channel(16);
915 deps.session_event_senders
916 .write()
917 .await
918 .insert("sess-1".to_string(), tx.clone());
919
920 let relay_fired = deps.notification_service.notify(
924 "sess-1",
925 &AgentEvent::Complete {
926 usage: bamboo_agent_core::TokenUsage {
927 prompt_tokens: 1,
928 completion_tokens: 1,
929 total_tokens: 2,
930 },
931 },
932 );
933 assert!(relay_fired.is_some());
934
935 notify_schedule_run_outcome(
936 &deps,
937 "sess-1",
938 true,
939 "Schedule 'nightly' completed".to_string(),
940 "All done.".to_string(),
941 )
942 .await;
943
944 let outcome = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
947 assert!(
948 outcome.is_err(),
949 "deduped schedule-level notification must not broadcast a second event"
950 );
951 }
952
953 #[tokio::test]
957 async fn notify_schedule_run_outcome_first_dedups_a_later_generic_complete() {
958 let (deps, _dir) = relay_deps();
959
960 notify_schedule_run_outcome(
961 &deps,
962 "sess-2",
963 true,
964 "Schedule 'nightly' completed".to_string(),
965 "All done.".to_string(),
966 )
967 .await;
968
969 let relay_fired = deps.notification_service.notify(
970 "sess-2",
971 &AgentEvent::Complete {
972 usage: bamboo_agent_core::TokenUsage {
973 prompt_tokens: 1,
974 completion_tokens: 1,
975 total_tokens: 2,
976 },
977 },
978 );
979 assert!(
980 relay_fired.is_none(),
981 "the later generic classification must be deduped away"
982 );
983 }
984}