1use std::cmp::Reverse;
2use std::collections::HashSet;
3use std::sync::Arc;
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use futures::StreamExt;
8use tokio::sync::RwLock;
9
10use bamboo_agent_core::{Message, SessionKind};
11use bamboo_domain::reasoning::ReasoningEffort;
12use bamboo_llm::Config;
13use bamboo_llm::{LLMChunk, LLMProvider, LLMRequestOptions};
14use bamboo_llm::{ProviderModelRouter, ProviderRegistry};
15use bamboo_memory::auto_dream::{
16 build_consolidation_prompt, build_extraction_prompt, build_rebuild_consolidation_prompt,
17 build_refine_consolidation_prompt, derive_session_outline, normalize_dream_notebook_body,
18 normalize_existing_dream_for_prompt, parse_candidate_scope, parse_candidate_type,
19 parse_extraction_candidates, parse_last_consolidated_at, parse_last_full_rebuild_at,
20 should_force_full_rebuild, should_use_dream_refine_mode, truncate_chars,
21 ConsolidationSessionInfo, DreamCandidateInfo, DreamGenerationMode,
22};
23use bamboo_memory::memory_store::{MemoryScope, MemoryStore};
24use bamboo_storage::{SessionIndexEntry, SessionStoreV2};
25
26const DREAM_RUNTIME_SESSION_ID: &str = "__dream__";
27const DREAM_TRACING_TARGET: &str = "bamboo.auto_dream";
28const DREAM_FULL_REBUILD_INTERVAL_SECS: i64 = 60 * 60 * 24 * 30;
31const DREAM_MAX_SESSIONS: usize = 12;
32const DREAM_MAX_SUMMARY_CHARS: usize = 12_000;
33const EXTRACTION_MAX_TOPICS_PER_SESSION: usize = 4;
34const EXTRACTION_MAX_TOPIC_CHARS: usize = 1_500;
35const EXTRACTION_MAX_CANDIDATES: usize = 8;
36
37fn to_consolidation_sessions(
38 entries: &[(SessionIndexEntry, Option<String>)],
39) -> Vec<ConsolidationSessionInfo> {
40 entries
41 .iter()
42 .map(|(entry, summary)| ConsolidationSessionInfo {
43 id: entry.id.clone(),
44 title: entry.title.clone(),
45 kind: format!("{:?}", entry.kind),
46 updated_at: entry.updated_at.to_rfc3339(),
47 message_count: entry.message_count,
48 last_run_status: entry.last_run_status.clone(),
49 summary: summary.clone(),
50 })
51 .collect()
52}
53
54#[derive(Clone)]
55pub struct AutoDreamContext {
56 pub session_store: Arc<SessionStoreV2>,
57 pub storage: Arc<dyn bamboo_agent_core::storage::Storage>,
58 pub provider: Arc<dyn LLMProvider>,
59 pub config: Arc<RwLock<Config>>,
60 pub provider_registry: Arc<ProviderRegistry>,
61}
62
63fn memory_store_for_context(ctx: &AutoDreamContext) -> MemoryStore {
64 MemoryStore::new(ctx.session_store.bamboo_home_dir())
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct AutoDreamRunResult {
69 pub used_model: String,
70 pub session_count: usize,
71 pub note_path: std::path::PathBuf,
72 pub notebook_chars: usize,
73}
74
75#[derive(Debug, Clone)]
76struct CandidateSessionContext {
77 entry: SessionIndexEntry,
78 summary: Option<String>,
79 session_id: String,
80 project_key: Option<String>,
81 topics: Vec<(String, String)>,
82}
83
84#[derive(Debug, Clone)]
85struct DreamSourceWindow {
86 existing_dream: Option<String>,
87 recent_durable_memory: Option<String>,
88 durable_memory_index: Option<String>,
89 sessions: Vec<(SessionIndexEntry, Option<String>)>,
90}
91
92fn session_is_candidate(entry: &SessionIndexEntry, since: DateTime<Utc>) -> bool {
93 matches!(entry.kind, SessionKind::Root)
94 && entry.updated_at >= since
95 && !entry.id.trim().is_empty()
96 && entry.id != DREAM_RUNTIME_SESSION_ID
97}
98
99async fn collect_candidate_sessions(
100 ctx: &AutoDreamContext,
101 since: DateTime<Utc>,
102) -> Vec<(SessionIndexEntry, Option<String>)> {
103 let mut items = ctx.session_store.list_index_entries().await;
104 items.retain(|entry| session_is_candidate(entry, since));
105 items.sort_by_key(|e| Reverse(e.updated_at));
106
107 let mut seen_roots = HashSet::new();
108 let mut out = Vec::new();
109 for entry in items.into_iter() {
110 if !seen_roots.insert(entry.root_session_id.clone()) {
111 continue;
112 }
113 let summary = match ctx.storage.load_session(&entry.id).await {
114 Ok(Some(session)) => session
115 .conversation_summary
116 .as_ref()
117 .map(|summary| summary.content.clone())
118 .or_else(|| derive_session_outline(&session)),
119 _ => None,
120 };
121 out.push((entry, summary));
122 if out.len() >= DREAM_MAX_SESSIONS {
123 break;
124 }
125 }
126 out
127}
128
129async fn resolve_session_project_key(
130 ctx: &AutoDreamContext,
131 memory: &MemoryStore,
132 session_id: &str,
133) -> Option<String> {
134 ctx.storage
135 .load_session(session_id)
136 .await
137 .ok()
138 .flatten()
139 .and_then(|session| session.workspace_path_meta())
140 .map(std::path::PathBuf::from)
141 .map(|path| bamboo_memory::memory_store::project_key_from_path(&path))
142 .or_else(|| memory.project_key_for_session(Some(session_id)))
143}
144
145async fn collect_candidate_sessions_for_project(
146 ctx: &AutoDreamContext,
147 memory: &MemoryStore,
148 project_key: &str,
149 since: DateTime<Utc>,
150) -> Vec<(SessionIndexEntry, Option<String>)> {
151 let mut out = Vec::new();
152 for (entry, summary) in collect_candidate_sessions(ctx, since).await {
153 if resolve_session_project_key(ctx, memory, &entry.id)
154 .await
155 .as_deref()
156 != Some(project_key)
157 {
158 continue;
159 }
160 out.push((entry, summary));
161 if out.len() >= DREAM_MAX_SESSIONS {
162 break;
163 }
164 }
165 out
166}
167
168async fn collect_candidate_session_contexts_from_sessions(
169 ctx: &AutoDreamContext,
170 memory: &MemoryStore,
171 sessions: Vec<(SessionIndexEntry, Option<String>)>,
172) -> Vec<CandidateSessionContext> {
173 let mut out = Vec::new();
174 for (entry, summary) in sessions {
175 let project_key = resolve_session_project_key(ctx, memory, &entry.id).await;
176 let topics = memory
177 .read_session_topics_with_content(&entry.id)
178 .await
179 .unwrap_or_default()
180 .into_iter()
181 .take(EXTRACTION_MAX_TOPICS_PER_SESSION)
182 .map(|(topic, content)| (topic, truncate_chars(&content, EXTRACTION_MAX_TOPIC_CHARS)))
183 .collect::<Vec<_>>();
184 if topics.is_empty()
185 && summary
186 .as_deref()
187 .map(str::trim)
188 .unwrap_or_default()
189 .is_empty()
190 {
191 continue;
192 }
193 out.push(CandidateSessionContext {
194 session_id: entry.id.clone(),
195 project_key,
196 entry,
197 summary,
198 topics,
199 });
200 }
201 out
202}
203
204async fn collect_candidate_session_contexts(
205 ctx: &AutoDreamContext,
206 memory: &MemoryStore,
207 since: DateTime<Utc>,
208) -> Vec<CandidateSessionContext> {
209 collect_candidate_session_contexts_from_sessions(
210 ctx,
211 memory,
212 collect_candidate_sessions(ctx, since).await,
213 )
214 .await
215}
216
217async fn collect_candidate_session_contexts_for_project(
218 ctx: &AutoDreamContext,
219 memory: &MemoryStore,
220 project_key: &str,
221 since: DateTime<Utc>,
222) -> Vec<CandidateSessionContext> {
223 collect_candidate_session_contexts_from_sessions(
224 ctx,
225 memory,
226 collect_candidate_sessions_for_project(ctx, memory, project_key, since).await,
227 )
228 .await
229}
230
231async fn extract_and_persist_durable_candidates(
232 provider: &Arc<dyn LLMProvider>,
233 memory: &MemoryStore,
234 model: &str,
235 sessions: &[CandidateSessionContext],
236) -> Result<usize, String> {
237 if sessions.is_empty() {
238 return Ok(0);
239 }
240
241 let candidates_info: Vec<DreamCandidateInfo> = sessions
242 .iter()
243 .map(|session| DreamCandidateInfo {
244 session_id: session.session_id.clone(),
245 title: session.entry.title.clone(),
246 project_key: session.project_key.clone(),
247 updated_at: session.entry.updated_at.to_rfc3339(),
248 summary: session.summary.clone(),
249 topics: session.topics.clone(),
250 })
251 .collect();
252 let prompt = build_extraction_prompt(&candidates_info);
253 let raw = collect_stream_text(provider.clone(), model, prompt).await?;
254 let candidates = parse_extraction_candidates(&raw)?;
255 if candidates.is_empty() {
256 return Ok(0);
257 }
258
259 let mut session_project_keys = std::collections::HashMap::new();
260 for session in sessions {
261 session_project_keys.insert(session.session_id.clone(), session.project_key.clone());
262 }
263
264 let extracted_at = Utc::now().to_rfc3339();
265 let mut writes = 0usize;
266 let mut touched_sessions = HashSet::new();
267 for candidate in candidates.into_iter().take(EXTRACTION_MAX_CANDIDATES) {
268 let Some(memory_type) = parse_candidate_type(&candidate.kind) else {
269 continue;
270 };
271 let title = candidate.title.trim();
272 let content = candidate.content.trim();
273 if title.is_empty() || content.is_empty() {
274 continue;
275 }
276 let session_id = candidate
277 .session_id
278 .as_deref()
279 .map(str::trim)
280 .filter(|value| !value.is_empty());
281 let project_key = session_id
282 .and_then(|id| session_project_keys.get(id))
283 .and_then(|value| value.as_deref())
284 .map(ToString::to_string);
285 let scope = parse_candidate_scope(&candidate, project_key.as_deref());
286 let tags = candidate.tags;
287 let _ = &candidate.confidence;
288 memory
289 .write_memory(
290 scope,
291 project_key.as_deref(),
292 memory_type,
293 title,
294 content,
295 &tags,
296 session_id,
297 "background-fast-model",
298 false,
299 )
300 .await
301 .map_err(|error| {
302 format!(
303 "failed to persist durable extraction candidate '{}': {error}",
304 title
305 )
306 })?;
307 writes += 1;
308 if let Some(session_id) = session_id {
309 touched_sessions.insert(session_id.to_string());
310 }
311 }
312
313 for session_id in touched_sessions {
314 memory
315 .mark_session_extracted(&session_id, &extracted_at)
316 .await
317 .map_err(|error| {
318 format!("failed to update session extraction state for {session_id}: {error}")
319 })?;
320 }
321
322 Ok(writes)
323}
324
325async fn collect_stream_text(
326 provider: Arc<dyn LLMProvider>,
327 model: &str,
328 prompt: String,
329) -> Result<String, String> {
330 let messages = vec![
331 Message::system(
332 "You are Bamboo's background Dream consolidator. Return only the Dream notebook body sections as plain markdown. Do not return an outer '# Bamboo Dream Notebook' title, metadata lines, or markdown fences."
333 ),
334 Message::user(prompt),
335 ];
336 let options = LLMRequestOptions {
337 session_id: Some(DREAM_RUNTIME_SESSION_ID.to_string()),
338 reasoning_effort: Some(ReasoningEffort::High),
339 parallel_tool_calls: None,
340 responses: None,
341 request_purpose: Some("auto_dream".to_string()),
342 cache: None,
343 };
344
345 let mut stream = provider
346 .chat_stream_with_options(&messages, &[], Some(8192), model, Some(&options))
347 .await
348 .map_err(|error| format!("auto-dream provider call failed: {error}"))?;
349
350 let mut content = String::new();
351 while let Some(chunk) = stream.next().await {
352 match chunk {
353 Ok(LLMChunk::Token(text)) => content.push_str(&text),
354 Ok(LLMChunk::Done) => break,
355 Ok(_) => {}
356 Err(error) => {
357 if !content.is_empty() {
358 break;
359 }
360 return Err(format!("auto-dream stream failed: {error}"));
361 }
362 }
363 }
364
365 let trimmed = content.trim();
366 if trimmed.is_empty() {
367 return Err("auto-dream returned empty content".to_string());
368 }
369 Ok(truncate_chars(trimmed, DREAM_MAX_SUMMARY_CHARS))
370}
371
372async fn read_existing_dream_for_scope(
373 memory: &MemoryStore,
374 scope: MemoryScope,
375 project_key: Option<&str>,
376) -> Result<Option<String>, String> {
377 match scope {
378 MemoryScope::Global => memory
379 .read_dream_view()
380 .await
381 .map_err(|error| format!("failed to read Dream notebook: {error}")),
382 MemoryScope::Project => {
383 let project_key = project_key
384 .map(str::trim)
385 .filter(|value| !value.is_empty())
386 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
387 memory
388 .read_project_dream_view(project_key)
389 .await
390 .map_err(|error| {
391 format!("failed to read project Dream notebook for '{project_key}': {error}")
392 })
393 }
394 MemoryScope::Session => Err("session-scoped Dream generation is not supported".to_string()),
395 }
396}
397
398async fn read_recent_durable_memory_for_scope(
399 memory: &MemoryStore,
400 scope: MemoryScope,
401 project_key: Option<&str>,
402) -> Result<Option<String>, String> {
403 memory
404 .read_recent_view(scope, project_key)
405 .await
406 .map_err(|error| format!("failed to read recent durable memory view: {error}"))
407}
408
409async fn read_durable_memory_index_for_scope(
410 memory: &MemoryStore,
411 scope: MemoryScope,
412 project_key: Option<&str>,
413) -> Result<Option<String>, String> {
414 memory
415 .read_memory_view(scope, project_key)
416 .await
417 .map_err(|error| format!("failed to read durable memory index view: {error}"))
418}
419
420async fn write_dream_for_scope(
421 memory: &MemoryStore,
422 scope: MemoryScope,
423 project_key: Option<&str>,
424 content: &str,
425) -> Result<std::path::PathBuf, String> {
426 match scope {
427 MemoryScope::Global => memory
428 .write_dream_view(content)
429 .await
430 .map_err(|error| format!("failed to persist Dream notebook: {error}")),
431 MemoryScope::Project => {
432 let project_key = project_key
433 .map(str::trim)
434 .filter(|value| !value.is_empty())
435 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
436 memory
437 .write_project_dream_view(project_key, content)
438 .await
439 .map_err(|error| {
440 format!("failed to persist project Dream notebook for '{project_key}': {error}")
441 })
442 }
443 MemoryScope::Session => Err("session-scoped Dream generation is not supported".to_string()),
444 }
445}
446
447async fn build_dream_notebook_body(
448 provider: &Arc<dyn LLMProvider>,
449 model: &str,
450 source_window: &DreamSourceWindow,
451 generation_mode: DreamGenerationMode,
452) -> Result<String, String> {
453 match generation_mode {
454 DreamGenerationMode::Refine => {
455 tracing::info!(
456 target: DREAM_TRACING_TARGET,
457 event = "refine_attempt",
458 model = model,
459 session_count = source_window.sessions.len(),
460 existing_dream_present = source_window.existing_dream.is_some(),
461 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
462 "Attempting refine-mode Dream synthesis"
463 );
464
465 let existing_dream_for_prompt = normalize_existing_dream_for_prompt(
466 source_window.existing_dream.as_deref(),
467 model,
468 source_window.sessions.len(),
469 DREAM_MAX_SUMMARY_CHARS,
470 );
471 let refine_prompt = build_refine_consolidation_prompt(
472 existing_dream_for_prompt.as_deref(),
473 source_window.recent_durable_memory.as_deref(),
474 &to_consolidation_sessions(&source_window.sessions),
475 );
476 match collect_stream_text(provider.clone(), model, refine_prompt).await {
477 Ok(raw_body) => {
478 match normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS) {
479 Ok(body) => {
480 tracing::info!(
481 target: DREAM_TRACING_TARGET,
482 event = "refine_success",
483 model = model,
484 session_count = source_window.sessions.len(),
485 notebook_body_chars = body.chars().count(),
486 existing_dream_present = source_window.existing_dream.is_some(),
487 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
488 "Refine-mode Dream synthesis succeeded"
489 );
490 Ok(body)
491 }
492 Err(error) => {
493 tracing::warn!(
494 target: DREAM_TRACING_TARGET,
495 event = "refine_output_normalization_failed",
496 model = model,
497 session_count = source_window.sessions.len(),
498 existing_dream_present = source_window.existing_dream.is_some(),
499 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
500 "[auto_dream] refine-mode Dream output normalization failed; falling back to incremental prompt: {}",
501 error
502 );
503 let prompt = build_consolidation_prompt(&to_consolidation_sessions(
504 &source_window.sessions,
505 ));
506 let raw_body =
507 collect_stream_text(provider.clone(), model, prompt).await?;
508 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
509 }
510 }
511 }
512 Err(error) => {
513 tracing::warn!(
514 target: DREAM_TRACING_TARGET,
515 event = "refine_provider_failed",
516 model = model,
517 session_count = source_window.sessions.len(),
518 existing_dream_present = source_window.existing_dream.is_some(),
519 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
520 "[auto_dream] refine-mode Dream synthesis failed; falling back to incremental prompt: {}",
521 error
522 );
523 let prompt = build_consolidation_prompt(&to_consolidation_sessions(
524 &source_window.sessions,
525 ));
526 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
527 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
528 }
529 }
530 }
531 DreamGenerationMode::Rebuild => {
532 tracing::info!(
533 target: DREAM_TRACING_TARGET,
534 event = "rebuild_attempt",
535 model = model,
536 session_count = source_window.sessions.len(),
537 durable_memory_index_present = source_window.durable_memory_index.is_some(),
538 "Attempting full rebuild Dream synthesis"
539 );
540 let prompt = build_rebuild_consolidation_prompt(
541 source_window.durable_memory_index.as_deref(),
542 &to_consolidation_sessions(&source_window.sessions),
543 );
544 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
545 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
546 }
547 DreamGenerationMode::Incremental => {
548 let prompt =
549 build_consolidation_prompt(&to_consolidation_sessions(&source_window.sessions));
550 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
551 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
552 }
553 }
554}
555
556async fn run_auto_dream_once_for_scope(
557 ctx: &AutoDreamContext,
558 memory: &MemoryStore,
559 scope: MemoryScope,
560 project_key: Option<&str>,
561 require_auto_dream_enabled: bool,
562) -> Result<Option<AutoDreamRunResult>, String> {
563 let scope_label = match scope {
564 MemoryScope::Global => "global",
565 MemoryScope::Project => "project",
566 MemoryScope::Session => "session",
567 };
568
569 let config_snapshot = ctx.config.read().await.clone();
570 let memory_cfg = config_snapshot.memory.clone().unwrap_or_default();
571 if require_auto_dream_enabled && !memory_cfg.auto_dream_enabled {
572 tracing::info!(
573 target: DREAM_TRACING_TARGET,
574 event = "run_skip",
575 reason = "auto_dream_disabled",
576 scope = scope_label,
577 project_key = project_key.unwrap_or(""),
578 "Skipping Dream generation because auto_dream is disabled"
579 );
580 return Ok(None);
581 }
582
583 let provider_ref_enabled = config_snapshot.features.provider_model_ref;
585 let model_ref = if provider_ref_enabled {
586 config_snapshot
587 .defaults
588 .as_ref()
589 .and_then(|d| d.memory_background.as_ref())
590 .or_else(|| {
591 config_snapshot
592 .defaults
593 .as_ref()
594 .and_then(|d| d.fast.as_ref())
595 })
596 } else {
597 None
598 };
599
600 let (bg_provider, model): (Arc<dyn LLMProvider>, String) = if let Some(ref mr) = model_ref {
601 let router = ProviderModelRouter::new(ctx.provider_registry.clone());
602 let routed = router.route(mr).map_err(|e| {
603 format!(
604 "[auto_dream] failed to route background model ref '{}': {}",
605 mr, e
606 )
607 })?;
608 tracing::debug!(
609 target: DREAM_TRACING_TARGET,
610 model_ref = %mr,
611 "Resolved background model via ProviderModelRef"
612 );
613 (routed, mr.model.clone())
614 } else {
615 let Some(model) = config_snapshot.get_memory_background_model() else {
616 tracing::warn!(
617 target: DREAM_TRACING_TARGET,
618 event = "run_skip",
619 reason = "no_background_model",
620 scope = scope_label,
621 project_key = project_key.unwrap_or(""),
622 "[auto_dream] skipped: no memory.background_model / provider.fast_model configured"
623 );
624 return Ok(None);
625 };
626 (ctx.provider.clone(), model)
627 };
628
629 let now = Utc::now();
630 let existing = read_existing_dream_for_scope(memory, scope, project_key).await?;
631 let recent_durable_memory =
632 read_recent_durable_memory_for_scope(memory, scope, project_key).await?;
633 let durable_memory_index =
634 read_durable_memory_index_for_scope(memory, scope, project_key).await?;
635 let last_full_rebuild_at = existing.as_deref().and_then(parse_last_full_rebuild_at);
636 let force_full_rebuild =
637 should_force_full_rebuild(last_full_rebuild_at, now, DREAM_FULL_REBUILD_INTERVAL_SECS);
638 let since = if force_full_rebuild {
639 now - chrono::Duration::days(30)
640 } else {
641 match existing.as_deref().and_then(parse_last_consolidated_at) {
642 Some(ts) => ts,
643 None => now - chrono::Duration::hours(24),
644 }
645 };
646
647 let sessions = match scope {
648 MemoryScope::Global => collect_candidate_sessions(ctx, since).await,
649 MemoryScope::Project => {
650 let project_key = project_key
651 .map(str::trim)
652 .filter(|value| !value.is_empty())
653 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
654 collect_candidate_sessions_for_project(ctx, memory, project_key, since).await
655 }
656 MemoryScope::Session => {
657 return Err("session-scoped Dream generation is not supported".to_string())
658 }
659 };
660 if sessions.is_empty() {
661 tracing::info!(
662 target: DREAM_TRACING_TARGET,
663 event = "run_skip",
664 reason = "no_candidate_sessions",
665 scope = scope_label,
666 project_key = project_key.unwrap_or(""),
667 model = model.as_str(),
668 existing_dream_present = existing.is_some(),
669 "Skipping Dream generation because there are no candidate sessions"
670 );
671 return Ok(None);
672 }
673
674 let generation_mode = if force_full_rebuild {
675 DreamGenerationMode::Rebuild
676 } else if should_use_dream_refine_mode(&memory_cfg) && existing.is_some() {
677 DreamGenerationMode::Refine
678 } else {
679 DreamGenerationMode::Incremental
680 };
681 tracing::info!(
682 target: DREAM_TRACING_TARGET,
683 event = "run_start",
684 scope = scope_label,
685 project_key = project_key.unwrap_or(""),
686 model = model.as_str(),
687 session_count = sessions.len(),
688 existing_dream_present = existing.is_some(),
689 recent_durable_memory_present = recent_durable_memory.is_some(),
690 durable_memory_index_present = durable_memory_index.is_some(),
691 generation_mode = match generation_mode {
692 DreamGenerationMode::Incremental => "incremental",
693 DreamGenerationMode::Refine => "refine",
694 DreamGenerationMode::Rebuild => "rebuild",
695 },
696 require_auto_dream_enabled = require_auto_dream_enabled,
697 "Starting Dream generation run"
698 );
699
700 let source_window = DreamSourceWindow {
701 existing_dream: existing,
702 recent_durable_memory,
703 durable_memory_index,
704 sessions,
705 };
706 let notebook_body =
707 build_dream_notebook_body(&bg_provider, &model, &source_window, generation_mode).await?;
708 let last_full_rebuild_line = if matches!(generation_mode, DreamGenerationMode::Rebuild) {
709 format!("Last full rebuild at: {}\n", now.to_rfc3339())
710 } else if let Some(existing_rebuild_at) = last_full_rebuild_at {
711 format!(
712 "Last full rebuild at: {}\n",
713 existing_rebuild_at.to_rfc3339()
714 )
715 } else {
716 String::new()
717 };
718 let final_note = match scope {
719 MemoryScope::Global => format!(
720 "# Bamboo Dream Notebook\n\nLast consolidated at: {}\n{}Sessions reviewed: {}\nModel: {}\n\n{}\n",
721 now.to_rfc3339(),
722 last_full_rebuild_line,
723 source_window.sessions.len(),
724 model,
725 notebook_body.trim(),
726 ),
727 MemoryScope::Project => format!(
728 "# Bamboo Dream Notebook\n\nProject key: {}\nLast consolidated at: {}\n{}Sessions reviewed: {}\nModel: {}\n\n{}\n",
729 project_key.unwrap_or_default(),
730 now.to_rfc3339(),
731 last_full_rebuild_line,
732 source_window.sessions.len(),
733 model,
734 notebook_body.trim(),
735 ),
736 MemoryScope::Session => unreachable!("session scope handled above"),
737 };
738
739 let note_path = write_dream_for_scope(memory, scope, project_key, &final_note).await?;
740
741 let extraction_sessions = match scope {
742 MemoryScope::Global => collect_candidate_session_contexts(ctx, memory, since).await,
743 MemoryScope::Project => {
744 let project_key = project_key
745 .map(str::trim)
746 .filter(|value| !value.is_empty())
747 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
748 collect_candidate_session_contexts_for_project(ctx, memory, project_key, since).await
749 }
750 MemoryScope::Session => unreachable!("session scope handled above"),
751 };
752 let extracted_count =
753 extract_and_persist_durable_candidates(&bg_provider, memory, &model, &extraction_sessions)
754 .await?;
755 let notebook_chars = final_note.chars().count();
756
757 tracing::info!(
758 target: DREAM_TRACING_TARGET,
759 event = "run_complete",
760 scope = scope_label,
761 project_key = project_key.unwrap_or(""),
762 model = model.as_str(),
763 session_count = source_window.sessions.len(),
764 existing_dream_present = source_window.existing_dream.is_some(),
765 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
766 durable_memory_index_present = source_window.durable_memory_index.is_some(),
767 generation_mode = match generation_mode {
768 DreamGenerationMode::Incremental => "incremental",
769 DreamGenerationMode::Refine => "refine",
770 DreamGenerationMode::Rebuild => "rebuild",
771 },
772 notebook_chars = notebook_chars,
773 durable_candidates_persisted = extracted_count,
774 note_path = %note_path.display(),
775 "Dream generation run completed"
776 );
777
778 Ok(Some(AutoDreamRunResult {
779 used_model: model,
780 session_count: source_window.sessions.len(),
781 note_path,
782 notebook_chars,
783 }))
784}
785
786async fn run_auto_dream_once_with_store(
787 ctx: &AutoDreamContext,
788 memory: &MemoryStore,
789) -> Result<Option<AutoDreamRunResult>, String> {
790 run_auto_dream_once_for_scope(ctx, memory, MemoryScope::Global, None, true).await
791}
792
793pub async fn run_auto_dream_once(
794 ctx: &AutoDreamContext,
795) -> Result<Option<AutoDreamRunResult>, String> {
796 let memory = memory_store_for_context(ctx);
797 run_auto_dream_once_with_store(ctx, &memory).await
798}
799
800pub async fn run_project_auto_dream_once(
801 ctx: &AutoDreamContext,
802 project_key: &str,
803) -> Result<Option<AutoDreamRunResult>, String> {
804 let memory = memory_store_for_context(ctx);
805 run_project_auto_dream_once_with_store(ctx, &memory, project_key).await
806}
807
808async fn run_project_auto_dream_once_with_store(
809 ctx: &AutoDreamContext,
810 memory: &MemoryStore,
811 project_key: &str,
812) -> Result<Option<AutoDreamRunResult>, String> {
813 let project_key = project_key.trim();
814 if project_key.is_empty() {
815 return Err("project Dream generation requires a non-empty project_key".to_string());
816 }
817 run_auto_dream_once_for_scope(ctx, memory, MemoryScope::Project, Some(project_key), false).await
818}
819
820pub fn spawn_auto_dream_task(ctx: AutoDreamContext) {
821 tokio::spawn(async move {
822 let interval_secs = ctx
823 .config
824 .read()
825 .await
826 .memory
827 .as_ref()
828 .map(|memory| memory.auto_dream_interval_secs)
829 .filter(|secs| *secs > 0)
830 .unwrap_or_else(|| bamboo_config::MemoryConfig::default().auto_dream_interval_secs);
833 let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs));
834 loop {
835 ticker.tick().await;
836 if let Err(error) = run_auto_dream_once(&ctx).await {
837 tracing::warn!(
838 target: DREAM_TRACING_TARGET,
839 event = "run_failed",
840 "[auto_dream] run failed: {}",
841 error
842 );
843 }
844 }
845 });
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 use std::collections::HashMap;
853 use std::sync::{Arc, Mutex};
854
855 use async_trait::async_trait;
856 use futures::stream;
857
858 use bamboo_agent_core::storage::Storage;
859 use bamboo_llm::{LLMError, LLMStream};
860
861 fn test_registry() -> Arc<ProviderRegistry> {
862 Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()))
863 }
864
865 #[derive(Debug, Clone)]
866 enum SequenceStep {
867 Response(String),
868 Fail(String),
869 }
870
871 #[derive(Clone)]
872 struct SequenceProvider {
873 steps: Arc<Mutex<Vec<SequenceStep>>>,
874 prompts: Arc<Mutex<Vec<String>>>,
875 }
876
877 impl SequenceProvider {
878 fn new(responses: Vec<String>) -> Self {
879 Self::from_steps(responses.into_iter().map(SequenceStep::Response).collect())
880 }
881
882 fn from_steps(steps: Vec<SequenceStep>) -> Self {
883 Self {
884 steps: Arc::new(Mutex::new(steps)),
885 prompts: Arc::new(Mutex::new(Vec::new())),
886 }
887 }
888
889 fn recorded_prompts(&self) -> Vec<String> {
890 self.prompts.lock().expect("lock poisoned").clone()
891 }
892 }
893
894 #[async_trait]
895 impl LLMProvider for SequenceProvider {
896 async fn chat_stream(
897 &self,
898 messages: &[Message],
899 _tools: &[bamboo_agent_core::tools::ToolSchema],
900 _max_output_tokens: Option<u32>,
901 _model: &str,
902 ) -> Result<LLMStream, LLMError> {
903 if let Some(prompt) = messages.last().map(|message| message.content.clone()) {
904 self.prompts.lock().expect("lock poisoned").push(prompt);
905 }
906 let next = self.steps.lock().expect("lock poisoned").remove(0);
907 match next {
908 SequenceStep::Response(text) => Ok(Box::pin(stream::iter(vec![
909 Ok(LLMChunk::Token(text)),
910 Ok(LLMChunk::Done),
911 ]))),
912 SequenceStep::Fail(error) => Err(LLMError::Stream(error)),
913 }
914 }
915 }
916
917 #[test]
918 fn parse_last_consolidated_at_reads_frontmatter_line() {
919 let note = "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 3\n";
920 let parsed = parse_last_consolidated_at(note).expect("timestamp should parse");
921 assert_eq!(parsed.to_rfc3339(), "2026-04-02T16:00:00+00:00");
922 }
923
924 #[test]
925 fn parse_extraction_candidates_accepts_fenced_json() {
926 let raw = "```json\n{\"candidates\":[{\"title\":\"User prefers terse responses\",\"type\":\"feedback\",\"scope\":\"global\",\"content\":\"The user prefers terse responses.\",\"tags\":[\"preference\"],\"session_id\":\"session-1\"}]}\n```";
927 let candidates = parse_extraction_candidates(raw).expect("candidates should parse");
928 assert_eq!(candidates.len(), 1);
929 assert_eq!(candidates[0].title, "User prefers terse responses");
930 assert_eq!(candidates[0].kind, "feedback");
931 }
932
933 #[tokio::test]
934 async fn extract_and_persist_durable_candidates_writes_memory_and_marks_session() {
935 let temp_dir = tempfile::tempdir().expect("tempdir");
936 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
937
938 let session_store = Arc::new(
939 SessionStoreV2::new(temp_dir.path().to_path_buf())
940 .await
941 .unwrap(),
942 );
943 let storage: Arc<dyn Storage> = session_store.clone();
944 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
945 "{\"candidates\":[{\"title\":\"User prefers terse responses\",\"type\":\"feedback\",\"scope\":\"project\",\"content\":\"The user prefers terse responses and no recap.\",\"tags\":[\"preference\",\"style\"],\"session_id\":\"session-auto\",\"confidence\":\"high\"}]}".to_string(),
946 ]));
947 let config = Arc::new(RwLock::new(Config {
948 memory: Some(bamboo_config::MemoryConfig {
949 background_model: Some("fast-model".to_string()),
950 auto_dream_enabled: true,
951 ..bamboo_config::MemoryConfig::default()
952 }),
953 ..Config::default()
954 }));
955
956 let mut session = bamboo_agent_core::Session::new("session-auto", "model");
957 session.title = "Auto memory test".to_string();
958 session.metadata.insert(
959 "workspace_path".to_string(),
960 temp_dir
961 .path()
962 .join("workspace-a")
963 .to_string_lossy()
964 .to_string(),
965 );
966 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
967 "User confirmed a stable response preference.",
968 3,
969 128,
970 ));
971 session.add_message(Message::user("Please be terse and skip the recap."));
972 storage.save_session(&session).await.expect("save session");
973
974 let memory = MemoryStore::new(temp_dir.path());
975 memory
976 .write_session_topic("session-auto", "default", "User prefers terse responses.")
977 .await
978 .expect("write session topic");
979
980 let context = AutoDreamContext {
981 session_store: session_store.clone(),
982 storage: storage.clone(),
983 provider: provider.clone(),
984 config: config.clone(),
985 provider_registry: test_registry(),
986 };
987 let contexts = collect_candidate_session_contexts(
988 &context,
989 &memory,
990 Utc::now() - chrono::Duration::hours(24),
991 )
992 .await;
993 assert_eq!(contexts.len(), 1);
994
995 let writes =
996 extract_and_persist_durable_candidates(&provider, &memory, "fast-model", &contexts)
997 .await
998 .expect("extraction should succeed");
999 assert_eq!(writes, 1);
1000
1001 let project_key = bamboo_memory::memory_store::project_key_from_path(
1002 &temp_dir.path().join("workspace-a"),
1003 );
1004 let results = memory
1005 .query_scope(
1006 MemoryScope::Project,
1007 Some(&project_key),
1008 Some("terse recap"),
1009 None,
1010 None,
1011 &bamboo_memory::memory_store::MemoryQueryOptions {
1012 limit: Some(5),
1013 max_chars: Some(2000),
1014 cursor: None,
1015 include_related: false,
1016 },
1017 )
1018 .await
1019 .expect("query should succeed");
1020 assert_eq!(results.matched_count, 1);
1021 assert_eq!(results.items[0].title, "User prefers terse responses");
1022
1023 let state = memory
1024 .read_session_state("session-auto")
1025 .await
1026 .expect("read session state");
1027 assert!(state.last_extracted_at.is_some());
1028 }
1029
1030 #[tokio::test]
1031 async fn extract_and_persist_durable_candidates_ignores_empty_candidate_lists() {
1032 let temp_dir = tempfile::tempdir().expect("tempdir");
1033 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1034
1035 let session_store = Arc::new(
1036 SessionStoreV2::new(temp_dir.path().to_path_buf())
1037 .await
1038 .unwrap(),
1039 );
1040 let storage: Arc<dyn Storage> = session_store.clone();
1041 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1042 "{\"candidates\":[]}".to_string(),
1043 ]));
1044 let config = Arc::new(RwLock::new(Config {
1045 memory: Some(bamboo_config::MemoryConfig {
1046 background_model: Some("fast-model".to_string()),
1047 auto_dream_enabled: true,
1048 ..bamboo_config::MemoryConfig::default()
1049 }),
1050 ..Config::default()
1051 }));
1052
1053 let mut session = bamboo_agent_core::Session::new("session-empty", "model");
1054 session.metadata.insert(
1055 "workspace_path".to_string(),
1056 temp_dir.path().to_string_lossy().to_string(),
1057 );
1058 session.add_message(Message::user("This should not produce durable memory."));
1059 storage.save_session(&session).await.expect("save session");
1060
1061 let memory = MemoryStore::new(temp_dir.path());
1062 memory
1063 .write_session_topic("session-empty", "default", "ephemeral scratch")
1064 .await
1065 .expect("write session topic");
1066
1067 let context = AutoDreamContext {
1068 session_store,
1069 storage,
1070 provider,
1071 config,
1072 provider_registry: test_registry(),
1073 };
1074 let sessions = collect_candidate_session_contexts(
1075 &context,
1076 &memory,
1077 Utc::now() - chrono::Duration::hours(24),
1078 )
1079 .await;
1080 let writes = extract_and_persist_durable_candidates(
1081 &context.provider,
1082 &memory,
1083 "fast-model",
1084 &sessions,
1085 )
1086 .await
1087 .expect("empty extraction should succeed");
1088 assert_eq!(writes, 0);
1089
1090 let state = memory
1091 .read_session_state("session-empty")
1092 .await
1093 .expect("read session state");
1094 assert!(state.last_extracted_at.is_none());
1095 }
1096
1097 #[tokio::test]
1098 async fn run_auto_dream_once_updates_dream_and_persists_candidates() {
1099 let temp_dir = tempfile::tempdir().expect("tempdir");
1100 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1101
1102 let session_store = Arc::new(
1103 SessionStoreV2::new(temp_dir.path().to_path_buf())
1104 .await
1105 .unwrap(),
1106 );
1107 let storage: Arc<dyn Storage> = session_store.clone();
1108 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1109 "## Current durable context\n- Durable signal found\n\n## Cross-session patterns\n- Prefer concise answers\n\n## Active threads to remember\n- Memory extraction\n\n## Stable constraints and preferences\n- Terse replies\n\n## Open risks or questions\n- None".to_string(),
1110 "{\"candidates\":[{\"title\":\"User prefers concise answers\",\"type\":\"feedback\",\"scope\":\"project\",\"content\":\"The user prefers concise answers and minimal recap.\",\"tags\":[\"preference\"],\"session_id\":\"session-dream-run\"}]}".to_string(),
1111 ]));
1112 let config = Arc::new(RwLock::new(Config {
1113 memory: Some(bamboo_config::MemoryConfig {
1114 background_model: Some("fast-model".to_string()),
1115 auto_dream_enabled: true,
1116 ..bamboo_config::MemoryConfig::default()
1117 }),
1118 ..Config::default()
1119 }));
1120
1121 let mut session = bamboo_agent_core::Session::new("session-dream-run", "model");
1122 session.title = "Dream run test".to_string();
1123 session.metadata.insert(
1124 "workspace_path".to_string(),
1125 temp_dir
1126 .path()
1127 .join("workspace-run")
1128 .to_string_lossy()
1129 .to_string(),
1130 );
1131 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1132 "Stable user preference discussed.",
1133 4,
1134 200,
1135 ));
1136 session.add_message(Message::user("Please keep answers concise."));
1137 storage.save_session(&session).await.expect("save session");
1138
1139 let memory = MemoryStore::new(temp_dir.path());
1140 memory
1141 .write_session_topic(
1142 "session-dream-run",
1143 "default",
1144 "User prefers concise answers and minimal recap.",
1145 )
1146 .await
1147 .expect("write session topic");
1148
1149 let context = AutoDreamContext {
1150 session_store,
1151 storage,
1152 provider,
1153 config,
1154 provider_registry: test_registry(),
1155 };
1156 let result = run_auto_dream_once_with_store(&context, &memory)
1157 .await
1158 .expect("auto dream run should succeed")
1159 .expect("auto dream should produce output");
1160 assert_eq!(result.used_model, "fast-model");
1161 assert_eq!(result.session_count, 1);
1162
1163 let dream = memory
1164 .read_dream_view()
1165 .await
1166 .expect("read dream view")
1167 .expect("dream should exist");
1168 assert!(dream.contains("Bamboo Dream Notebook"));
1169 assert!(dream.contains("Durable signal found"));
1170
1171 let project_key = bamboo_memory::memory_store::project_key_from_path(
1172 &temp_dir.path().join("workspace-run"),
1173 );
1174 let results = memory
1175 .query_scope(
1176 MemoryScope::Project,
1177 Some(&project_key),
1178 Some("concise answers"),
1179 None,
1180 None,
1181 &bamboo_memory::memory_store::MemoryQueryOptions {
1182 limit: Some(5),
1183 max_chars: Some(2000),
1184 cursor: None,
1185 include_related: false,
1186 },
1187 )
1188 .await
1189 .expect("query should succeed");
1190 assert_eq!(results.matched_count, 1);
1191 assert_eq!(results.items[0].title, "User prefers concise answers");
1192 }
1193
1194 #[tokio::test]
1195 async fn run_project_auto_dream_once_filters_sessions_by_project_and_writes_project_dream() {
1196 let temp_dir = tempfile::tempdir().expect("tempdir");
1197 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1198
1199 let workspace_a = temp_dir.path().join("workspace-a");
1200 let workspace_b = temp_dir.path().join("workspace-b");
1201 std::fs::create_dir_all(&workspace_a).expect("workspace a");
1202 std::fs::create_dir_all(&workspace_b).expect("workspace b");
1203 let project_key_a = bamboo_memory::memory_store::project_key_from_path(&workspace_a);
1204
1205 let session_store = Arc::new(
1206 SessionStoreV2::new(temp_dir.path().to_path_buf())
1207 .await
1208 .unwrap(),
1209 );
1210 let storage: Arc<dyn Storage> = session_store.clone();
1211 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1212 "## Current durable context\n- Project A signal only\n\n## Cross-session patterns\n- Focus on project A\n\n## Active threads to remember\n- Ship project A\n\n## Stable constraints and preferences\n- Keep scope isolated\n\n## Open risks or questions\n- None".to_string(),
1213 "{\"candidates\":[{\"title\":\"Project A prefers concise planning\",\"type\":\"project\",\"scope\":\"project\",\"content\":\"Project A plans should stay concise and scoped.\",\"tags\":[\"planning\"],\"session_id\":\"session-project-a\"}]}".to_string(),
1214 ]));
1215 let config = Arc::new(RwLock::new(Config {
1216 memory: Some(bamboo_config::MemoryConfig {
1217 background_model: Some("fast-model".to_string()),
1218 auto_dream_enabled: true,
1219 ..bamboo_config::MemoryConfig::default()
1220 }),
1221 ..Config::default()
1222 }));
1223
1224 let mut session_a = bamboo_agent_core::Session::new("session-project-a", "model");
1225 session_a.title = "Project A session".to_string();
1226 session_a.metadata.insert(
1227 "workspace_path".to_string(),
1228 workspace_a.to_string_lossy().to_string(),
1229 );
1230 session_a.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1231 "Project A stable direction.",
1232 4,
1233 160,
1234 ));
1235 session_a.add_message(Message::user("Keep project A plans concise."));
1236 storage
1237 .save_session(&session_a)
1238 .await
1239 .expect("save session a");
1240
1241 let mut session_b = bamboo_agent_core::Session::new("session-project-b", "model");
1242 session_b.title = "Project B session".to_string();
1243 session_b.metadata.insert(
1244 "workspace_path".to_string(),
1245 workspace_b.to_string_lossy().to_string(),
1246 );
1247 session_b.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1248 "Project B unrelated direction.",
1249 4,
1250 160,
1251 ));
1252 session_b.add_message(Message::user("This is unrelated project B context."));
1253 storage
1254 .save_session(&session_b)
1255 .await
1256 .expect("save session b");
1257
1258 let memory = MemoryStore::new(temp_dir.path());
1259 memory
1260 .write_session_topic(
1261 "session-project-a",
1262 "default",
1263 "Project A planning should remain concise.",
1264 )
1265 .await
1266 .expect("write session topic a");
1267 memory
1268 .write_session_topic(
1269 "session-project-b",
1270 "default",
1271 "Project B note that should not be included.",
1272 )
1273 .await
1274 .expect("write session topic b");
1275
1276 let context = AutoDreamContext {
1277 session_store,
1278 storage,
1279 provider,
1280 config,
1281 provider_registry: test_registry(),
1282 };
1283 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key_a)
1284 .await
1285 .expect("project auto dream should succeed")
1286 .expect("project auto dream should produce output");
1287 assert_eq!(result.used_model, "fast-model");
1288 assert_eq!(result.session_count, 1);
1289
1290 let project_dream = memory
1291 .read_project_dream_view(&project_key_a)
1292 .await
1293 .expect("read project dream")
1294 .expect("project dream should exist");
1295 assert!(project_dream.contains("Bamboo Dream Notebook"));
1296 assert!(project_dream.contains("Project key: "));
1297 assert!(project_dream.contains(&project_key_a));
1298 assert!(project_dream.contains("Project A signal only"));
1299 assert!(!project_dream.contains("unrelated project B"));
1300
1301 let global_dream = memory.read_dream_view().await.expect("read global dream");
1302 assert!(global_dream.is_none());
1303
1304 let results = memory
1305 .query_scope(
1306 MemoryScope::Project,
1307 Some(&project_key_a),
1308 Some("concise planning"),
1309 None,
1310 None,
1311 &bamboo_memory::memory_store::MemoryQueryOptions {
1312 limit: Some(5),
1313 max_chars: Some(2000),
1314 cursor: None,
1315 include_related: false,
1316 },
1317 )
1318 .await
1319 .expect("query should succeed");
1320 assert_eq!(results.matched_count, 1);
1321 assert_eq!(results.items[0].title, "Project A prefers concise planning");
1322 }
1323
1324 #[tokio::test]
1325 async fn run_project_auto_dream_once_returns_none_without_target_project_sessions_and_preserves_existing_dream(
1326 ) {
1327 let temp_dir = tempfile::tempdir().expect("tempdir");
1328 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1329
1330 let workspace_other = temp_dir.path().join("workspace-other");
1331 let workspace_target = temp_dir.path().join("workspace-target");
1332 std::fs::create_dir_all(&workspace_other).expect("workspace other");
1333 std::fs::create_dir_all(&workspace_target).expect("workspace target");
1334 let target_project_key =
1335 bamboo_memory::memory_store::project_key_from_path(&workspace_target);
1336
1337 let session_store = Arc::new(
1338 SessionStoreV2::new(temp_dir.path().to_path_buf())
1339 .await
1340 .unwrap(),
1341 );
1342 let storage: Arc<dyn Storage> = session_store.clone();
1343 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
1344 let config = Arc::new(RwLock::new(Config {
1345 memory: Some(bamboo_config::MemoryConfig {
1346 background_model: Some("fast-model".to_string()),
1347 auto_dream_enabled: true,
1348 ..bamboo_config::MemoryConfig::default()
1349 }),
1350 ..Config::default()
1351 }));
1352
1353 let mut other_session = bamboo_agent_core::Session::new("session-other-project", "model");
1354 other_session.title = "Other project session".to_string();
1355 other_session.metadata.insert(
1356 "workspace_path".to_string(),
1357 workspace_other.to_string_lossy().to_string(),
1358 );
1359 other_session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1360 "Other project only.",
1361 2,
1362 80,
1363 ));
1364 other_session.add_message(Message::user("Other project context only."));
1365 storage
1366 .save_session(&other_session)
1367 .await
1368 .expect("save other session");
1369
1370 let memory = MemoryStore::new(temp_dir.path());
1371 memory
1372 .write_project_dream_view(
1373 &target_project_key,
1374 "# Bamboo Dream Notebook\n\nExisting target project dream",
1375 )
1376 .await
1377 .expect("write existing project dream");
1378
1379 let context = AutoDreamContext {
1380 session_store,
1381 storage,
1382 provider,
1383 config,
1384 provider_registry: test_registry(),
1385 };
1386 let result = run_project_auto_dream_once_with_store(&context, &memory, &target_project_key)
1387 .await
1388 .expect("project auto dream without sessions should not error");
1389 assert!(result.is_none());
1390
1391 let project_dream = memory
1392 .read_project_dream_view(&target_project_key)
1393 .await
1394 .expect("read project dream")
1395 .expect("existing dream should remain");
1396 assert!(project_dream.contains("Existing target project dream"));
1397 }
1398
1399 #[tokio::test]
1400 async fn run_project_auto_dream_once_still_runs_when_auto_background_dream_is_disabled() {
1401 let temp_dir = tempfile::tempdir().expect("tempdir");
1402 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1403
1404 let workspace = temp_dir.path().join("workspace-manual-project-dream");
1405 std::fs::create_dir_all(&workspace).expect("workspace dir");
1406 let project_key = bamboo_memory::memory_store::project_key_from_path(&workspace);
1407
1408 let session_store = Arc::new(
1409 SessionStoreV2::new(temp_dir.path().to_path_buf())
1410 .await
1411 .unwrap(),
1412 );
1413 let storage: Arc<dyn Storage> = session_store.clone();
1414 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1415 "## Current durable context\n- Manual project dream worked\n\n## Cross-session patterns\n- None\n\n## Active threads to remember\n- None\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
1416 "{\"candidates\":[]}".to_string(),
1417 ]));
1418 let config = Arc::new(RwLock::new(Config {
1419 memory: Some(bamboo_config::MemoryConfig {
1420 background_model: Some("fast-model".to_string()),
1421 ..bamboo_config::MemoryConfig::default()
1422 }),
1423 ..Config::default()
1424 }));
1425
1426 let mut session = bamboo_agent_core::Session::new("session-manual-project-dream", "model");
1427 session.title = "Manual project dream session".to_string();
1428 session.metadata.insert(
1429 "workspace_path".to_string(),
1430 workspace.to_string_lossy().to_string(),
1431 );
1432 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1433 "Manual project dream summary.",
1434 3,
1435 100,
1436 ));
1437 session.add_message(Message::user("Generate a project-scoped dream manually."));
1438 storage.save_session(&session).await.expect("save session");
1439
1440 let memory = MemoryStore::new(temp_dir.path());
1441 memory
1442 .write_session_topic(
1443 "session-manual-project-dream",
1444 "default",
1445 "Manual project dream note.",
1446 )
1447 .await
1448 .expect("write session topic");
1449
1450 let context = AutoDreamContext {
1451 session_store,
1452 storage,
1453 provider,
1454 config,
1455 provider_registry: test_registry(),
1456 };
1457 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
1458 .await
1459 .expect(
1460 "manual project dream should succeed even when auto background dream is disabled",
1461 )
1462 .expect("manual project dream should produce output");
1463 assert_eq!(result.session_count, 1);
1464
1465 let project_dream = memory
1466 .read_project_dream_view(&project_key)
1467 .await
1468 .expect("read project dream")
1469 .expect("project dream should exist");
1470 assert!(project_dream.contains("Manual project dream worked"));
1471 }
1472
1473 #[tokio::test]
1474 async fn run_auto_dream_once_refine_mode_includes_existing_dream_in_prompt() {
1475 let temp_dir = tempfile::tempdir().expect("tempdir");
1476 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1477
1478 let session_store = Arc::new(
1479 SessionStoreV2::new(temp_dir.path().to_path_buf())
1480 .await
1481 .unwrap(),
1482 );
1483 let storage: Arc<dyn Storage> = session_store.clone();
1484 let provider = SequenceProvider::new(vec![
1485 "## Current durable context\n- Refined durable theme\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Update the notebook\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
1486 "{\"candidates\":[]}".to_string(),
1487 ]);
1488 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1489 let config = Arc::new(RwLock::new(Config {
1490 memory: Some(bamboo_config::MemoryConfig {
1491 background_model: Some("fast-model".to_string()),
1492 auto_dream_enabled: true,
1493 dream_refine_mode: true,
1494 ..bamboo_config::MemoryConfig::default()
1495 }),
1496 ..Config::default()
1497 }));
1498
1499 let workspace = temp_dir.path().join("workspace-refine-mode");
1500 std::fs::create_dir_all(&workspace).expect("workspace dir");
1501
1502 let mut session = bamboo_agent_core::Session::new("session-refine-mode", "model");
1503 session.title = "Refine mode test".to_string();
1504 session.metadata.insert(
1505 "workspace_path".to_string(),
1506 workspace.to_string_lossy().to_string(),
1507 );
1508 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1509 "Recent session summary for refine mode.",
1510 3,
1511 120,
1512 ));
1513 session.add_message(Message::user("Update the dream with the latest thread."));
1514 storage.save_session(&session).await.expect("save session");
1515
1516 let memory = MemoryStore::new(temp_dir.path());
1517 memory
1518 .write_dream_view(
1519 "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
1520 )
1521 .await
1522 .expect("write existing dream");
1523 memory
1524 .write_session_topic("session-refine-mode", "default", "Recent session note.")
1525 .await
1526 .expect("write session topic");
1527
1528 let context = AutoDreamContext {
1529 session_store,
1530 storage,
1531 provider: provider_handle,
1532 config,
1533 provider_registry: test_registry(),
1534 };
1535
1536 let result = run_auto_dream_once_with_store(&context, &memory)
1537 .await
1538 .expect("refine-mode auto dream should succeed")
1539 .expect("dream output should be produced");
1540 assert_eq!(result.session_count, 1);
1541
1542 let prompts = provider.recorded_prompts();
1543 assert!(prompts.len() >= 2);
1544 assert!(prompts[0].contains("## Existing Dream notebook"));
1545 assert!(prompts[0].contains("Existing durable thread"));
1546 assert!(prompts[0].contains("## Recent durable memory updates"));
1547 assert!(prompts[0].contains("start from it and preserve still-valid durable context"));
1548 }
1549
1550 #[tokio::test]
1551 async fn run_auto_dream_once_refine_mode_includes_recent_durable_memory_in_prompt() {
1552 let temp_dir = tempfile::tempdir().expect("tempdir");
1553 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1554
1555 let session_store = Arc::new(
1556 SessionStoreV2::new(temp_dir.path().to_path_buf())
1557 .await
1558 .unwrap(),
1559 );
1560 let storage: Arc<dyn Storage> = session_store.clone();
1561 let provider = SequenceProvider::new(vec![
1562 "## Current durable context\n- Refined from durable memory\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Update the notebook\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
1563 "{\"candidates\":[]}".to_string(),
1564 ]);
1565 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1566 let config = Arc::new(RwLock::new(Config {
1567 memory: Some(bamboo_config::MemoryConfig {
1568 background_model: Some("fast-model".to_string()),
1569 auto_dream_enabled: true,
1570 dream_refine_mode: true,
1571 ..bamboo_config::MemoryConfig::default()
1572 }),
1573 ..Config::default()
1574 }));
1575
1576 let workspace = temp_dir.path().join("workspace-refine-recent-memory");
1577 std::fs::create_dir_all(&workspace).expect("workspace dir");
1578 let project_key = bamboo_memory::memory_store::project_key_from_path(&workspace);
1579
1580 let mut session = bamboo_agent_core::Session::new("session-refine-recent-memory", "model");
1581 session.title = "Refine recent memory test".to_string();
1582 session.metadata.insert(
1583 "workspace_path".to_string(),
1584 workspace.to_string_lossy().to_string(),
1585 );
1586 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1587 "Recent session summary for refine recent memory.",
1588 3,
1589 120,
1590 ));
1591 session.add_message(Message::user(
1592 "Update the dream with recent durable memory.",
1593 ));
1594 storage.save_session(&session).await.expect("save session");
1595
1596 let memory = MemoryStore::new(temp_dir.path());
1597 memory
1598 .write_project_dream_view(
1599 &project_key,
1600 "# Bamboo Dream Notebook\n\nProject key: project\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
1601 )
1602 .await
1603 .expect("write existing project dream");
1604 memory
1605 .write_memory(
1606 MemoryScope::Project,
1607 Some(&project_key),
1608 bamboo_memory::memory_store::DurableMemoryType::Project,
1609 "Release freeze rule",
1610 "The release freeze starts on Tuesday for mobile.",
1611 &["release".to_string(), "freeze".to_string()],
1612 Some("session-refine-recent-memory"),
1613 "main-model",
1614 false,
1615 )
1616 .await
1617 .expect("write recent durable memory");
1618
1619 let context = AutoDreamContext {
1620 session_store,
1621 storage,
1622 provider: provider_handle,
1623 config,
1624 provider_registry: test_registry(),
1625 };
1626
1627 let _ = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
1628 .await
1629 .expect("refine recent-memory auto dream should succeed")
1630 .expect("dream output should be produced");
1631
1632 let prompts = provider.recorded_prompts();
1633 assert!(prompts.len() >= 2);
1634 assert!(prompts[0].contains("## Recent durable memory updates"));
1635 assert!(prompts[0].contains("Release freeze rule"));
1636 assert!(prompts[0].contains("Recent Memory Updates"));
1637 }
1638
1639 #[tokio::test]
1640 async fn run_auto_dream_once_forces_periodic_full_rebuild_using_memory_index() {
1641 let temp_dir = tempfile::tempdir().expect("tempdir");
1642 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1643
1644 let session_store = Arc::new(
1645 SessionStoreV2::new(temp_dir.path().to_path_buf())
1646 .await
1647 .unwrap(),
1648 );
1649 let storage: Arc<dyn Storage> = session_store.clone();
1650 let provider = SequenceProvider::new(vec![
1651 "## Current durable context\n- Rebuilt from durable memory index\n\n## Cross-session patterns\n- Canonical project history\n\n## Active threads to remember\n- Refresh active blockers\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
1652 "{\"candidates\":[]}".to_string(),
1653 ]);
1654 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1655 let config = Arc::new(RwLock::new(Config {
1656 memory: Some(bamboo_config::MemoryConfig {
1657 background_model: Some("fast-model".to_string()),
1658 auto_dream_enabled: true,
1659 dream_refine_mode: true,
1660 ..bamboo_config::MemoryConfig::default()
1661 }),
1662 ..Config::default()
1663 }));
1664
1665 let workspace = temp_dir.path().join("workspace-rebuild-mode");
1666 std::fs::create_dir_all(&workspace).expect("workspace dir");
1667 let project_key = bamboo_memory::memory_store::project_key_from_path(&workspace);
1668
1669 let mut session = bamboo_agent_core::Session::new("session-rebuild-mode", "model");
1670 session.title = "Rebuild mode test".to_string();
1671 session.metadata.insert(
1672 "workspace_path".to_string(),
1673 workspace.to_string_lossy().to_string(),
1674 );
1675 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1676 "Recent session summary for rebuild mode.",
1677 3,
1678 120,
1679 ));
1680 session.add_message(Message::user(
1681 "Refresh the project dream from canonical memory.",
1682 ));
1683 storage.save_session(&session).await.expect("save session");
1684
1685 let memory = MemoryStore::new(temp_dir.path());
1686 memory
1687 .write_project_dream_view(
1688 &project_key,
1689 "# Bamboo Dream Notebook\n\nProject key: project\nLast consolidated at: 2026-02-02T16:00:00Z\nLast full rebuild at: 2026-02-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing project dream\n",
1690 )
1691 .await
1692 .expect("write existing project dream");
1693 memory
1694 .write_memory(
1695 MemoryScope::Project,
1696 Some(&project_key),
1697 bamboo_memory::memory_store::DurableMemoryType::Project,
1698 "Canonical release decision",
1699 "Release freeze starts Tuesday and all mobile changes require review.",
1700 &["release".to_string(), "mobile".to_string()],
1701 Some("session-rebuild-mode"),
1702 "main-model",
1703 false,
1704 )
1705 .await
1706 .expect("write project durable memory");
1707
1708 let context = AutoDreamContext {
1709 session_store,
1710 storage,
1711 provider: provider_handle,
1712 config,
1713 provider_registry: test_registry(),
1714 };
1715
1716 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
1717 .await
1718 .expect("rebuild auto dream should succeed")
1719 .expect("rebuild dream output should be produced");
1720 assert_eq!(result.session_count, 1);
1721
1722 let prompts = provider.recorded_prompts();
1723 assert!(prompts.len() >= 2);
1724 assert!(prompts[0].contains("## Durable memory index"));
1725 assert!(prompts[0].contains("Canonical release decision"));
1726 assert!(prompts[0].contains("canonical durable memory plus recent session activity"));
1727
1728 let dream = memory
1729 .read_project_dream_view(&project_key)
1730 .await
1731 .expect("read project dream")
1732 .expect("project dream should exist");
1733 assert!(dream.contains("Rebuilt from durable memory index"));
1734 assert!(dream.contains("Last full rebuild at:"));
1735 }
1736
1737 #[test]
1738 fn normalize_dream_notebook_body_strips_nested_fenced_notebook_wrapper() {
1739 let raw = r#"
1740```md
1741# Bamboo Dream Notebook
1742
1743Last consolidated at: 2026-04-10T06:28:54.680302+00:00
1744Sessions reviewed: 2
1745Model: gpt-5-mini
1746
1747## Current durable context
1748- Existing durable thread
1749
1750## Cross-session patterns
1751- Keep continuity
1752
1753## Active threads to remember
1754- Update the notebook
1755
1756## Stable constraints and preferences
1757- None
1758
1759## Open risks or questions
1760- None
1761```
1762"#;
1763
1764 let normalized = normalize_dream_notebook_body(raw, DREAM_MAX_SUMMARY_CHARS)
1765 .expect("normalization should succeed");
1766 assert!(!normalized.contains("```md"));
1767 assert!(!normalized.contains("# Bamboo Dream Notebook"));
1768 assert!(normalized.contains("## Current durable context"));
1769 assert!(normalized.contains("Existing durable thread"));
1770 }
1771
1772 #[tokio::test]
1773 async fn run_auto_dream_once_refine_mode_normalizes_nested_notebook_output() {
1774 let temp_dir = tempfile::tempdir().expect("tempdir");
1775 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1776
1777 let session_store = Arc::new(
1778 SessionStoreV2::new(temp_dir.path().to_path_buf())
1779 .await
1780 .unwrap(),
1781 );
1782 let storage: Arc<dyn Storage> = session_store.clone();
1783 let provider = SequenceProvider::new(vec![
1784 "```md\n# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-10T06:28:54.680302+00:00\nSessions reviewed: 2\nModel: gpt-5-mini\n\n## Current durable context\n- Refined durable theme\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Update the notebook\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None\n```".to_string(),
1785 "{\"candidates\":[]}".to_string(),
1786 ]);
1787 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1788 let config = Arc::new(RwLock::new(Config {
1789 memory: Some(bamboo_config::MemoryConfig {
1790 background_model: Some("fast-model".to_string()),
1791 auto_dream_enabled: true,
1792 dream_refine_mode: true,
1793 ..bamboo_config::MemoryConfig::default()
1794 }),
1795 ..Config::default()
1796 }));
1797
1798 let workspace = temp_dir.path().join("workspace-refine-normalize");
1799 std::fs::create_dir_all(&workspace).expect("workspace dir");
1800
1801 let mut session = bamboo_agent_core::Session::new("session-refine-normalize", "model");
1802 session.title = "Refine normalize test".to_string();
1803 session.metadata.insert(
1804 "workspace_path".to_string(),
1805 workspace.to_string_lossy().to_string(),
1806 );
1807 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1808 "Recent session summary for refine normalization.",
1809 3,
1810 120,
1811 ));
1812 session.add_message(Message::user("Normalize the refined dream output."));
1813 storage.save_session(&session).await.expect("save session");
1814
1815 let memory = MemoryStore::new(temp_dir.path());
1816 memory
1817 .write_dream_view(
1818 "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
1819 )
1820 .await
1821 .expect("write existing dream");
1822 memory
1823 .write_session_topic(
1824 "session-refine-normalize",
1825 "default",
1826 "Recent session note.",
1827 )
1828 .await
1829 .expect("write session topic");
1830
1831 let context = AutoDreamContext {
1832 session_store,
1833 storage,
1834 provider: provider_handle,
1835 config,
1836 provider_registry: test_registry(),
1837 };
1838
1839 let result = run_auto_dream_once_with_store(&context, &memory)
1840 .await
1841 .expect("refine normalize auto dream should succeed")
1842 .expect("dream output should be produced");
1843 assert_eq!(result.session_count, 1);
1844
1845 let dream = memory
1846 .read_dream_view()
1847 .await
1848 .expect("read dream view")
1849 .expect("dream should exist");
1850 assert!(dream.contains("Refined durable theme"));
1851 assert!(!dream.contains("```md"));
1852 assert_eq!(dream.matches("# Bamboo Dream Notebook").count(), 1);
1853 }
1854
1855 #[tokio::test]
1856 async fn run_auto_dream_once_refine_mode_falls_back_to_legacy_prompt_on_failure() {
1857 let temp_dir = tempfile::tempdir().expect("tempdir");
1858 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1859
1860 let session_store = Arc::new(
1861 SessionStoreV2::new(temp_dir.path().to_path_buf())
1862 .await
1863 .unwrap(),
1864 );
1865 let storage: Arc<dyn Storage> = session_store.clone();
1866 let provider = SequenceProvider::from_steps(vec![
1867 SequenceStep::Fail("refine prompt failed".to_string()),
1868 SequenceStep::Response(
1869 "## Current durable context\n- Legacy fallback result\n\n## Cross-session patterns\n- None\n\n## Active threads to remember\n- None\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
1870 ),
1871 SequenceStep::Response("{\"candidates\":[]}".to_string()),
1872 ]);
1873 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1874 let config = Arc::new(RwLock::new(Config {
1875 memory: Some(bamboo_config::MemoryConfig {
1876 background_model: Some("fast-model".to_string()),
1877 auto_dream_enabled: true,
1878 dream_refine_mode: true,
1879 ..bamboo_config::MemoryConfig::default()
1880 }),
1881 ..Config::default()
1882 }));
1883
1884 let workspace = temp_dir.path().join("workspace-refine-fallback");
1885 std::fs::create_dir_all(&workspace).expect("workspace dir");
1886
1887 let mut session = bamboo_agent_core::Session::new("session-refine-fallback", "model");
1888 session.title = "Refine fallback test".to_string();
1889 session.metadata.insert(
1890 "workspace_path".to_string(),
1891 workspace.to_string_lossy().to_string(),
1892 );
1893 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1894 "Recent summary for fallback mode.",
1895 3,
1896 120,
1897 ));
1898 session.add_message(Message::user("Please update the dream safely."));
1899 storage.save_session(&session).await.expect("save session");
1900
1901 let memory = MemoryStore::new(temp_dir.path());
1902 memory
1903 .write_dream_view(
1904 "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
1905 )
1906 .await
1907 .expect("write existing dream");
1908 memory
1909 .write_session_topic("session-refine-fallback", "default", "Recent session note.")
1910 .await
1911 .expect("write session topic");
1912
1913 let context = AutoDreamContext {
1914 session_store,
1915 storage,
1916 provider: provider_handle,
1917 config,
1918 provider_registry: test_registry(),
1919 };
1920
1921 let result = run_auto_dream_once_with_store(&context, &memory)
1922 .await
1923 .expect("fallback auto dream should succeed")
1924 .expect("dream output should be produced");
1925 assert_eq!(result.session_count, 1);
1926
1927 let prompts = provider.recorded_prompts();
1928 assert!(prompts.len() >= 3);
1929 assert!(prompts[0].contains("## Existing Dream notebook"));
1930 assert!(!prompts[1].contains("## Existing Dream notebook"));
1931 assert!(prompts[1].contains("## Recent sessions"));
1932
1933 let dream = memory
1934 .read_dream_view()
1935 .await
1936 .expect("read dream view")
1937 .expect("dream should exist after fallback");
1938 assert!(dream.contains("Legacy fallback result"));
1939 }
1940
1941 #[tokio::test]
1942 async fn run_auto_dream_once_returns_none_when_disabled() {
1943 let temp_dir = tempfile::tempdir().expect("tempdir");
1944 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1945
1946 let session_store = Arc::new(
1947 SessionStoreV2::new(temp_dir.path().to_path_buf())
1948 .await
1949 .unwrap(),
1950 );
1951 let storage: Arc<dyn Storage> = session_store.clone();
1952 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
1953 let config = Arc::new(RwLock::new(Config {
1954 memory: Some(bamboo_config::MemoryConfig {
1955 background_model: Some("fast-model".to_string()),
1956 ..bamboo_config::MemoryConfig::default()
1957 }),
1958 ..Config::default()
1959 }));
1960
1961 let context = AutoDreamContext {
1962 session_store,
1963 storage,
1964 provider,
1965 config,
1966 provider_registry: test_registry(),
1967 };
1968 let result = run_auto_dream_once(&context)
1969 .await
1970 .expect("disabled auto dream should not error");
1971 assert!(result.is_none());
1972 }
1973
1974 #[tokio::test]
1975 async fn run_auto_dream_once_returns_none_without_candidate_sessions() {
1976 let temp_dir = tempfile::tempdir().expect("tempdir");
1977 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1978
1979 let session_store = Arc::new(
1980 SessionStoreV2::new(temp_dir.path().to_path_buf())
1981 .await
1982 .unwrap(),
1983 );
1984 let storage: Arc<dyn Storage> = session_store.clone();
1985 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
1986 let config = Arc::new(RwLock::new(Config {
1987 memory: Some(bamboo_config::MemoryConfig {
1988 background_model: Some("fast-model".to_string()),
1989 auto_dream_enabled: true,
1990 ..bamboo_config::MemoryConfig::default()
1991 }),
1992 ..Config::default()
1993 }));
1994
1995 let context = AutoDreamContext {
1996 session_store,
1997 storage,
1998 provider,
1999 config,
2000 provider_registry: test_registry(),
2001 };
2002 let result = run_auto_dream_once(&context)
2003 .await
2004 .expect("no candidate sessions should not error");
2005 assert!(result.is_none());
2006 }
2007}