mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat: unified thread model for web gateway Every piece of activity (user chat, routine run, heartbeat alert, external channel message) now lives in its own thread, properly isolated, with meaningful titles and visual distinction. Key changes: - Add `channel` field to ConversationSummary and ThreadInfo so the gateway can distinguish thread origins (gateway, telegram, routine, heartbeat). - Add `list_conversations_all_channels` to Database trait (both postgres and libsql) so chat_threads_handler shows cross-channel threads. - Routine runs get a persistent conversation per routine via `get_or_create_routine_conversation`; notifications carry thread_id. - Heartbeat gets a persistent conversation via `get_or_create_heartbeat_conversation`; HeartbeatRunner accepts an optional Database store and binds notifications to the thread. - Fix broadcast() in web gateway to propagate response.thread_id instead of hardcoding empty string. - Fix isCurrentThread(null) returning true (the core notification leak bug) — now returns false so events without a thread_id don't leak into the active thread. - Rewrite frontend thread sidebar: meaningful titles with channel-specific fallbacks, relative timestamps instead of turn counts, channel badges for non-gateway threads, unread notification dots, read-only indicator for external channel threads. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review — TOCTOU races, stale comment, debounce, broadcast warning - Fix TOCTOU race in get_or_create_routine_conversation (postgres): use INSERT ON CONFLICT on new uq_conv_routine unique index + SELECT-back. - Fix TOCTOU race in get_or_create_heartbeat_conversation (postgres): use INSERT ON CONFLICT on new uq_conv_heartbeat unique index + SELECT-back. - Fix TOCTOU race in get_or_create_routine_conversation (libsql): use BEGIN IMMEDIATE transaction to serialize concurrent writers. - Fix TOCTOU race in get_or_create_heartbeat_conversation (libsql): use BEGIN IMMEDIATE transaction to serialize concurrent writers. - Add V11 migration with partial unique indexes for postgres. - Add matching unique indexes to libsql schema. - Update stale comment on isCurrentThread (said "always shown" but logic now returns false for missing thread_id). - Debounce loadThreads() on off-thread SSE events to prevent request storms. - Log warning in broadcast() when thread_id is None (clients will drop it). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: sort in-memory thread fallback by updated_at descending The in-memory thread list fallback (when no DB is available) used HashMap::values() which has no guaranteed ordering. Sort by updated_at descending to match the SQL query ordering. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: retry libsql connect() on transient "unable to open database file" The cron ticker's background task occasionally fails with "unable to open database file" when creating a new SQLite connection concurrently with the main thread. Add retry with exponential backoff (50ms, 100ms, 200ms) to handle transient VFS/locking issues in libsql's local mode. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use ON CONFLICT with index expressions instead of named constraints PostgreSQL ON CONFLICT ON CONSTRAINT requires a named table constraint, but V11 migration creates unique indexes. Switch to the expression form (ON CONFLICT (columns) WHERE condition) which works with unique indexes. Also fix dead code in threadTitle() where thread.title was already checked on the previous line. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix rustfmt chain collapse in heartbeat.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: skip broadcast when thread_id is None instead of sending empty Clients drop SSE events with empty thread_id anyway, so avoid the unnecessary network traffic by returning early. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add libsql routine/heartbeat conversation idempotency tests Add tests proving get_or_create_routine_conversation returns the same conversation ID across multiple invocations with the same routine_id. Add debug logging to routine engine to track conversation resolution. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: show "New chat" title for empty threads - threadTitle() returns "New chat" when turn_count is 0 - Assistant thread label updates dynamically from API data - Default HTML label changed from "Assistant" to "New chat" - New threads naturally sort to top via last_activity DESC [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: thread sorting, routine isolation, and UI polish - Fix libsql timestamp format mismatch causing broken thread sort order. SQLite defaults used `datetime('now')` (space-separated) while Rust code used RFC3339 (T-separated), breaking string-based ORDER BY. All INSERTs now use RFC3339, and queries use `datetime()` to normalize comparison. - Route manual routine triggers through RoutineEngine.fire_manual() instead of injecting as regular chat messages, so routines always run in their dedicated conversation thread. - Add RoutineEngineSlot to GatewayState for gateway<->engine communication. - Derive routine thread titles from conversation metadata (routine_name) instead of showing truncated UUID hashes. - Make chat_new_thread_handler persist to DB synchronously so loadThreads() sees newly created threads immediately. - Fix enableChatInput() no-op and wrong element ID in disableChatInputReadOnly(). - Fix handlers/chat.rs stale gateway-only query (use list_conversations_all_channels). - Sort in-memory threads by DateTime before converting to RFC3339 strings. - Trigger debouncedLoadThreads() on thinking/status SSE events for non-current threads so routine/heartbeat threads appear in sidebar promptly. - Remove "Threads" text from sidebar header. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: routine history display, orphaned tool_results, duplicate system messages Three independent fixes with regression tests: 1. Routine conversations now display in the web UI. build_turns_from_db_messages() handles standalone assistant messages (no preceding user message) by creating turns with empty user_input. Frontend skips empty user bubbles. 2. Worker select_tools and execute_plan paths now push an assistant_with_tool_calls message before tool execution, preventing sanitize_tool_messages from rewriting tool_results as orphaned user messages. 3. Reasoning::plan() and respond_with_tools() merge system messages from context into a single system prompt instead of creating [system, system, ...] sequences that strict LLM providers (Qwen) reject. Also: sidebar padding/spacing improvements, wider thread panel (240px). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #607 review — RwLock held across await, missing ownership check, heartbeat config - Clone Arc<RoutineEngine> out of RwLock before .await in trigger handler - Add user_id ownership check to fire_manual() with NotAuthorized error - Wire heartbeat notify_user/notify_channel from config to AgentHeartbeatConfig Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: gitignore trace_*.json files and remove stale traces Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: remove trace JSON files from repo Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: proper HTTP status codes for routine errors, read-only input guard, respond thread_id - Map RoutineError::NotFound → 404, NotAuthorized → 403, Disabled → 409 - Guard enableChatInput() against re-enabling on read-only threads - Skip respond() when thread_id is None (matches broadcast() behavior) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
553 lines
18 KiB
Rust
553 lines
18 KiB
Rust
//! Proactive heartbeat system for periodic execution.
|
|
//!
|
|
//! The heartbeat runner executes periodically (default: every 30 minutes) and:
|
|
//! 1. Reads the HEARTBEAT.md checklist
|
|
//! 2. Runs an agent turn to process the checklist
|
|
//! 3. Reports any findings to the configured channel
|
|
//!
|
|
//! If nothing needs attention, the agent replies "HEARTBEAT_OK" and no
|
|
//! message is sent to the user.
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! Create a HEARTBEAT.md in the workspace with a checklist of things to monitor:
|
|
//!
|
|
//! ```markdown
|
|
//! # Heartbeat Checklist
|
|
//!
|
|
//! - [ ] Check for unread emails
|
|
//! - [ ] Review calendar for upcoming events
|
|
//! - [ ] Check project build status
|
|
//! ```
|
|
//!
|
|
//! The agent will process this checklist on each heartbeat and only notify
|
|
//! if action is needed.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::channels::OutgoingResponse;
|
|
use crate::db::Database;
|
|
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
|
use crate::safety::SafetyLayer;
|
|
use crate::workspace::Workspace;
|
|
use crate::workspace::hygiene::HygieneConfig;
|
|
|
|
/// Configuration for the heartbeat runner.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HeartbeatConfig {
|
|
/// Interval between heartbeat checks.
|
|
pub interval: Duration,
|
|
/// Whether heartbeat is enabled.
|
|
pub enabled: bool,
|
|
/// Maximum consecutive failures before disabling.
|
|
pub max_failures: u32,
|
|
/// User ID to notify on heartbeat findings.
|
|
pub notify_user_id: Option<String>,
|
|
/// Channel to notify on heartbeat findings.
|
|
pub notify_channel: Option<String>,
|
|
}
|
|
|
|
impl Default for HeartbeatConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
interval: Duration::from_secs(30 * 60), // 30 minutes
|
|
enabled: true,
|
|
max_failures: 3,
|
|
notify_user_id: None,
|
|
notify_channel: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HeartbeatConfig {
|
|
/// Create a config with a specific interval.
|
|
pub fn with_interval(mut self, interval: Duration) -> Self {
|
|
self.interval = interval;
|
|
self
|
|
}
|
|
|
|
/// Disable heartbeat.
|
|
pub fn disabled(mut self) -> Self {
|
|
self.enabled = false;
|
|
self
|
|
}
|
|
|
|
/// Set the notification target.
|
|
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
|
|
self.notify_user_id = Some(user_id.into());
|
|
self.notify_channel = Some(channel.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Result of a heartbeat check.
|
|
#[derive(Debug)]
|
|
pub enum HeartbeatResult {
|
|
/// Nothing needs attention.
|
|
Ok,
|
|
/// Something needs attention, with the message to send.
|
|
NeedsAttention(String),
|
|
/// Heartbeat was skipped (no checklist or disabled).
|
|
Skipped,
|
|
/// Heartbeat failed.
|
|
Failed(String),
|
|
}
|
|
|
|
/// Heartbeat runner for proactive periodic execution.
|
|
pub struct HeartbeatRunner {
|
|
config: HeartbeatConfig,
|
|
hygiene_config: HygieneConfig,
|
|
workspace: Arc<Workspace>,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
|
store: Option<Arc<dyn Database>>,
|
|
consecutive_failures: u32,
|
|
}
|
|
|
|
impl HeartbeatRunner {
|
|
/// Create a new heartbeat runner.
|
|
pub fn new(
|
|
config: HeartbeatConfig,
|
|
hygiene_config: HygieneConfig,
|
|
workspace: Arc<Workspace>,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
) -> Self {
|
|
Self {
|
|
config,
|
|
hygiene_config,
|
|
workspace,
|
|
llm,
|
|
safety,
|
|
response_tx: None,
|
|
store: None,
|
|
consecutive_failures: 0,
|
|
}
|
|
}
|
|
|
|
/// Set the response channel for notifications.
|
|
pub fn with_response_channel(mut self, tx: mpsc::Sender<OutgoingResponse>) -> Self {
|
|
self.response_tx = Some(tx);
|
|
self
|
|
}
|
|
|
|
/// Set the database store for persistent heartbeat conversations.
|
|
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
|
self.store = Some(store);
|
|
self
|
|
}
|
|
|
|
/// Run the heartbeat loop.
|
|
///
|
|
/// This runs forever, checking periodically based on the configured interval.
|
|
pub async fn run(&mut self) {
|
|
if !self.config.enabled {
|
|
tracing::info!("Heartbeat is disabled, not starting loop");
|
|
return;
|
|
}
|
|
|
|
tracing::info!(
|
|
"Starting heartbeat loop with interval {:?}",
|
|
self.config.interval
|
|
);
|
|
|
|
let mut interval = tokio::time::interval(self.config.interval);
|
|
// Don't run immediately on startup
|
|
interval.tick().await;
|
|
|
|
loop {
|
|
interval.tick().await;
|
|
|
|
// Run memory hygiene in the background so it never delays the
|
|
// heartbeat checklist. Failures are logged inside run_if_due.
|
|
let hygiene_workspace = Arc::clone(&self.workspace);
|
|
let hygiene_config = self.hygiene_config.clone();
|
|
tokio::spawn(async move {
|
|
let report =
|
|
crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config)
|
|
.await;
|
|
if report.had_work() {
|
|
tracing::info!(
|
|
daily_logs_deleted = report.daily_logs_deleted,
|
|
conversation_docs_deleted = report.conversation_docs_deleted,
|
|
"heartbeat: memory hygiene deleted stale documents"
|
|
);
|
|
}
|
|
});
|
|
|
|
match self.check_heartbeat().await {
|
|
HeartbeatResult::Ok => {
|
|
tracing::debug!("Heartbeat OK");
|
|
self.consecutive_failures = 0;
|
|
}
|
|
HeartbeatResult::NeedsAttention(message) => {
|
|
tracing::info!("Heartbeat needs attention: {}", message);
|
|
self.consecutive_failures = 0;
|
|
self.send_notification(&message).await;
|
|
}
|
|
HeartbeatResult::Skipped => {
|
|
tracing::debug!("Heartbeat skipped");
|
|
}
|
|
HeartbeatResult::Failed(error) => {
|
|
tracing::error!("Heartbeat failed: {}", error);
|
|
self.consecutive_failures += 1;
|
|
|
|
if self.consecutive_failures >= self.config.max_failures {
|
|
tracing::error!(
|
|
"Heartbeat disabled after {} consecutive failures",
|
|
self.consecutive_failures
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run a single heartbeat check.
|
|
pub async fn check_heartbeat(&self) -> HeartbeatResult {
|
|
// Get the heartbeat checklist
|
|
let checklist = match self.workspace.heartbeat_checklist().await {
|
|
Ok(Some(content)) if !is_effectively_empty(&content) => content,
|
|
Ok(_) => return HeartbeatResult::Skipped,
|
|
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
|
|
};
|
|
|
|
// Build the heartbeat prompt
|
|
let prompt = format!(
|
|
"Read the HEARTBEAT.md checklist below and follow it strictly. \
|
|
Do not infer or repeat old tasks. Check each item and report findings.\n\
|
|
\n\
|
|
If nothing needs attention, reply EXACTLY with: HEARTBEAT_OK\n\
|
|
\n\
|
|
If something needs attention, provide a concise summary of what needs action.\n\
|
|
\n\
|
|
## HEARTBEAT.md\n\
|
|
\n\
|
|
{}",
|
|
checklist
|
|
);
|
|
|
|
// Get the system prompt for context
|
|
let system_prompt = match self.workspace.system_prompt().await {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to get system prompt for heartbeat: {}", e);
|
|
String::new()
|
|
}
|
|
};
|
|
|
|
// Run the agent turn
|
|
let messages = if system_prompt.is_empty() {
|
|
vec![ChatMessage::user(&prompt)]
|
|
} else {
|
|
vec![
|
|
ChatMessage::system(&system_prompt),
|
|
ChatMessage::user(&prompt),
|
|
]
|
|
};
|
|
|
|
// Use the model's context_length to set max_tokens. The API returns
|
|
// the total context window; we cap output at half of that (the rest is
|
|
// the prompt) with a floor of 4096.
|
|
let max_tokens = match self.llm.model_metadata().await {
|
|
Ok(meta) => {
|
|
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
|
|
from_api.max(4096)
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Could not fetch model metadata, using default max_tokens: {}",
|
|
e
|
|
);
|
|
4096
|
|
}
|
|
};
|
|
|
|
let request = CompletionRequest::new(messages)
|
|
.with_max_tokens(max_tokens)
|
|
.with_temperature(0.3);
|
|
|
|
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
|
let (content, _usage) = match reasoning.complete(request).await {
|
|
Ok(r) => r,
|
|
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
|
|
};
|
|
|
|
let content = content.trim();
|
|
|
|
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
|
|
// burn all output tokens on chain-of-thought and return content: null.
|
|
if content.is_empty() {
|
|
return HeartbeatResult::Failed("LLM returned empty content.".to_string());
|
|
}
|
|
|
|
// Check if nothing needs attention
|
|
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
|
return HeartbeatResult::Ok;
|
|
}
|
|
|
|
HeartbeatResult::NeedsAttention(content.to_string())
|
|
}
|
|
|
|
/// Send a notification about heartbeat findings.
|
|
async fn send_notification(&self, message: &str) {
|
|
let Some(ref tx) = self.response_tx else {
|
|
tracing::debug!("No response channel configured for heartbeat notifications");
|
|
return;
|
|
};
|
|
|
|
let user_id = self.config.notify_user_id.as_deref().unwrap_or("default");
|
|
|
|
// Persist to heartbeat conversation and get thread_id
|
|
let thread_id = if let Some(ref store) = self.store {
|
|
match store.get_or_create_heartbeat_conversation(user_id).await {
|
|
Ok(conv_id) => {
|
|
if let Err(e) = store
|
|
.add_conversation_message(conv_id, "assistant", message)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to persist heartbeat message: {}", e);
|
|
}
|
|
Some(conv_id.to_string())
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to get heartbeat conversation: {}", e);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let response = OutgoingResponse {
|
|
content: format!("🔔 *Heartbeat Alert*\n\n{}", message),
|
|
thread_id,
|
|
attachments: Vec::new(),
|
|
metadata: serde_json::json!({
|
|
"source": "heartbeat",
|
|
}),
|
|
};
|
|
|
|
if let Err(e) = tx.send(response).await {
|
|
tracing::error!("Failed to send heartbeat notification: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if heartbeat content is effectively empty.
|
|
///
|
|
/// Returns true if the content contains only:
|
|
/// - Whitespace
|
|
/// - Markdown headers (lines starting with #)
|
|
/// - HTML comments (`<!-- ... -->`)
|
|
/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`)
|
|
///
|
|
/// This skips the LLM call when the user hasn't added real tasks yet,
|
|
/// saving API costs.
|
|
fn is_effectively_empty(content: &str) -> bool {
|
|
let without_comments = strip_html_comments(content);
|
|
|
|
without_comments.lines().all(|line| {
|
|
let trimmed = line.trim();
|
|
trimmed.is_empty()
|
|
|| trimmed.starts_with('#')
|
|
|| trimmed == "- [ ]"
|
|
|| trimmed == "- [x]"
|
|
|| trimmed == "-"
|
|
|| trimmed == "*"
|
|
})
|
|
}
|
|
|
|
/// Remove HTML comments from content.
|
|
fn strip_html_comments(content: &str) -> String {
|
|
let mut result = String::with_capacity(content.len());
|
|
let mut rest = content;
|
|
while let Some(start) = rest.find("<!--") {
|
|
result.push_str(&rest[..start]);
|
|
match rest[start..].find("-->") {
|
|
Some(end) => rest = &rest[start + end + 3..],
|
|
None => return result, // unclosed comment, treat rest as comment
|
|
}
|
|
}
|
|
result.push_str(rest);
|
|
result
|
|
}
|
|
|
|
/// Spawn the heartbeat runner as a background task.
|
|
///
|
|
/// Returns a handle that can be used to stop the runner.
|
|
pub fn spawn_heartbeat(
|
|
config: HeartbeatConfig,
|
|
hygiene_config: HygieneConfig,
|
|
workspace: Arc<Workspace>,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
|
store: Option<Arc<dyn Database>>,
|
|
) -> tokio::task::JoinHandle<()> {
|
|
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety);
|
|
if let Some(tx) = response_tx {
|
|
runner = runner.with_response_channel(tx);
|
|
}
|
|
if let Some(s) = store {
|
|
runner = runner.with_store(s);
|
|
}
|
|
|
|
tokio::spawn(async move {
|
|
runner.run().await;
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_heartbeat_config_defaults() {
|
|
let config = HeartbeatConfig::default();
|
|
assert!(config.enabled);
|
|
assert_eq!(config.interval, Duration::from_secs(30 * 60));
|
|
assert_eq!(config.max_failures, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_config_builders() {
|
|
let config = HeartbeatConfig::default()
|
|
.with_interval(Duration::from_secs(60))
|
|
.with_notify("user1", "telegram");
|
|
|
|
assert_eq!(config.interval, Duration::from_secs(60));
|
|
assert_eq!(config.notify_user_id, Some("user1".to_string()));
|
|
assert_eq!(config.notify_channel, Some("telegram".to_string()));
|
|
|
|
let disabled = HeartbeatConfig::default().disabled();
|
|
assert!(!disabled.enabled);
|
|
}
|
|
|
|
// ==================== strip_html_comments ====================
|
|
|
|
#[test]
|
|
fn test_strip_html_comments_no_comments() {
|
|
assert_eq!(strip_html_comments("hello world"), "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_html_comments_single() {
|
|
assert_eq!(
|
|
strip_html_comments("before<!-- gone -->after"),
|
|
"beforeafter"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_html_comments_multiple() {
|
|
let input = "a<!-- 1 -->b<!-- 2 -->c";
|
|
assert_eq!(strip_html_comments(input), "abc");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_html_comments_multiline() {
|
|
let input = "# Title\n<!-- multi\nline\ncomment -->\nreal content";
|
|
assert_eq!(strip_html_comments(input), "# Title\n\nreal content");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_html_comments_unclosed() {
|
|
let input = "before<!-- never closed";
|
|
assert_eq!(strip_html_comments(input), "before");
|
|
}
|
|
|
|
// ==================== is_effectively_empty ====================
|
|
|
|
#[test]
|
|
fn test_effectively_empty_empty_string() {
|
|
assert!(is_effectively_empty(""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_whitespace() {
|
|
assert!(is_effectively_empty(" \n\n \n "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_headers_only() {
|
|
assert!(is_effectively_empty("# Title\n## Subtitle\n### Section"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_html_comments_only() {
|
|
assert!(is_effectively_empty("<!-- this is a comment -->"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_empty_checkboxes() {
|
|
assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_bare_list_markers() {
|
|
assert!(is_effectively_empty("-\n*\n-"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_seeded_template() {
|
|
let template = "\
|
|
# Heartbeat Checklist
|
|
|
|
<!-- Keep this file empty to skip heartbeat API calls.
|
|
Add tasks below when you want the agent to check something periodically.
|
|
|
|
Example:
|
|
- [ ] Check for unread emails needing a reply
|
|
- [ ] Review today's calendar for upcoming meetings
|
|
- [ ] Check CI build status for main branch
|
|
-->";
|
|
assert!(is_effectively_empty(template));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_real_checklist() {
|
|
let content = "\
|
|
# Heartbeat Checklist
|
|
|
|
- [ ] Check for unread emails needing a reply
|
|
- [ ] Review today's calendar for upcoming meetings";
|
|
assert!(!is_effectively_empty(content));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_mixed_real_and_headers() {
|
|
let content = "# Title\n\nDo something important";
|
|
assert!(!is_effectively_empty(content));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectively_empty_comment_plus_real_content() {
|
|
let content = "<!-- comment -->\nActual task here";
|
|
assert!(!is_effectively_empty(content));
|
|
}
|
|
|
|
#[test]
|
|
fn test_spawn_heartbeat_accepts_store_param() {
|
|
// Regression: spawn_heartbeat must accept an optional Database store
|
|
// for persisting heartbeat notifications to a dedicated conversation.
|
|
// Compile-time check: the 7th parameter is `Option<Arc<dyn Database>>`.
|
|
#[allow(clippy::type_complexity)]
|
|
let _fn_ptr: fn(
|
|
HeartbeatConfig,
|
|
HygieneConfig,
|
|
Arc<crate::workspace::Workspace>,
|
|
Arc<dyn crate::llm::LlmProvider>,
|
|
Arc<crate::safety::SafetyLayer>,
|
|
Option<tokio::sync::mpsc::Sender<crate::channels::OutgoingResponse>>,
|
|
Option<Arc<dyn crate::db::Database>>,
|
|
) -> tokio::task::JoinHandle<()> = spawn_heartbeat;
|
|
let _ = _fn_ptr;
|
|
}
|
|
}
|