use crate::brain::agent::PushOrigin;
use crate::brain::agent::service::MessageEnqueueCallback;
use crate::brain::agent::service::QueuedUserMessage;
use crate::brain::agent::service::background_tasks::{
BackgroundTaskManager, ClaudeTurnTasks, CmdResult, claude_completion_message,
claude_needs_survival_delivery, claude_task_label, clear_claude_mirrors_for_session,
completion_message, format_elapsed, run_detached, short_label, tail_lines,
};
use crate::brain::agent::service::restart_recovery;
use crate::brain::agent::service::session_routes;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
#[test]
fn tail_keeps_last_n_lines() {
let text = (1..=100)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let tail = tail_lines(&text, 3);
assert_eq!(tail, "98\n99\n100");
assert_eq!(tail_lines("a\nb", 10), "a\nb");
}
#[test]
fn completion_message_reflects_success_and_failure() {
let ok = completion_message(
"cargo test",
"cargo test --all-features",
&CmdResult {
success: true,
code: 0,
output: "test result: ok. 5 passed".into(),
},
12.0,
);
assert!(ok.context_text.contains("exit 0 (success)"));
assert!(ok.context_text.contains("cargo test --all-features"));
assert!(ok.context_text.contains("Do not re-run"));
assert!(ok.display_text.contains("finished"));
let meta = ok.bg_meta.expect("bg completion carries BgTaskMeta");
assert!(meta.success);
assert_eq!(meta.label, "cargo test");
assert_eq!(meta.elapsed_secs, 12.0);
assert_eq!(meta.tail, "test result: ok. 5 passed");
let fail = completion_message(
"build",
"cargo build",
&CmdResult {
success: false,
code: 101,
output: "error[E0001]".into(),
},
3.0,
);
assert!(fail.context_text.contains("exit 101 (failure)"));
assert!(fail.display_text.contains("failed"));
let meta = fail.bg_meta.expect("failed completion still carries meta");
assert!(!meta.success);
assert_eq!(meta.elapsed_secs, 3.0);
}
#[test]
fn format_elapsed_buckets_match_the_spec() {
assert_eq!(format_elapsed(0.4), "0s");
assert_eq!(format_elapsed(42.0), "42s");
assert_eq!(format_elapsed(59.6), "1m 0s"); assert_eq!(format_elapsed(185.0), "3m 5s");
assert_eq!(format_elapsed(3599.4), "59m 59s");
assert_eq!(format_elapsed(3720.0), "1h 2m");
}
#[test]
fn short_label_takes_the_command_after_cd() {
assert_eq!(short_label("cd ~/proj && cargo test"), "cargo test");
assert_eq!(short_label("cargo build"), "cargo build");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn spawn_command_enqueues_on_completion() {
let _guard = restart_recovery::test_guard();
#[allow(clippy::type_complexity)]
let recorded: Arc<Mutex<Vec<(Uuid, QueuedUserMessage)>>> = Arc::new(Mutex::new(Vec::new()));
let rec = recorded.clone();
let enqueue: MessageEnqueueCallback = Arc::new(move |sid, msg| {
rec.lock().unwrap().push((sid, msg));
});
let mgr = Arc::new(BackgroundTaskManager::new());
let sid = Uuid::new_v4();
session_routes::register_session_route(sid, enqueue);
let cwd = std::env::temp_dir();
mgr.clone().spawn_command(
sid,
cwd,
"echo probe".to_string(),
"echo BG_DONE_MARKER".to_string(),
);
let mut waited = 0;
while recorded.lock().unwrap().is_empty() && waited < 50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
waited += 1;
}
let r = recorded.lock().unwrap();
assert_eq!(r.len(), 1, "completion should have enqueued exactly once");
assert_eq!(r[0].0, sid);
assert!(r[0].1.context_text.contains("BG_DONE_MARKER"));
assert!(r[0].1.context_text.contains("exit 0 (success)"));
assert_eq!(mgr.running_for(sid), 0);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn spawn_command_with_hook_replaces_generic_delivery() {
let _guard = restart_recovery::test_guard();
#[allow(clippy::type_complexity)]
let recorded: Arc<Mutex<Vec<(Uuid, QueuedUserMessage)>>> = Arc::new(Mutex::new(Vec::new()));
let rec = recorded.clone();
let enqueue: MessageEnqueueCallback = Arc::new(move |sid, msg| {
rec.lock().unwrap().push((sid, msg));
});
let mgr = Arc::new(BackgroundTaskManager::new());
let sid = Uuid::new_v4();
session_routes::register_session_route(sid, enqueue);
let hooked: Arc<Mutex<Option<(bool, bool)>>> = Arc::new(Mutex::new(None));
let hk = hooked.clone();
let hook: crate::brain::agent::service::background_tasks::CompletionHook = Box::new(
move |ctx: crate::brain::agent::service::background_tasks::HookContext| {
Box::pin(async move {
*hk.lock().unwrap() = Some((ctx.result.success, ctx.elapsed_secs >= 0.0));
})
},
);
mgr.clone().spawn_command_with_hook(
sid,
std::env::temp_dir(),
"hook probe".to_string(),
"echo HOOK_DONE_MARKER".to_string(),
hook,
);
let mut waited = 0;
while hooked.lock().unwrap().is_none() && waited < 50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
waited += 1;
}
let h = hooked.lock().unwrap();
let (success, elapsed_ok) = h.expect("hook must fire on completion");
assert!(success, "echo must report success to the hook");
assert!(elapsed_ok, "hook carries the command runtime");
assert!(
recorded.lock().unwrap().is_empty(),
"a hook replaces generic session delivery"
);
assert_eq!(mgr.running_for(sid), 0);
}
#[test]
fn claude_task_label_carries_source_tag() {
assert_eq!(claude_task_label("btglunt18"), "claude-cli btglunt18");
}
#[test]
fn mirror_registers_then_removes() {
let mgr = Arc::new(BackgroundTaskManager::new());
let sid = Uuid::new_v4();
let label = claude_task_label("btglunt18");
assert_eq!(mgr.running_for(sid), 0);
mgr.mirror_started(sid, &label);
assert_eq!(mgr.running_for(sid), 1);
let rows = mgr.running_tasks(sid);
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].label, "claude-cli btglunt18",
"row carries the source tag"
);
mgr.mirror_finished(sid, &label);
assert_eq!(mgr.running_for(sid), 0);
assert!(mgr.running_tasks(sid).is_empty());
}
#[test]
fn mirror_finish_is_noop_for_unknown_task() {
let mgr = Arc::new(BackgroundTaskManager::new());
let sid = Uuid::new_v4();
mgr.mirror_finished(sid, &claude_task_label("ghost01"));
assert_eq!(mgr.running_for(sid), 0);
}
#[test]
fn mirror_finish_removes_oldest_on_duplicate_labels() {
let mgr = Arc::new(BackgroundTaskManager::new());
let sid = Uuid::new_v4();
let label = claude_task_label("btglunt18");
mgr.mirror_started(sid, &label);
std::thread::sleep(std::time::Duration::from_millis(5));
mgr.mirror_started(sid, &label);
assert_eq!(mgr.running_for(sid), 2);
mgr.mirror_finished(sid, &label);
assert_eq!(mgr.running_for(sid), 1, "oldest row removed, survivor kept");
mgr.mirror_finished(sid, &label);
assert_eq!(mgr.running_for(sid), 0, "second finish clears the survivor");
}
#[test]
fn survivor_delivery_fires_only_for_tasks_not_started_this_turn() {
let sid = Uuid::new_v4();
let other = Uuid::new_v4();
let mut turn_started = ClaudeTurnTasks::default();
assert!(claude_needs_survival_delivery(
&turn_started,
sid,
"bAAA1111"
));
turn_started.insert((sid, "bAAA1111".to_string()));
assert!(!claude_needs_survival_delivery(
&turn_started,
sid,
"bAAA1111"
));
turn_started.insert((other, "bAAA1111".to_string()));
turn_started.insert((sid, "bBBB2222".to_string()));
assert!(claude_needs_survival_delivery(
&turn_started,
other,
"bCCC3333"
));
assert!(claude_needs_survival_delivery(
&turn_started,
sid,
"bDDD4444"
));
}
#[test]
fn claude_completion_message_carries_status_and_summary_mechanically() {
let msg = claude_completion_message("bAAA1111", "completed", Some("tests passed"));
assert!(
msg.context_text.contains("claude-cli bAAA1111"),
"label in context"
);
assert!(msg.context_text.contains("Status: completed"));
assert!(msg.context_text.contains("Summary: tests passed"));
assert!(msg.context_text.contains("Do not re-run the task"));
assert_eq!(
msg.display_text,
"🔧 background task finished: claude-cli bAAA1111"
);
assert_eq!(msg.origin, PushOrigin::BackgroundTask, "#1221 echo tag");
assert!(msg.bg_meta.is_none(), "no CmdResult, no fabricated receipt");
}
#[test]
fn claude_completion_message_failed_wording_and_summary_fallback() {
let msg = claude_completion_message("bBBB2222", "failed", None);
assert!(msg.context_text.contains("Status: failed"));
assert!(msg.context_text.contains("Summary: (no summary provided)"));
assert_eq!(
msg.display_text,
"🔧 background task failed: claude-cli bBBB2222"
);
}
#[test]
fn task_notification_clears_the_row_for_any_status() {
let src = include_str!("../brain/agent/service/helpers.rs");
assert!(
src.contains("(\"task_notification\", st)"),
"any task_notification must clear the mirror row, not only the two \
statuses the old arm enumerated"
);
assert!(
!src.contains("(\"task_notification\", Some(s @ (\"completed\" | \"failed\")))"),
"the status-filtered clear is the #1984 stuck-row bug, restored"
);
assert!(
src.contains("matches!(st, Some(\"completed\") | Some(\"failed\"))"),
"the synthetic survival wake must stay limited to result statuses"
);
}
#[test]
fn process_exit_clears_the_sessions_mirrors_and_markers_only() {
let mgr = BackgroundTaskManager::new();
let sid_a = Uuid::new_v4();
let sid_b = Uuid::new_v4();
let mut tasks: ClaudeTurnTasks = ClaudeTurnTasks::new();
for (sid, id) in [(sid_a, "a1"), (sid_a, "a2"), (sid_b, "b1")] {
tasks.insert((sid, id.to_string()));
mgr.mirror_started(sid, &claude_task_label(id));
}
assert_eq!(mgr.running_for(sid_a), 2);
clear_claude_mirrors_for_session(&mut tasks, &mgr, sid_a);
assert_eq!(
mgr.running_for(sid_a),
0,
"a session's mirrors must not outlive its process"
);
assert_eq!(mgr.running_for(sid_b), 1, "other sessions are untouched");
assert!(
!tasks.iter().any(|(s, _)| *s == sid_a),
"A's markers go with its rows"
);
assert_eq!(tasks.len(), 1, "B's marker stays");
}
#[cfg(unix)]
#[tokio::test]
async fn detached_run_does_not_wait_on_survivor_output() {
let sid = Uuid::new_v4();
let started = std::time::Instant::now();
let result = run_detached(
"sleep 10 & echo done",
&std::env::temp_dir(),
sid,
std::time::Duration::from_secs(30),
)
.await;
let elapsed = started.elapsed();
assert!(result.success, "the shell exited 0: {}", result.output);
assert!(
result.output.contains("done"),
"output must be captured: {:?}",
result.output
);
assert!(
elapsed < std::time::Duration::from_secs(3),
"the shell exited at once; waiting {elapsed:?} means the wait is on \
the survivor's inherited output, not on the shell (#1984)"
);
}
#[cfg(unix)]
#[tokio::test]
async fn detached_run_stops_waiting_at_its_ceiling() {
let sid = Uuid::new_v4();
let started = std::time::Instant::now();
let result = run_detached(
"echo starting; sleep 30",
&std::env::temp_dir(),
sid,
std::time::Duration::from_millis(300),
)
.await;
let elapsed = started.elapsed();
assert!(!result.success, "a ceiling cut is not a success");
assert!(
result.output.contains("ceiling"),
"the receipt must say why: {:?}",
result.output
);
assert!(
result.output.contains("starting"),
"what ran before the cut is still reported"
);
assert!(
elapsed < std::time::Duration::from_secs(3),
"the ceiling must end the wait, not sleep 30 ({elapsed:?})"
);
}