Files
Kigi-CLI/crates/codegen/kigi-memory/src/dream.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

1472 lines
54 KiB
Rust

//! autoDream gating and execution logic.
//!
//! Determines whether a dream consolidation should fire based on
//! config gates, time elapsed, and session count. Provides prompt
//! construction, response processing, and lock lifecycle management
//! for the dream consolidation pass.
use std::path::Path;
use std::time::SystemTime;
use super::dream_lock::DreamLock;
use kigi_config_types::MemoryDreamConfig;
/// Result of the dream gate check.
#[derive(Debug, PartialEq, Eq)]
pub enum DreamGate {
/// All gates passed — dream should fire.
Open {
/// Sessions to consolidate (file stems for the prompt).
sessions: Vec<String>,
},
/// Dream is disabled by config.
Disabled,
/// Not enough time has passed since last consolidation.
TooSoon { hours_since: u64 },
/// Not enough sessions since last consolidation.
TooFewSessions { count: usize, required: u64 },
/// Lock acquisition or I/O failed.
Error(String),
}
/// Check all dream gates (cheapest first).
///
/// Gate order:
/// 1. Config: `dream.enabled` must be true
/// 2. Time: hours since last consolidation >= `dream.min_hours`
/// 3. Sessions: session count since last consolidation >= `dream.min_sessions`
///
/// Does NOT acquire the lock — callers should acquire after confirming `Open`.
pub fn check_dream_gates(
config: &MemoryDreamConfig,
lock: &DreamLock,
sessions_dir: &Path,
current_session_sid8: Option<&str>,
) -> DreamGate {
if !config.enabled {
return DreamGate::Disabled;
}
// Time gate
let last_at = match lock.last_consolidated_at() {
Ok(Some(t)) => t,
Ok(None) => SystemTime::UNIX_EPOCH,
Err(e) => return DreamGate::Error(e.to_string()),
};
let elapsed = SystemTime::now()
.duration_since(last_at)
.unwrap_or_default();
let hours_since = elapsed.as_secs() / 3600;
if hours_since < config.min_hours {
return DreamGate::TooSoon { hours_since };
}
// Session gate
let sessions =
match super::dream_lock::sessions_since(sessions_dir, last_at, current_session_sid8) {
Ok(s) => s,
Err(e) => return DreamGate::Error(e.to_string()),
};
if (sessions.len() as u64) < config.min_sessions {
return DreamGate::TooFewSessions {
count: sessions.len(),
required: config.min_sessions,
};
}
DreamGate::Open { sessions }
}
// ---------------------------------------------------------------------------
// Dream prompt, response processing, and execution
// ---------------------------------------------------------------------------
use super::text_utils::{has_markdown_headers, is_no_reply};
const LOG: &str = "xai_memory";
pub const DREAM_SYSTEM_PROMPT: &str = "\
You are performing a dream \u{2014} a reflective pass over memory files. \
Synthesize recent session logs into durable, well-organized memories \
so future sessions orient quickly.
You will receive the contents of recent session logs. \
You may also receive an existing memory document \u{2014} merge it with new sessions \
rather than discarding prior knowledge. Your job:
1. **Merge** related information into coherent topic summaries
2. **Resolve** contradictions \u{2014} if a recent session disproves an older fact, keep only the current truth
3. **Convert** relative dates (\"yesterday\", \"last week\") to absolute dates
4. **Discard** ephemeral details:
- Greetings, meta-commentary, tool output noise
- Message counts and tool-usage statistics
- 'Current state' and 'Next steps' sections
- User preferences already in global memory (OS, shell, paths)
- Session metadata (dates, message counts)
5. **Preserve** decisions, rationale, architecture, preferences, and problem/solution pairs
Respond with a single markdown document. Use ## headers to separate topics. \
Each topic should be self-contained and useful to a future session that knows \
nothing about the current conversation.
If the session logs contain nothing worth persisting, respond with NO_REPLY.";
/// Outcome of a dream consolidation attempt.
#[derive(Debug)]
pub struct DreamResult {
pub status: DreamStatus,
/// Number of gate-eligible sessions (total sessions passed to the dream,
/// including those beyond the 32K input cap that were not actually read).
pub sessions_eligible: usize,
/// File stems that were actually deleted after a successful consolidation.
/// Empty for non-`Completed` statuses. The caller uses this to remove
/// the corresponding chunks from the search index — only stems that were
/// truly removed from disk should have their index entries purged.
pub cleaned_stems: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum DreamStatus {
/// Gates didn't pass \u{2014} no dream attempted.
Skipped(String),
/// Dream ran and produced consolidated output.
Completed { chars_written: usize },
/// Dream ran but model returned nothing useful.
NothingToConsolidate,
/// Dream failed.
Failed(String),
}
const MAX_DREAM_INPUT_CHARS: usize = 32_000;
/// Output of [`build_dream_user_message`]: the prompt text and the stems
/// that were actually read (within the size cap).
#[derive(Debug)]
pub struct DreamMessage {
/// The concatenated session content for the model prompt.
pub content: String,
/// File stems that were successfully read and included. Only these
/// sessions should be cleaned up after a successful consolidation —
/// stems beyond the [`MAX_DREAM_INPUT_CHARS`] cap are deliberately
/// excluded so their content is preserved for a future dream pass.
pub processed_stems: Vec<String>,
}
/// Returns `true` if the content is scaffold boilerplate that should not be
/// fed to the dream model as existing memory context.
///
/// A file is scaffold only if it is short (< 500 bytes trimmed) AND contains
/// a scaffold marker. Files with substantial content are never scaffold, even
/// if they contain leftover marker strings from the initial template.
pub(crate) fn is_scaffold_template(content: &str) -> bool {
const SCAFFOLD_MAX_LEN: usize = 500;
const MARKERS: &[&str] = &[
"Auto-populated by dream consolidation",
"Add project-specific knowledge here",
"Add any cross-project preferences here",
];
let trimmed = content.trim();
trimmed.len() < SCAFFOLD_MAX_LEN && MARKERS.iter().any(|marker| trimmed.contains(marker))
}
/// Build the user message for the dream model call from session log contents.
///
/// When `existing_memory` is provided and is not scaffold boilerplate,
/// it is prepended before session logs so the model can merge prior
/// knowledge with new sessions.
///
/// Reads each session file and concatenates their contents with separators.
/// Stops adding sessions once total size exceeds [`MAX_DREAM_INPUT_CHARS`].
/// Returns `None` if no session files could be read.
pub fn build_dream_user_message(
sessions_dir: &Path,
stems: &[String],
existing_memory: Option<&str>,
) -> Option<DreamMessage> {
let existing_len = existing_memory.map_or(0, str::len);
let mut buf = String::with_capacity(existing_len + stems.len().min(10) * 2000);
if let Some(mem) = existing_memory {
let trimmed = mem.trim();
if !trimmed.is_empty() && !is_scaffold_template(trimmed) {
buf.push_str("--- Existing Memory (merge with new sessions) ---\n\n");
let cap = MAX_DREAM_INPUT_CHARS / 2;
if trimmed.len() <= cap {
buf.push_str(trimmed);
} else {
let mut end = cap;
while end > 0 && !trimmed.is_char_boundary(end) {
end -= 1;
}
buf.push_str(&trimmed[..end]);
tracing::warn!(
target: LOG,
original = trimmed.len(),
cap,
"DREAM_BUILD_MESSAGE: existing memory truncated"
);
}
}
}
let mut processed_stems = Vec::with_capacity(stems.len());
for stem in stems {
let path = sessions_dir.join(format!("{stem}.md"));
if let Ok(content) = std::fs::read_to_string(&path)
&& !content.trim().is_empty()
{
if !buf.is_empty() {
buf.push_str("\n\n");
}
buf.push_str("--- Session: ");
buf.push_str(stem);
buf.push_str(" ---\n\n");
buf.push_str(&content);
processed_stems.push(stem.clone());
if buf.len() >= MAX_DREAM_INPUT_CHARS {
tracing::warn!(
target: LOG,
sessions_read = processed_stems.len(),
total = stems.len(),
chars = buf.len(),
"DREAM_BUILD_MESSAGE: truncated input at {MAX_DREAM_INPUT_CHARS} chars"
);
break;
}
}
}
if processed_stems.is_empty() {
tracing::debug!(target: LOG, "DREAM_BUILD_MESSAGE: no readable session files");
return None;
}
tracing::info!(
target: LOG,
sessions_read = processed_stems.len(),
total = stems.len(),
chars = buf.len(),
"DREAM_BUILD_MESSAGE: built user message"
);
Some(DreamMessage {
content: buf,
processed_stems,
})
}
/// Output cap for dream responses. Hardcoded for v1; can be moved to
/// `MemoryDreamConfig` if operational tuning is needed.
const MAX_DREAM_CHARS: usize = 16_000;
/// Process the dream model's response.
///
/// Returns the processed content ready for writing, or `None` if:
/// - Response is empty/whitespace
/// - Response matches the `NO_REPLY` pattern
/// - Response lacks markdown heading structure
///
/// Dream output that passes the quality check below contains markdown
/// headers (`# ` or `## `). Since `write_long_term` writes content
/// directly without normalization, dream's markdown structure is
/// preserved as-is.
///
/// Truncates content exceeding [`MAX_DREAM_CHARS`].
pub fn process_dream_response(response: &str) -> Option<String> {
let trimmed = response.trim();
if trimmed.is_empty() {
tracing::info!(target: LOG, "DREAM_RESPONSE: empty");
return None;
}
if is_no_reply(trimmed) {
tracing::info!(target: LOG, "DREAM_RESPONSE: NO_REPLY");
return None;
}
if !has_markdown_headers(trimmed) {
tracing::info!(
target: LOG,
len = trimmed.len(),
"DREAM_RESPONSE: no markdown headers, rejected"
);
return None;
}
let char_count = trimmed.chars().count();
let (content, accepted_chars) = if char_count > MAX_DREAM_CHARS {
tracing::warn!(
target: LOG,
original = char_count,
limit = MAX_DREAM_CHARS,
"DREAM_RESPONSE: truncated"
);
(
trimmed.chars().take(MAX_DREAM_CHARS).collect(),
MAX_DREAM_CHARS,
)
} else {
(trimmed.to_string(), char_count)
};
tracing::info!(
target: LOG,
chars = accepted_chars,
"DREAM_RESPONSE: accepted"
);
Some(content)
}
/// Minimum age (in seconds) a session file must have before cleanup will
/// delete it. Protects against removing files that a concurrent session
/// may still be actively appending to.
const CLEANUP_RECENCY_GUARD_SECS: u64 = 300; // 5 minutes
/// Delete session log files whose stems were processed during dream.
///
/// Returns the stems that were actually removed from disk. The caller
/// uses this list to purge the corresponding search-index entries —
/// stems skipped by the recency guard or that failed to delete are
/// excluded so their index chunks remain intact.
///
/// Logs warnings for individual deletion failures but never propagates
/// errors \u{2014} the consolidation has already succeeded at this point.
///
/// Files modified within the last [`CLEANUP_RECENCY_GUARD_SECS`] are
/// skipped to avoid deleting logs that a concurrent session is still
/// actively writing to.
fn clean_processed_sessions(sessions_dir: &Path, stems: &[String]) -> Vec<String> {
let mut cleaned = Vec::new();
let now = SystemTime::now();
for stem in stems {
let path = sessions_dir.join(format!("{stem}.md"));
// Recency guard: skip files modified very recently — a
// concurrent session may be actively appending.
if let Ok(meta) = std::fs::metadata(&path)
&& let Ok(mtime) = meta.modified()
&& now.duration_since(mtime).unwrap_or_default().as_secs() < CLEANUP_RECENCY_GUARD_SECS
{
tracing::debug!(
target: LOG,
path = %path.display(),
"DREAM_CLEANUP: skipping recently-modified session file"
);
continue;
}
match std::fs::remove_file(&path) {
Ok(()) => cleaned.push(stem.clone()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Already gone \u{2014} not an error, but don't count as removed.
}
Err(e) => {
tracing::warn!(
target: LOG,
path = %path.display(),
error = %e,
"DREAM_CLEANUP: failed to delete session file"
);
}
}
}
if !cleaned.is_empty() {
tracing::info!(
target: LOG,
cleaned = cleaned.len(),
total = stems.len(),
"DREAM_CLEANUP: removed processed session files"
);
}
cleaned
}
/// Execute the dream lifecycle around a provided model response.
///
/// This is the "pure logic" half of dream execution \u{2014} the session actor
/// handles the actual model call and passes the response here.
///
/// Steps:
/// 1. Acquire lock
/// 2. Process response
/// 3. Overwrite workspace MEMORY.md
/// 4. Clean up processed session files (on success only)
/// 5. On success: leave lock mtime (records consolidation)
/// 6. On failure: rollback lock
pub fn execute_dream(
lock: &DreamLock,
storage: &super::storage::MemoryStorage,
response: &str,
sessions_eligible: usize,
stale_lock_secs: u64,
sessions_dir: &Path,
processed_stems: &[String],
) -> DreamResult {
let prior = match lock.try_acquire(stale_lock_secs) {
Ok(Some(prior)) => {
tracing::info!(target: LOG, "DREAM_EXECUTE: lock acquired");
prior
}
Ok(None) => {
tracing::info!(target: LOG, "DREAM_EXECUTE: lock held by another process, skipping");
return DreamResult {
status: DreamStatus::Skipped("lock held by another process".into()),
sessions_eligible: 0,
cleaned_stems: Vec::new(),
};
}
Err(e) => {
tracing::warn!(target: LOG, error = %e, "DREAM_EXECUTE: lock acquire failed");
return DreamResult {
status: DreamStatus::Failed(format!("lock acquire failed: {e}")),
sessions_eligible: 0,
cleaned_stems: Vec::new(),
};
}
};
let content = match process_dream_response(response) {
Some(c) => c,
None => {
tracing::info!(target: LOG, sessions_eligible, "DREAM_EXECUTE: nothing to consolidate");
return DreamResult {
status: DreamStatus::NothingToConsolidate,
sessions_eligible,
cleaned_stems: Vec::new(),
};
}
};
let chars_written = content.chars().count();
if let Err(e) = storage.write_long_term(super::storage::MemoryScope::Workspace, &content) {
let _ = lock.rollback(prior);
tracing::warn!(target: LOG, error = %e, "DREAM_EXECUTE: write failed, lock rolled back");
return DreamResult {
status: DreamStatus::Failed(format!("failed to write MEMORY.md: {e}")),
sessions_eligible,
cleaned_stems: Vec::new(),
};
}
// Consolidation succeeded — clean up the session files that were read.
let cleaned_stems = clean_processed_sessions(sessions_dir, processed_stems);
tracing::info!(
target: LOG,
chars_written,
sessions_eligible,
sessions_cleaned = cleaned_stems.len(),
"DREAM_EXECUTE: completed"
);
DreamResult {
status: DreamStatus::Completed { chars_written },
sessions_eligible,
cleaned_stems,
}
}
#[cfg(test)]
mod tests {
use super::*;
use filetime::FileTime;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
// Mirrors `dream_lock::tests::write_session` — kept local to avoid
// cross-module test coupling for a trivial 5-line helper.
fn write_session(dir: &Path, name: &str, age_secs: u64) {
fs::create_dir_all(dir).unwrap();
let path = dir.join(format!("{name}.md"));
fs::write(&path, "test").unwrap();
let t = SystemTime::now() - Duration::from_secs(age_secs);
filetime::set_file_mtime(&path, FileTime::from_system_time(t)).unwrap();
}
fn enabled_config() -> MemoryDreamConfig {
MemoryDreamConfig {
enabled: true,
min_hours: 24,
min_sessions: 5,
stale_lock_secs: 3600,
check_interval_secs: None,
}
}
fn set_consolidation_age(dir: &Path, age_secs: u64) {
let lock_path = dir.join(".dream-lock");
fs::write(&lock_path, "").unwrap();
let t = SystemTime::now() - Duration::from_secs(age_secs);
filetime::set_file_mtime(&lock_path, FileTime::from_system_time(t)).unwrap();
}
#[test]
fn disabled_config_returns_disabled() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
let config = MemoryDreamConfig {
enabled: false,
..enabled_config()
};
assert_eq!(
check_dream_gates(&config, &lock, &sessions, None),
DreamGate::Disabled
);
}
#[test]
fn too_soon_when_recently_consolidated() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
lock.record_consolidation().unwrap();
let sessions = dir.path().join("sessions");
let config = enabled_config();
let result = check_dream_gates(&config, &lock, &sessions, None);
match result {
DreamGate::TooSoon { hours_since } => {
assert!(hours_since < config.min_hours);
}
other => panic!("expected TooSoon, got {other:?}"),
}
}
#[test]
fn too_few_sessions_when_under_threshold() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
set_consolidation_age(dir.path(), 48 * 3600);
for i in 0..3 {
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
}
let config = enabled_config();
let result = check_dream_gates(&config, &lock, &sessions, None);
assert_eq!(
result,
DreamGate::TooFewSessions {
count: 3,
required: 5,
}
);
}
#[test]
fn all_gates_pass_returns_open_with_sessions() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
set_consolidation_age(dir.path(), 48 * 3600);
for i in 0..6 {
write_session(&sessions, &format!("2026-01-{i:02}-proj-aaa{i:05}"), 100);
}
let config = enabled_config();
let result = check_dream_gates(&config, &lock, &sessions, None);
match result {
DreamGate::Open { sessions } => {
assert_eq!(sessions.len(), 6);
assert!(sessions.windows(2).all(|w| w[0] <= w[1]));
}
other => panic!("expected Open, got {other:?}"),
}
}
#[test]
fn no_prior_consolidation_treats_as_epoch() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
for i in 0..5 {
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
}
let config = enabled_config();
let result = check_dream_gates(&config, &lock, &sessions, None);
match result {
DreamGate::Open { sessions } => assert_eq!(sessions.len(), 5),
other => panic!("expected Open, got {other:?}"),
}
}
#[test]
fn nonexistent_sessions_dir_returns_too_few() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("nonexistent");
let config = enabled_config();
let result = check_dream_gates(&config, &lock, &sessions, None);
assert_eq!(
result,
DreamGate::TooFewSessions {
count: 0,
required: 5,
}
);
}
#[test]
fn current_session_excluded_from_count() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
set_consolidation_age(dir.path(), 48 * 3600);
for i in 0..4 {
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
}
write_session(&sessions, "2026-01-05-proj-current1", 100);
let config = enabled_config();
let result = check_dream_gates(&config, &lock, &sessions, Some("current1"));
assert_eq!(
result,
DreamGate::TooFewSessions {
count: 4,
required: 5,
}
);
}
#[test]
fn min_hours_boundary_exact_is_too_soon() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
let config = MemoryDreamConfig {
min_hours: 24,
..enabled_config()
};
set_consolidation_age(dir.path(), 23 * 3600 + 59 * 60);
let result = check_dream_gates(&config, &lock, &sessions, None);
match result {
DreamGate::TooSoon { hours_since } => assert_eq!(hours_since, 23),
other => panic!("expected TooSoon, got {other:?}"),
}
}
#[test]
fn min_sessions_boundary_exact_passes() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
let config = MemoryDreamConfig {
min_sessions: 3,
..enabled_config()
};
for i in 0..3 {
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
}
let result = check_dream_gates(&config, &lock, &sessions, None);
match result {
DreamGate::Open { sessions } => assert_eq!(sessions.len(), 3),
other => panic!("expected Open, got {other:?}"),
}
}
#[test]
fn min_sessions_boundary_one_below_fails() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sessions = dir.path().join("sessions");
let config = MemoryDreamConfig {
min_sessions: 3,
..enabled_config()
};
for i in 0..2 {
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
}
let result = check_dream_gates(&config, &lock, &sessions, None);
assert_eq!(
result,
DreamGate::TooFewSessions {
count: 2,
required: 3,
}
);
}
// -------------------------------------------------------------------
// build_dream_user_message tests
// -------------------------------------------------------------------
fn write_session_content(dir: &Path, name: &str, content: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.md")), content).unwrap();
}
/// Write a session file and back-date its mtime so it passes the
/// recency guard in `clean_processed_sessions`.
fn write_old_session_content(dir: &Path, name: &str, content: &str) {
write_session_content(dir, name, content);
let old = SystemTime::now() - Duration::from_secs(CLEANUP_RECENCY_GUARD_SECS + 60);
let path = dir.join(format!("{name}.md"));
filetime::set_file_mtime(&path, FileTime::from_system_time(old)).unwrap();
}
#[test]
fn build_message_with_valid_sessions() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "sess-a", "Decision: use Rust");
write_session_content(&sessions, "sess-b", "Architecture: event-driven");
let stems = vec!["sess-a".to_string(), "sess-b".to_string()];
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
assert!(msg.content.contains("--- Session: sess-a ---"));
assert!(msg.content.contains("Decision: use Rust"));
assert!(msg.content.contains("--- Session: sess-b ---"));
assert!(msg.content.contains("Architecture: event-driven"));
assert_eq!(msg.processed_stems, vec!["sess-a", "sess-b"]);
}
#[test]
fn build_message_skips_empty_sessions() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "real", "Some content");
write_session_content(&sessions, "empty", " ");
let stems = vec!["real".to_string(), "empty".to_string()];
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
assert!(msg.content.contains("--- Session: real ---"));
assert!(!msg.content.contains("--- Session: empty ---"));
assert_eq!(msg.processed_stems, vec!["real"]);
}
#[test]
fn build_message_returns_none_for_no_readable_files() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
fs::create_dir_all(&sessions).unwrap();
let stems = vec!["missing-a".to_string(), "missing-b".to_string()];
assert!(build_dream_user_message(&sessions, &stems, None).is_none());
}
#[test]
fn build_message_mixed_readable_and_missing() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "exists", "Important info");
let stems = vec!["exists".to_string(), "ghost".to_string()];
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
assert!(msg.content.contains("Important info"));
assert!(!msg.content.contains("ghost"));
assert_eq!(msg.processed_stems, vec!["exists"]);
}
// -------------------------------------------------------------------
// process_dream_response tests
// -------------------------------------------------------------------
#[test]
fn process_empty_response_returns_none() {
assert!(process_dream_response("").is_none());
assert!(process_dream_response(" ").is_none());
assert!(process_dream_response("\n\n").is_none());
}
#[test]
fn process_no_reply_returns_none() {
assert!(process_dream_response("NO_REPLY").is_none());
assert!(process_dream_response("no reply").is_none());
assert!(process_dream_response("No-Reply").is_none());
assert!(process_dream_response(" NO_REPLY ").is_none());
}
#[test]
fn process_valid_markdown_returns_content() {
let input = "## Topic A\n\nSome insight.\n\n## Topic B\n\nAnother insight.";
let result = process_dream_response(input).unwrap();
assert_eq!(result, input);
}
#[test]
fn process_h1_header_accepted() {
let input = "# Top Level\n\nSome content.";
assert_eq!(process_dream_response(input).unwrap(), input);
}
#[test]
fn process_no_headers_returns_none() {
assert!(process_dream_response("Just plain text without headers.").is_none());
}
#[test]
fn process_truncates_long_content() {
let header = "## Long\n\n";
let body = "x".repeat(MAX_DREAM_CHARS + 1000);
let input = format!("{header}{body}");
let result = process_dream_response(&input).unwrap();
assert_eq!(result.chars().count(), MAX_DREAM_CHARS);
assert!(result.starts_with("## Long"));
}
#[test]
fn process_exactly_at_limit_not_truncated() {
let header = "## H\n\n";
let remaining = MAX_DREAM_CHARS - header.len();
let body = "a".repeat(remaining);
let input = format!("{header}{body}");
let result = process_dream_response(&input).unwrap();
assert_eq!(result.chars().count(), MAX_DREAM_CHARS);
assert_eq!(result, input);
}
// -------------------------------------------------------------------
// execute_dream tests
// -------------------------------------------------------------------
use super::super::storage::MemoryStorage;
use std::path::PathBuf;
fn test_storage(tmp: &TempDir) -> (MemoryStorage, PathBuf) {
let global = tmp.path().join("memory");
let workspace = global.join("test-ws");
let storage = MemoryStorage::with_paths(global, workspace.clone());
(storage, workspace)
}
/// Helper: path for an empty sessions directory (no cleanup expected).
fn empty_sessions_dir(tmp: &TempDir) -> PathBuf {
let p = tmp.path().join("empty-sessions");
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn execute_dream_valid_response_writes_memory() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, ws) = test_storage(&dir);
let sdir = empty_sessions_dir(&dir);
let response = "## Decisions\n\nWe chose Rust.\n\n## Architecture\n\nEvent-driven.";
let result = execute_dream(&lock, &storage, response, 5, 300, &sdir, &[]);
assert!(
matches!(result.status, DreamStatus::Completed { chars_written } if chars_written == response.chars().count())
);
assert_eq!(result.sessions_eligible, 5);
assert_eq!(result.cleaned_stems.len(), 0);
let memory = fs::read_to_string(ws.join("MEMORY.md")).unwrap();
assert!(memory.contains("We chose Rust."));
assert!(memory.contains("Event-driven."));
}
#[test]
fn execute_dream_empty_response_nothing_to_consolidate() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _) = test_storage(&dir);
let sdir = empty_sessions_dir(&dir);
let result = execute_dream(&lock, &storage, "", 3, 300, &sdir, &[]);
assert_eq!(result.status, DreamStatus::NothingToConsolidate);
assert_eq!(result.sessions_eligible, 3);
assert_eq!(result.cleaned_stems.len(), 0);
}
#[test]
fn execute_dream_no_reply_nothing_to_consolidate() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _) = test_storage(&dir);
let sdir = empty_sessions_dir(&dir);
let result = execute_dream(&lock, &storage, "NO_REPLY", 2, 300, &sdir, &[]);
assert_eq!(result.status, DreamStatus::NothingToConsolidate);
assert_eq!(result.cleaned_stems.len(), 0);
}
#[test]
fn execute_dream_lock_held_returns_skipped() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _) = test_storage(&dir);
let sdir = empty_sessions_dir(&dir);
// Acquire the lock (our own PID, non-stale)
lock.try_acquire(300).unwrap().unwrap();
let result = execute_dream(&lock, &storage, "## Topic\n\nContent", 5, 300, &sdir, &[]);
match &result.status {
DreamStatus::Skipped(reason) => {
assert!(reason.contains("lock"), "reason: {reason}");
}
other => panic!("expected Skipped, got {other:?}"),
}
assert_eq!(result.sessions_eligible, 0);
assert_eq!(result.cleaned_stems.len(), 0);
}
#[test]
fn execute_dream_write_failure_rolls_back_lock() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let sdir = empty_sessions_dir(&dir);
// Point workspace dir at a path under a regular file, so
// create_dir_all inside append_to_memory will fail (works
// even when running as root, unlike chmod-based approaches).
let blocker = dir.path().join("memory").join("blocker");
fs::create_dir_all(blocker.parent().unwrap()).unwrap();
fs::write(&blocker, "I am a file").unwrap();
let workspace = blocker.join("impossible-subdir");
let storage = MemoryStorage::with_paths(dir.path().join("memory"), workspace);
let result = execute_dream(&lock, &storage, "## Topic\n\nContent", 3, 300, &sdir, &[]);
match &result.status {
DreamStatus::Failed(reason) => {
assert!(reason.contains("MEMORY.md"), "reason: {reason}");
}
other => panic!("expected Failed, got {other:?}"),
}
assert_eq!(result.cleaned_stems.len(), 0);
// Verify rollback: lock file should be gone (prior was None).
let lock_path = dir.path().join(".dream-lock");
assert!(
!lock_path.exists(),
"lock file should be deleted after rollback with no prior"
);
}
#[test]
fn execute_dream_overwrites_existing_memory() {
let dir = TempDir::new().unwrap();
let (storage, ws) = test_storage(&dir);
let sdir = empty_sessions_dir(&dir);
// Pre-populate MEMORY.md
fs::create_dir_all(&ws).unwrap();
fs::write(ws.join("MEMORY.md"), "## Existing\n\nOld content.").unwrap();
let lock = DreamLock::new(dir.path());
let response = "## New Topic\n\nFresh insight.";
let result = execute_dream(&lock, &storage, response, 2, 300, &sdir, &[]);
assert!(matches!(result.status, DreamStatus::Completed { .. }));
let memory = fs::read_to_string(ws.join("MEMORY.md")).unwrap();
// write_long_term overwrites: old content is replaced
assert!(!memory.contains("Old content."));
assert_eq!(memory.trim(), response);
}
// -------------------------------------------------------------------
// DREAM_SYSTEM_PROMPT sanity checks
// -------------------------------------------------------------------
#[test]
fn dream_system_prompt_has_required_content() {
assert!(DREAM_SYSTEM_PROMPT.contains("dream"));
assert!(DREAM_SYSTEM_PROMPT.contains("NO_REPLY"));
assert!(DREAM_SYSTEM_PROMPT.contains("## headers"));
// Core instruction verbs that define dream behavior
assert!(DREAM_SYSTEM_PROMPT.contains("Merge"));
assert!(DREAM_SYSTEM_PROMPT.contains("Resolve"));
assert!(DREAM_SYSTEM_PROMPT.contains("Discard"));
assert!(DREAM_SYSTEM_PROMPT.contains("Preserve"));
assert!(DREAM_SYSTEM_PROMPT.contains("Convert"));
}
#[test]
fn build_message_respects_input_size_cap() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
let big_content = "x".repeat(MAX_DREAM_INPUT_CHARS);
write_session_content(&sessions, "big", &big_content);
write_session_content(&sessions, "small", "should not appear");
let stems = vec!["big".to_string(), "small".to_string()];
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
assert!(msg.content.len() >= MAX_DREAM_INPUT_CHARS);
assert!(
!msg.content.contains("should not appear"),
"second session should be skipped after cap"
);
// Only the first stem should be in processed_stems — the second
// was beyond the 32K cap and must NOT be cleaned up.
assert_eq!(msg.processed_stems, vec!["big"]);
}
#[test]
fn normalize_memory_content_preserves_headers() {
let input = "## Topic A\n\nContent A.\n\n## Topic B\n\nContent B.";
let normalized = super::super::storage::normalize_memory_content(input);
assert_eq!(normalized, input);
}
// -------------------------------------------------------------------
// Session cleanup tests
// -------------------------------------------------------------------
#[test]
fn cleanup_deletes_processed_sessions_on_completed() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _ws) = test_storage(&dir);
// Create session files that will be "processed" (old mtime to
// pass the recency guard).
let sessions = dir.path().join("sessions");
write_old_session_content(&sessions, "sess-a", "Content A");
write_old_session_content(&sessions, "sess-b", "Content B");
let processed = vec!["sess-a".to_string(), "sess-b".to_string()];
let response = "## Consolidated\n\nMerged content.";
let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);
assert!(matches!(result.status, DreamStatus::Completed { .. }));
assert_eq!(result.cleaned_stems.len(), 2);
// Both files should be gone
assert!(!sessions.join("sess-a.md").exists());
assert!(!sessions.join("sess-b.md").exists());
}
#[test]
fn cleanup_preserves_unprocessed_sessions() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _ws) = test_storage(&dir);
// Create session files — only some will be "processed"
let sessions = dir.path().join("sessions");
write_old_session_content(&sessions, "processed", "Content");
write_old_session_content(&sessions, "unprocessed", "Kept content");
// Only mark "processed" as having been read
let processed = vec!["processed".to_string()];
let response = "## Consolidated\n\nMerged.";
let result = execute_dream(&lock, &storage, response, 1, 300, &sessions, &processed);
assert!(matches!(result.status, DreamStatus::Completed { .. }));
assert_eq!(result.cleaned_stems.len(), 1);
// Processed file should be gone, unprocessed should remain
assert!(!sessions.join("processed.md").exists());
assert!(sessions.join("unprocessed.md").exists());
assert_eq!(
fs::read_to_string(sessions.join("unprocessed.md")).unwrap(),
"Kept content"
);
}
#[test]
fn cleanup_failure_does_not_affect_completed_status() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _ws) = test_storage(&dir);
let sessions = dir.path().join("sessions");
fs::create_dir_all(&sessions).unwrap();
// Create a directory where a .md file is expected — remove_file
// on a directory fails even as root, giving us a guaranteed
// cleanup failure without relying on chmod. Back-date the dir's
// mtime so it passes the recency guard.
let bad_path = sessions.join("bad-stem.md");
fs::create_dir_all(&bad_path).unwrap();
let old = SystemTime::now() - Duration::from_secs(CLEANUP_RECENCY_GUARD_SECS + 60);
filetime::set_file_mtime(&bad_path, FileTime::from_system_time(old)).unwrap();
// Also create a normal file that CAN be cleaned
write_old_session_content(&sessions, "good-stem", "Content");
let processed = vec!["bad-stem".to_string(), "good-stem".to_string()];
let response = "## Consolidated\n\nMerged.";
let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);
// Status must still be Completed despite the cleanup failure
assert!(matches!(result.status, DreamStatus::Completed { .. }));
// Only the good-stem file was cleaned; bad-stem failed
assert_eq!(result.cleaned_stems.len(), 1);
// The directory-as-file still exists (cleanup failure)
assert!(sessions.join("bad-stem.md").exists());
// The normal file was successfully removed
assert!(!sessions.join("good-stem.md").exists());
}
#[test]
fn cleanup_skips_recently_modified_files() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _ws) = test_storage(&dir);
let sessions = dir.path().join("sessions");
// This file has the current mtime (within recency guard window)
write_session_content(&sessions, "recent", "Still being written");
// This file is old enough to be cleaned
write_old_session_content(&sessions, "old", "Done");
let processed = vec!["recent".to_string(), "old".to_string()];
let response = "## Consolidated\n\nMerged.";
let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);
assert!(matches!(result.status, DreamStatus::Completed { .. }));
// Only the old file should be cleaned; recent should be skipped.
// Crucially, cleaned_stems must contain ONLY "old" — if the
// caller used this list for index cleanup, "recent" must NOT
// have its index chunks removed since its file is still on disk.
assert_eq!(result.cleaned_stems, vec!["old"]);
assert!(
sessions.join("recent.md").exists(),
"recently-modified file must be preserved"
);
assert!(!sessions.join("old.md").exists());
}
#[test]
fn no_cleanup_on_nothing_to_consolidate() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _ws) = test_storage(&dir);
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "kept", "Content");
let processed = vec!["kept".to_string()];
// NO_REPLY → NothingToConsolidate — should NOT clean up
let result = execute_dream(&lock, &storage, "NO_REPLY", 1, 300, &sessions, &processed);
assert_eq!(result.status, DreamStatus::NothingToConsolidate);
assert_eq!(result.cleaned_stems.len(), 0);
assert!(
sessions.join("kept.md").exists(),
"session file must be preserved on NothingToConsolidate"
);
}
#[test]
fn no_cleanup_on_failed_status() {
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
// Force a write failure with the blocker trick
let blocker = dir.path().join("memory").join("blocker");
fs::create_dir_all(blocker.parent().unwrap()).unwrap();
fs::write(&blocker, "I am a file").unwrap();
let workspace = blocker.join("impossible-subdir");
let storage = MemoryStorage::with_paths(dir.path().join("memory"), workspace);
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "kept", "Content");
let processed = vec!["kept".to_string()];
let result = execute_dream(
&lock,
&storage,
"## Topic\n\nContent",
1,
300,
&sessions,
&processed,
);
assert!(matches!(result.status, DreamStatus::Failed(_)));
assert_eq!(result.cleaned_stems.len(), 0);
assert!(
sessions.join("kept.md").exists(),
"session file must be preserved on Failed"
);
}
#[test]
fn build_message_processed_stems_excludes_capped_sessions() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
// First session fills the buffer past the cap
let big_content = "x".repeat(MAX_DREAM_INPUT_CHARS);
write_session_content(&sessions, "first", &big_content);
// Second session should NOT be processed (over cap)
write_session_content(&sessions, "second", "small content");
// Third session should NOT be processed either
write_session_content(&sessions, "third", "more content");
let stems = vec![
"first".to_string(),
"second".to_string(),
"third".to_string(),
];
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
assert_eq!(
msg.processed_stems,
vec!["first"],
"only the first session should be in processed_stems"
);
assert_eq!(msg.processed_stems.len(), 1);
}
#[test]
fn end_to_end_cap_boundary_cleanup() {
// Integration test: build_dream_user_message hits the 32K cap
// partway through the stems list, then execute_dream cleans up
// only the processed files while preserving unprocessed ones.
let dir = TempDir::new().unwrap();
let lock = DreamLock::new(dir.path());
let (storage, _ws) = test_storage(&dir);
let sessions = dir.path().join("sessions");
// Create 5 session files. The first 2 will fill past the cap;
// sessions 3-5 should survive cleanup.
let half_cap = MAX_DREAM_INPUT_CHARS / 2 + 500; // slightly over half
write_old_session_content(&sessions, "aaa-first", &"a".repeat(half_cap));
write_old_session_content(&sessions, "bbb-second", &"b".repeat(half_cap));
write_old_session_content(&sessions, "ccc-third", "small content 3");
write_old_session_content(&sessions, "ddd-fourth", "small content 4");
write_old_session_content(&sessions, "eee-fifth", "small content 5");
let all_stems: Vec<String> = vec![
"aaa-first",
"bbb-second",
"ccc-third",
"ddd-fourth",
"eee-fifth",
]
.into_iter()
.map(String::from)
.collect();
// Phase 1: build_dream_user_message should cap after the first 2
let dream_msg = build_dream_user_message(&sessions, &all_stems, None).unwrap();
assert_eq!(
dream_msg.processed_stems.len(),
2,
"only first 2 sessions should fit within 32K cap"
);
assert_eq!(dream_msg.processed_stems[0], "aaa-first");
assert_eq!(dream_msg.processed_stems[1], "bbb-second");
// Phase 2: execute_dream with a valid response should clean up
// only the processed stems.
let response = "## Consolidated\n\nMerged from 2 sessions.";
let result = execute_dream(
&lock,
&storage,
response,
all_stems.len(),
300,
&sessions,
&dream_msg.processed_stems,
);
assert!(matches!(result.status, DreamStatus::Completed { .. }));
assert_eq!(result.cleaned_stems.len(), 2);
// Processed files deleted
assert!(!sessions.join("aaa-first.md").exists());
assert!(!sessions.join("bbb-second.md").exists());
// Unprocessed files preserved
assert!(sessions.join("ccc-third.md").exists());
assert!(sessions.join("ddd-fourth.md").exists());
assert!(sessions.join("eee-fifth.md").exists());
}
#[test]
fn clean_processed_sessions_handles_already_missing_files() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
fs::create_dir_all(&sessions).unwrap();
// Only create one of two stems (old mtime to pass recency guard)
write_old_session_content(&sessions, "exists", "Content");
// Pass both — "missing" doesn't exist, should not error or count
let cleaned =
clean_processed_sessions(&sessions, &["exists".to_string(), "missing".to_string()]);
assert_eq!(
cleaned.len(),
1,
"only the existing file should count as cleaned"
);
assert_eq!(cleaned[0], "exists");
assert!(!sessions.join("exists.md").exists());
}
// -------------------------------------------------------------------
// is_scaffold_template tests
// -------------------------------------------------------------------
#[test]
fn scaffold_detects_old_workspace_template() {
assert!(is_scaffold_template(
"# Project Memory\n\n## Project Context\n\n<!-- Add project-specific knowledge here -->\n",
));
}
#[test]
fn scaffold_detects_new_workspace_template() {
assert!(is_scaffold_template(
"# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n",
));
}
#[test]
fn scaffold_detects_global_template() {
assert!(is_scaffold_template(
"# Global Memory\n\n## Preferences\n\n<!-- Add any cross-project preferences here -->\n",
));
}
#[test]
fn scaffold_rejects_real_content() {
assert!(!is_scaffold_template(
"## Decisions\n\nWe chose Rust.\n\n## Architecture\n\nEvent-driven.",
));
}
#[test]
fn scaffold_boundary_499_bytes_is_scaffold() {
let marker = "Add project-specific knowledge here";
let pad_len = 499 - marker.len();
let content = format!("{}{}", "x".repeat(pad_len), marker);
assert_eq!(content.trim().len(), 499);
assert!(
is_scaffold_template(&content),
"499-byte content with marker must be classified as scaffold"
);
}
#[test]
fn scaffold_boundary_500_bytes_is_not_scaffold() {
let marker = "Add project-specific knowledge here";
let pad_len = 500 - marker.len();
let content = format!("{}{}", "x".repeat(pad_len), marker);
assert_eq!(content.trim().len(), 500);
assert!(
!is_scaffold_template(&content),
"500-byte content with marker must NOT be classified as scaffold"
);
}
#[test]
fn scaffold_rejects_large_content_with_leftover_marker() {
let real_content = "x".repeat(600);
let content_with_marker = format!(
"# Project Memory\n\n<!-- Add project-specific knowledge here -->\n\n## Decisions\n\n{}",
real_content
);
assert!(
!is_scaffold_template(&content_with_marker),
"large file with leftover scaffold marker must NOT be classified as scaffold"
);
}
#[test]
fn scaffold_rejects_large_workspace_template_with_real_content() {
let real_content = "a ".repeat(300);
let content = format!(
"# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n\n## Architecture\n\n{}",
real_content
);
assert!(
!is_scaffold_template(&content),
"workspace template with substantial appended content must not be scaffold"
);
}
// -------------------------------------------------------------------
// build_dream_user_message with existing memory tests
// -------------------------------------------------------------------
#[test]
fn build_message_prepends_existing_memory() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "sess-a", "Decision: use Rust");
let stems = vec!["sess-a".to_string()];
let existing = "## Prior\n\nOld knowledge.";
let msg = build_dream_user_message(&sessions, &stems, Some(existing)).unwrap();
assert!(msg.content.starts_with("--- Existing Memory"));
assert!(msg.content.contains("Old knowledge."));
assert!(msg.content.contains("Decision: use Rust"));
let mem_pos = msg.content.find("Old knowledge.").unwrap();
let sess_pos = msg.content.find("Decision: use Rust").unwrap();
assert!(mem_pos < sess_pos, "existing memory must precede sessions");
}
#[test]
fn build_message_skips_scaffold_existing_memory() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "sess-a", "Decision: use Rust");
let stems = vec!["sess-a".to_string()];
let scaffold = "# Project Memory\n\n<!-- Add project-specific knowledge here -->\n";
let msg = build_dream_user_message(&sessions, &stems, Some(scaffold)).unwrap();
assert!(!msg.content.contains("Existing Memory"));
assert!(msg.content.contains("Decision: use Rust"));
}
#[test]
fn build_message_includes_large_memory_with_scaffold_marker() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
write_session_content(&sessions, "sess-a", "Decision: use Rust");
let stems = vec!["sess-a".to_string()];
let real_content = "x".repeat(600);
let memory = format!(
"# Project Memory\n\n<!-- Add project-specific knowledge here -->\n\n## Architecture\n\n{}",
real_content
);
let msg = build_dream_user_message(&sessions, &stems, Some(&memory)).unwrap();
assert!(
msg.content.contains("Existing Memory"),
"large memory with leftover scaffold marker must be included"
);
assert!(msg.content.contains(&real_content));
}
#[test]
fn build_message_returns_none_with_existing_memory_but_no_sessions() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
fs::create_dir_all(&sessions).unwrap();
let stems = vec!["missing".to_string()];
let existing = "## Prior\n\nOld knowledge.";
assert!(
build_dream_user_message(&sessions, &stems, Some(existing)).is_none(),
"should return None when no sessions are readable, even with existing memory"
);
}
}