Skip to main content

bamboo_engine/
auto_dream.rs

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";
28// Auto-Dream tick cadence now lives in `MemoryConfig::auto_dream_interval_secs`
29// (default 30 min); see `spawn_auto_dream_task`.
30const 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    // Resolve background model (and provider when using ProviderModelRef).
584    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            // Fall back to the config default (single source of truth for the
831            // 30-minute cadence) when memory config is absent or set to 0.
832            .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}