use synaps_cli::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
use agent_engine::session::{Envelope, RuntimeView, SessionEventWire, TurnTrigger};
use super::app::{App, ChatMessage, SubagentState, THINKING_PLACEHOLDER};
use super::draw::build_render_model;
use super::render_thread::RenderHandle;
use super::session_link::{PromptBridge, SessionLink};
use super::view_model::ViewInputs;
pub(super) fn needs_immediate_draw(event: &StreamEvent) -> bool {
matches!(
event,
StreamEvent::Llm(LlmEvent::ToolUse { .. })
| StreamEvent::Llm(LlmEvent::ToolResult { .. })
| StreamEvent::Agent(AgentEvent::SubagentStart { .. })
| StreamEvent::Agent(AgentEvent::SubagentUpdate { .. })
| StreamEvent::Agent(AgentEvent::SubagentDone { .. })
| StreamEvent::Agent(AgentEvent::SteeringDelivered { .. })
| StreamEvent::Session(SessionEvent::Done)
| StreamEvent::Session(SessionEvent::Error(_))
)
}
pub(super) fn handle_stream_event(event: StreamEvent, app: &mut App, view: &RuntimeView) {
match event {
StreamEvent::Llm(LlmEvent::Thinking(text)) => {
app.append_or_update_thinking(&text);
}
StreamEvent::Llm(LlmEvent::Text(text)) => {
app.append_or_update_text(&text);
}
StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
app.on_tool_use_start(tool_id, tool_name);
}
StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
app.on_tool_use_delta(&tool_id, &delta);
}
StreamEvent::Llm(LlmEvent::ToolUse {
tool_name,
tool_id,
input,
}) => {
let input_str = serde_json::to_string(&input).unwrap_or_default();
app.on_tool_use_finalized(tool_id, tool_name, input_str);
}
StreamEvent::Llm(LlmEvent::ToolResultDelta { delta, tool_id }) => {
app.on_tool_result_delta(tool_id, delta);
}
StreamEvent::Llm(LlmEvent::ToolResult { result, tool_id }) => {
app.on_tool_result(tool_id, result);
}
StreamEvent::Session(SessionEvent::MessageHistory(_)) => {}
StreamEvent::Agent(AgentEvent::SubagentStart {
subagent_id,
agent_name,
task_preview,
}) => {
app.subagents.push(SubagentState {
id: subagent_id,
name: agent_name,
status: format!("starting: {}", task_preview),
start_time: app.clock.now(),
done: false,
duration_secs: None,
done_at: None,
tools: 0,
result: None,
});
app.invalidate();
}
StreamEvent::Agent(AgentEvent::SubagentUpdate {
subagent_id,
status,
..
}) => {
if let Some(sa) = app.subagents.iter_mut().find(|s| s.id == subagent_id) {
if let Some((_, n)) = status
.strip_prefix("\u{2699} ")
.and_then(|rest| rest.rsplit_once(" (tool #"))
{
sa.tools = n.trim_end_matches(')').parse().unwrap_or(sa.tools + 1);
}
sa.status = status;
}
app.invalidate();
}
StreamEvent::Agent(AgentEvent::SubagentDone {
subagent_id,
result_preview,
duration_secs,
..
}) => {
if let Some(sa) = app.subagents.iter_mut().find(|s| s.id == subagent_id) {
sa.done = true;
sa.duration_secs = Some(duration_secs);
sa.done_at = Some(std::time::Instant::now());
let preview: String = result_preview.chars().take(40).collect();
if result_preview.starts_with("[TIMED OUT") {
sa.status = "\u{26a0} timed out".to_string();
} else if result_preview.starts_with("ERROR") {
sa.status = format!("\u{2718} {}", preview);
} else {
sa.status = format!("\u{2714} {}", preview);
}
let first = result_preview
.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#'))
.unwrap_or("");
let first = first.strip_prefix("ERROR: ").unwrap_or(first);
sa.result = Some(first.chars().take(120).collect());
}
app.invalidate();
}
StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => {
if !super::helpers::is_event_payload(&message) {
app.push_msg(ChatMessage::User(message.clone()));
}
if app.queued_message.as_ref() == Some(&message) {
app.queued_message = None;
}
app.transcript.scroll_to_bottom();
app.invalidate();
}
StreamEvent::Session(SessionEvent::Usage {
input_tokens,
output_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
cache_creation_5m,
cache_creation_1h,
model: usage_model,
}) => {
let model_for_pricing = usage_model.as_deref().unwrap_or(&view.model);
app.add_usage(
input_tokens,
output_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
cache_creation_5m,
cache_creation_1h,
model_for_pricing,
Some(view.context_window),
);
}
StreamEvent::Session(SessionEvent::Notice(text)) => {
app.push_msg(ChatMessage::System(sanitize_notice(&text)));
}
StreamEvent::Session(SessionEvent::Done) => {
app.transcript.end_response();
app.streaming = false;
app.quit_guard.reset();
app.drop_empty_thinking();
reconcile_subagents(
&mut app.subagents,
&app.subagent_rows,
std::time::Instant::now(),
);
}
StreamEvent::Session(SessionEvent::Error(err)) => {
app.transcript.end_response();
app.drop_empty_thinking();
app.push_msg(ChatMessage::Error(format!(
"{} [{}]",
sanitize_notice(&err.message),
err.category_label()
)));
app.streaming = false;
app.quit_guard.reset();
reconcile_subagents(
&mut app.subagents,
&app.subagent_rows,
std::time::Instant::now(),
);
}
StreamEvent::Llm(LlmEvent::ResponseStart) => {
app.drop_empty_thinking();
app.response_preview = Some((
app.transcript.messages().len(),
app.transcript.messages().last().map(|m| m.msg.clone()),
));
app.transcript.begin_response();
}
StreamEvent::Llm(LlmEvent::ResponseReset) => {
if let Some((start, last)) = app.response_preview.take() {
let start = app.transcript.response_start().unwrap_or(start);
app.transcript.reset_response_preview(start, last);
app.invalidate();
}
}
StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. }) => {}
}
}
pub(super) enum ArmFlow {
Continue,
Ended,
}
fn publish_frame(
app: &mut App,
view: &RuntimeView,
registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
render_handle: &RenderHandle,
) {
let term_size = crossterm::terminal::size()
.map(|(w, h)| ratatui::layout::Size {
width: w,
height: h,
})
.unwrap_or_default();
let built = build_render_model(&mut ViewInputs::from_app(app), view, registry, term_size);
if let Some((model, patch)) = built {
patch.apply(app);
render_handle.publish(model);
}
}
pub(super) fn on_external(app: &mut App, event: &synaps_cli::events::types::Event) {
let severity_str = event
.content
.severity
.as_ref()
.map(|s| s.as_str().to_string())
.unwrap_or_else(|| "medium".to_string());
app.push_msg(ChatMessage::Event {
source: event.source.source_type.clone(),
severity: severity_str,
text: event.content.text.clone(),
});
if event.content.content_type == "subagent_completion" {
if let Some(data) = &event.content.data {
let maybe_id = data["subagent_id"].as_u64();
let maybe_status = data["status"].as_str();
let maybe_duration = data["duration_secs"].as_f64();
if let (Some(sid), Some(status_str)) = (maybe_id, maybe_status) {
let now = std::time::Instant::now();
if let Some(sa) = app.subagents.iter_mut().find(|s| s.id == sid && !s.done) {
sa.done = true;
sa.done_at = Some(now);
if let Some(dur) = maybe_duration {
sa.duration_secs = Some(dur);
}
sa.status = match status_str {
"completed" => "\u{2714} done".to_string(),
"cancelled" => "\u{26a0} cancelled".to_string(),
"timed_out" => "\u{26a0} timed out".to_string(),
s if s.starts_with("fail") => {
let reason = data["error"].as_str().unwrap_or("error");
let preview: String = reason.chars().take(30).collect();
format!("\u{2718} {}", preview)
}
_ => format!("\u{2714} {status_str}"),
};
}
}
}
}
app.invalidate();
}
pub(super) fn on_auto_turn_cap(app: &mut App, cap: u32) {
app.push_msg(ChatMessage::System(format!(
"auto-turn cap reached ({} consecutive) — waiting for your input",
cap
)));
app.invalidate();
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_session_event_arm(
env: Envelope,
app: &mut App,
link: &mut SessionLink,
registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
render_handle: &RenderHandle,
prompt_bridge: &mut PromptBridge,
ext_mgr: Option<
&std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
>,
) -> ArmFlow {
let me = link.client_id();
match env.event {
SessionEventWire::Stream(ev) => {
let do_draw = needs_immediate_draw(&ev);
let view = std::sync::Arc::clone(link.view());
handle_stream_event(ev, app, &view);
if !app.streaming {
if let Some(msg) = app.reclaim_gamba() {
render_handle.resume();
app.push_msg(ChatMessage::System(msg));
app.invalidate();
}
}
if do_draw {
publish_frame(app, &view, registry, render_handle);
}
}
SessionEventWire::TurnStarted {
turn_baseline,
trigger,
user_text,
} => {
app.transcript.seal_text_block();
match trigger {
TurnTrigger::User | TurnTrigger::PluginCommand => {
let mine = app.last_submitted.take();
if let Some(text) =
user_text.filter(|t| !t.is_empty() && mine.as_deref() != Some(t.as_str()))
{
app.push_msg(ChatMessage::User(text));
app.transcript.scroll_to_bottom();
}
app.pending_attachments.clear();
app.streaming = true;
app.turn_baseline = turn_baseline;
app.status_text = None;
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
}
TurnTrigger::QueuedAuto => {
if let Some(msg) = app.reclaim_gamba() {
render_handle.resume();
app.push_msg(ChatMessage::System(msg));
app.invalidate();
}
app.consecutive_auto_turns = 0;
if let Some(q) = user_text {
app.push_msg(ChatMessage::User(q));
}
app.transcript.scroll_to_bottom();
app.status_text = Some("connecting…".to_string());
app.streaming = true;
app.turn_baseline = turn_baseline;
app.spinner_frame = 0;
let view = std::sync::Arc::clone(link.view());
publish_frame(app, &view, registry, render_handle);
app.status_text = None;
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
}
TurnTrigger::EventAuto | TurnTrigger::Compaction | TurnTrigger::DriverAuto => {
app.streaming = true;
app.turn_baseline = turn_baseline;
app.spinner_frame = 0;
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
}
}
}
SessionEventWire::Conversation(snap) => {
app.apply_conversation(&snap);
if let Some(applied) = app.compaction_applied.take() {
super::helpers::rebuild_display_from_conversation(&snap, app, link).await;
finish_compaction(app, applied);
}
if let Some(pending) = app.resume_pending.take() {
super::helpers::rebuild_display_from_conversation(&snap, app, link).await;
if let Some(notice) = pending.clamp_notice {
app.push_msg(ChatMessage::System(notice));
}
let via = pending
.via
.map(|v| format!(" (via {v})"))
.unwrap_or_default();
app.push_msg(ChatMessage::System(format!(
"switched from {} to {}{}",
pending.old_id, pending.new_id, via
)));
}
}
SessionEventWire::Prompt(req) => prompt_bridge.on_prompt(req),
SessionEventWire::PromptResolved { prompt_id } => {
if prompt_bridge.on_resolved(prompt_id) {
app.secret_prompts.dismiss();
app.invalidate();
}
}
SessionEventWire::External(ev) => on_external(app, &ev),
SessionEventWire::AutoTurnCapReached { cap } => on_auto_turn_cap(app, cap),
SessionEventWire::Idle => {
if app.status_text.as_deref() == Some(ABORTING_STATUS) {
app.status_text = None;
}
}
SessionEventWire::Steered { text, delivered } => {
if delivered {
app.push_msg(ChatMessage::System(format!("→ steering: {}", text)));
} else {
app.push_msg(ChatMessage::System(format!("queued: {}", text)));
}
app.queued_message = Some(text);
}
SessionEventWire::Dequeued { text } => {
app.push_msg(ChatMessage::System(format!("dequeued: {}", text)));
}
SessionEventWire::SystemNotice(text) => {
app.push_msg(ChatMessage::System(sanitize_notice(&text)));
}
SessionEventWire::LoaderProgress(ev) => {
super::loop_arms::handle_extension_loader_event(app, link.view(), ev, ext_mgr).await;
app.request_redraw();
}
SessionEventWire::ExtensionNotification {
extension_id,
method,
params,
} => {
if ext_mgr.is_none() && synaps_cli::extensions::widgets::is_widget_method(&method) {
if let Ok(event) =
synaps_cli::extensions::widgets::parse_widget_event(&method, ¶ms)
{
let ev = synaps_cli::extensions::widgets::ExtensionWidgetEvent {
extension_id,
event,
};
if super::loop_arms::handle_widget_event(app, ev) {
app.request_redraw();
}
}
}
}
SessionEventWire::SettingChanged(_) | SessionEventWire::QueryResult { .. } => {
}
SessionEventWire::Attached { .. }
| SessionEventWire::ClientJoined { .. }
| SessionEventWire::ClientLeft { .. } => {}
SessionEventWire::Ended { .. } => return ArmFlow::Ended,
SessionEventWire::Aborted { context_saved } => {
app.transcript.end_response();
app.streaming = false;
app.quit_guard.reset();
app.subagents.clear();
if app.status_text.as_deref() == Some(ABORTING_STATUS) {
app.status_text = None;
}
let abort_msg = if context_saved {
"aborted — partial work kept"
} else {
"aborted"
};
app.drop_empty_thinking();
app.push_msg(ChatMessage::Error(abort_msg.to_string()));
}
SessionEventWire::Cleared { .. } => {
app.transcript.clear();
app.invalidate();
app.api_messages_len = 0;
app.total_input_tokens = 0;
app.total_output_tokens = 0;
app.total_cache_read_tokens = 0;
app.total_cache_creation_tokens = 0;
app.session_cost = 0.0;
app.keepwarm_cost = 0.0;
app.keepwarm_tokens = Default::default();
app.input_tokens = 0;
app.output_tokens = 0;
app.push_msg(ChatMessage::System("new session started".to_string()));
}
SessionEventWire::CompactionStarted { disclosure, .. } => {
app.push_msg(ChatMessage::System(disclosure));
app.push_msg(ChatMessage::System(
"compacting conversation...".to_string(),
));
app.status_text = Some("compacting…".to_string());
app.spinner_frame = 0;
app.compacting = true;
}
SessionEventWire::CompactionApplied {
previous_session_id,
session_id,
chains_advanced,
queued_restored,
msg_count,
} => {
app.compaction_applied = Some(super::app::CompactionApplied {
previous_session_id,
session_id,
chains_advanced,
queued_restored,
msg_count,
});
}
SessionEventWire::CompactionFailed { message, panicked } => {
if panicked {
app.push_msg(ChatMessage::Error(format!(
"compaction task panicked: {}",
message
)));
} else {
app.push_msg(ChatMessage::Error(format!("compaction failed: {}", message)));
}
app.status_text = None;
app.compacting = false;
app.invalidate();
}
SessionEventWire::CompactionCancelled => {
app.push_msg(ChatMessage::System("compaction cancelled".to_string()));
app.status_text = None;
app.compacting = false;
app.invalidate();
}
SessionEventWire::SubagentRows(rows) => {
if apply_subagent_progress(&mut app.subagents, &rows) {
app.invalidate();
}
app.subagent_rows = rows;
}
SessionEventWire::Resumed { .. } => {}
SessionEventWire::InputOwnerChanged { from, to, .. } => {
if from == Some(me) && to != Some(me) {
let who = to
.map(|c| format!("client #{}", c.0))
.unwrap_or_else(|| "nobody".to_string());
app.toasts.upsert(
super::toast::Toast::new("input-owner", format!("input taken over by {who}"))
.titled("Session"),
);
} else if to == Some(me) && from != Some(me) {
app.toasts.upsert(
super::toast::Toast::new("input-owner", "you own input").titled("Session"),
);
}
app.request_redraw();
}
SessionEventWire::Refused {
client,
command,
reason,
} => {
if client == me {
app.push_msg(ChatMessage::Error(format!("{command} refused: {reason}")));
if app.streaming && app.turn_baseline == app.api_messages_len {
app.streaming = false;
app.quit_guard.reset();
app.status_text = None;
app.drop_empty_thinking();
}
if let Some(text) = app.last_submitted.take() {
app.set_input_text(&text);
}
}
}
SessionEventWire::AttachRefused { message } => {
app.push_msg(ChatMessage::Error(format!("attach refused: {message}")));
}
SessionEventWire::Lifecycle(state) => {
app.toasts.upsert(
super::toast::Toast::new("lifecycle", format!("session {state:?}"))
.titled("Session"),
);
app.request_redraw();
}
SessionEventWire::Reloading { generation, .. } => {
app.toasts.upsert(
super::toast::Toast::new(
"reload",
format!("daemon reloading (generation {generation})…"),
)
.titled("Daemon")
.ttl(None),
);
app.request_redraw();
}
SessionEventWire::DriverArmed {
plugin_id,
run_id,
models,
selection,
deadline_ms,
notice,
} => {
super::driver_client::on_armed(
app, plugin_id, run_id, models, selection, deadline_ms, notice,
);
}
SessionEventWire::DriverRevoked {
reason,
undelivered_steering,
} => {
super::driver_client::on_revoked(app, reason, undelivered_steering);
}
SessionEventWire::DriverTurnOutcome {
outcome,
selection,
feedback,
} => {
super::driver_client::on_turn_outcome(app, outcome, selection, feedback);
}
SessionEventWire::CostCapReached { scope, cost, cap } => {
app.push_msg(ChatMessage::Error(format!(
"{scope} cost cap reached (${cost:.4} ≥ ${cap:.4}) — turn cancelled, driver revoked"
)));
app.request_redraw();
}
}
ArmFlow::Continue
}
fn finish_compaction(app: &mut App, applied: super::app::CompactionApplied) {
let old_id = applied.previous_session_id;
for name in &applied.chains_advanced {
app.push_msg(ChatMessage::System(format!(
"chain '{}' advanced: {} → {}",
name, old_id, app.session.id
)));
}
if let Some(q) = applied.queued_restored {
app.push_msg(ChatMessage::System(format!(
"queued message restored: {}",
q
)));
}
app.push_msg(ChatMessage::System(format!(
"✓ compacted {} messages → new session {} (from {})",
applied.msg_count, app.session.id, old_id
)));
app.status_text = None;
app.compacting = false;
app.invalidate();
}
fn sanitize_notice(text: &str) -> String {
text.chars()
.filter(|c| !c.is_ascii_control() || *c == '\n' || *c == '\t')
.collect()
}
pub(super) fn apply_subagent_progress(
hud: &mut [SubagentState],
rows: &[synaps_cli::tools::SubagentDisplayRow],
) -> bool {
use synaps_cli::runtime::subagent::SubagentStatus;
let mut changed = false;
for row in rows {
if !matches!(row.status, SubagentStatus::Running) || row.cancel_requested {
continue;
}
let Some(sa) = hud.iter_mut().find(|s| s.id == row.subagent_id && !s.done) else {
continue;
};
if !row.step.is_empty() && sa.status != row.step {
sa.status = row.step.clone();
changed = true;
}
if sa.tools != row.tools {
sa.tools = row.tools;
changed = true;
}
}
changed
}
pub(super) const SUBAGENT_DONE_FLASH_SECS: f64 = 5.0;
pub(super) const ABORTING_STATUS: &str = "aborting…";
pub(super) fn reconcile_subagents(
hud: &mut Vec<SubagentState>,
rows: &[synaps_cli::tools::SubagentDisplayRow],
now: std::time::Instant,
) {
use synaps_cli::runtime::subagent::SubagentStatus;
let row_map: std::collections::HashMap<u64, &synaps_cli::tools::SubagentDisplayRow> =
rows.iter().map(|r| (r.subagent_id, r)).collect();
hud.retain_mut(|sa| {
match row_map.get(&sa.id) {
Some(row) => {
if sa.done {
if let Some(done_at) = sa.done_at {
if now.duration_since(done_at).as_secs_f64() > SUBAGENT_DONE_FLASH_SECS {
return false; }
}
return true; }
if !matches!(row.status, SubagentStatus::Running) {
sa.done = true;
sa.done_at = Some(now);
if sa.duration_secs.is_none() {
sa.duration_secs = Some(row.elapsed_secs);
}
if !sa.status.starts_with('\u{2714}')
&& !sa.status.starts_with('\u{2718}')
&& !sa.status.starts_with('\u{26a0}')
{
sa.status = match &row.status {
SubagentStatus::Completed => "\u{2714} done".to_string(),
SubagentStatus::Cancelled => "\u{26a0} cancelled".to_string(),
SubagentStatus::TimedOut => "\u{26a0} timed out".to_string(),
SubagentStatus::Failed(r) => {
let preview: String = r.chars().take(30).collect();
format!("\u{2718} {}", preview)
}
SubagentStatus::Running => sa.status.clone(), };
}
return true;
}
if row.cancel_requested && !sa.done {
sa.status = "\u{26a0} cancelling\u{2026}".to_string();
}
true }
None => {
if sa.done {
if let Some(done_at) = sa.done_at {
if now.duration_since(done_at).as_secs_f64() > SUBAGENT_DONE_FLASH_SECS {
return false;
}
}
}
true }
}
});
let hud_ids: std::collections::HashSet<u64> = hud.iter().map(|s| s.id).collect();
for row in rows {
if hud_ids.contains(&row.subagent_id) {
continue;
}
if matches!(row.status, SubagentStatus::Running) && !row.cancel_requested {
let elapsed_dur = std::time::Duration::from_secs_f64(row.elapsed_secs.max(0.0));
let start_time = now.checked_sub(elapsed_dur).unwrap_or(now);
hud.push(SubagentState {
id: row.subagent_id,
name: row.agent_name.clone(),
status: "running".to_string(),
start_time,
done: false,
duration_secs: None,
done_at: None,
tools: 0,
result: None,
});
}
}
}
#[cfg(test)]
mod tests {
use super::sanitize_notice;
#[test]
fn test_sanitize_notice_strips_ansi_escape_payload() {
let payload = "API retry \x1b[2J\x1b]0;pwned\x07 in 2s\nline two\tend";
let clean = sanitize_notice(payload);
assert_eq!(clean, "API retry [2J]0;pwned in 2s\nline two\tend");
assert!(!clean.contains('\x1b'));
assert!(!clean.contains('\x07'));
}
#[test]
fn test_sanitize_notice_passes_normal_text() {
let s = "retrying (attempt 2/5) — overloaded";
assert_eq!(sanitize_notice(s), s);
}
}
#[cfg(test)]
mod reconcile_tests {
use super::super::app::SubagentState;
use super::{apply_subagent_progress, reconcile_subagents, SUBAGENT_DONE_FLASH_SECS};
use std::time::{Duration, Instant};
use synaps_cli::runtime::subagent::SubagentStatus;
use synaps_cli::tools::SubagentDisplayRow;
fn make_row(id: u64, status: SubagentStatus, cancel_requested: bool) -> SubagentDisplayRow {
SubagentDisplayRow {
subagent_id: id,
agent_name: format!("agent-{id}"),
status,
cancel_requested,
elapsed_secs: 1.5,
finished_elapsed: None,
step: String::new(),
tools: 0,
}
}
fn make_hud_entry(id: u64, done: bool) -> SubagentState {
SubagentState {
id,
name: format!("agent-{id}"),
status: if done {
"\u{2714} done".to_string()
} else {
"running".to_string()
},
start_time: Instant::now(),
done,
duration_secs: if done { Some(1.5) } else { None },
done_at: if done { Some(Instant::now()) } else { None },
tools: 0,
result: None,
}
}
#[test]
fn progress_from_rows_moves_running_entries_only() {
let mut hud = vec![
make_hud_entry(1, false),
make_hud_entry(2, true),
make_hud_entry(3, false),
];
hud[2].status = "starting: task".into();
let mut running = make_row(1, SubagentStatus::Running, false);
running.step = "$ sleep 20".into();
running.tools = 1;
let mut done = make_row(2, SubagentStatus::Completed, false);
done.step = "stale".into();
let quiet = make_row(3, SubagentStatus::Running, false); let mut cancelling = make_row(4, SubagentStatus::Running, true);
cancelling.step = "x".into();
assert!(apply_subagent_progress(
&mut hud,
&[running.clone(), done, quiet, cancelling]
));
assert_eq!((hud[0].status.as_str(), hud[0].tools), ("$ sleep 20", 1));
assert_eq!(hud[1].status, "\u{2714} done", "done entry untouched");
assert_eq!(
hud[2].status, "starting: task",
"empty step keeps the placeholder"
);
assert!(
!apply_subagent_progress(&mut hud, &[running]),
"unchanged → false"
);
}
#[test]
fn idle_finish_marks_done() {
let now = Instant::now();
let mut hud = vec![make_hud_entry(1, false)];
let rows = vec![make_row(1, SubagentStatus::Completed, false)];
reconcile_subagents(&mut hud, &rows, now);
assert_eq!(hud.len(), 1);
assert!(hud[0].done, "must be marked done");
assert!(hud[0].done_at.is_some(), "done_at must be stamped");
assert!(hud[0].status.contains('\u{2714}'), "must have ✔ glyph");
}
#[test]
fn oneshot_in_flight_survives() {
let now = Instant::now();
let mut hud = vec![make_hud_entry(99, false)]; let rows: Vec<SubagentDisplayRow> = vec![]; reconcile_subagents(&mut hud, &rows, now);
assert_eq!(
hud.len(),
1,
"in-flight oneshot must survive even if not in registry"
);
}
#[test]
fn cancelling_filtered() {
let now = Instant::now();
let mut hud = vec![make_hud_entry(1, false)];
let rows = vec![make_row(1, SubagentStatus::Running, true)]; reconcile_subagents(&mut hud, &rows, now);
assert_eq!(hud.len(), 1);
assert!(
hud[0].status.contains("cancelling"),
"status must say cancelling: {}",
hud[0].status
);
}
#[test]
fn flash_expiry_removes_done() {
let old_done_at = Instant::now() - Duration::from_secs_f64(SUBAGENT_DONE_FLASH_SECS + 1.0);
let mut entry = make_hud_entry(1, true);
entry.done_at = Some(old_done_at);
let mut hud = vec![entry];
let rows = vec![make_row(1, SubagentStatus::Completed, false)];
let now = Instant::now();
reconcile_subagents(&mut hud, &rows, now);
assert!(hud.is_empty(), "expired done entry must be removed");
}
#[test]
fn insert_missing() {
let now = Instant::now();
let mut hud: Vec<SubagentState> = vec![];
let rows = vec![make_row(5, SubagentStatus::Running, false)];
reconcile_subagents(&mut hud, &rows, now);
assert_eq!(hud.len(), 1, "missing running entry must be inserted");
assert_eq!(hud[0].id, 5);
assert!(!hud[0].done);
}
#[test]
fn multiple_subagents_mixed_states() {
let now = Instant::now();
let old_done_at = Instant::now() - Duration::from_secs_f64(SUBAGENT_DONE_FLASH_SECS + 1.0);
let mut sa3 = make_hud_entry(3, true);
sa3.done_at = Some(old_done_at);
let mut hud = vec![
make_hud_entry(1, false), make_hud_entry(2, false), sa3, ];
let rows = vec![
make_row(1, SubagentStatus::Completed, false),
make_row(2, SubagentStatus::Running, true),
make_row(3, SubagentStatus::Completed, false),
make_row(4, SubagentStatus::Running, false), ];
reconcile_subagents(&mut hud, &rows, now);
let sa1 = hud.iter().find(|s| s.id == 1).expect("sa_1 must exist");
assert!(sa1.done);
let sa2 = hud.iter().find(|s| s.id == 2).expect("sa_2 must exist");
assert!(
sa2.status.contains("cancelling"),
"sa2 status: {}",
sa2.status
);
assert!(
hud.iter().find(|s| s.id == 3).is_none(),
"sa_3 expired flash must be removed"
);
assert!(
hud.iter().find(|s| s.id == 4).is_some(),
"sa_4 must be inserted"
);
}
#[test]
fn done_not_in_registry_stays_during_flash() {
let now = Instant::now();
let mut entry = make_hud_entry(99, true);
entry.done_at = Some(now - Duration::from_millis(100));
let mut hud = vec![entry];
let rows: Vec<SubagentDisplayRow> = vec![];
reconcile_subagents(&mut hud, &rows, now);
assert_eq!(
hud.len(),
1,
"recently-done entry not in registry must stay for flash"
);
}
}