use std::collections::HashMap;
use std::collections::HashSet;
use crate::ChatRole;
use crate::agent::registry::{AgentHandle, NonAgentCallHandle, ParentKey};
use crate::gui::dialog;
use crate::gui::session_view::{
MAX_TOOL_TOOLTIP_WIDTH, SessionEntry, ToolBlockView, ToolCallEntry, build_ledger,
collapse_control_chars, promoted_reasoning, tool_block, truncate_at_boundary,
};
use crate::gui::theme;
use crate::gui::widgets;
use chrono::{DateTime, Utc};
use iced::widget::{
Column, Row, Space, button, column, container, mouse_area, row, stack, text, tooltip,
};
use iced::{Alignment, Element, Length};
use iced_fonts::lucide;
use super::Message;
use crate::Workspace;
const MAX_GROUP_LABEL_CHARS: usize = 104;
const MAX_METRIC_TOOLTIP_WIDTH: f32 = 360.0;
#[derive(Debug, Clone)]
pub(crate) enum RunningMessage {
CancelRequest(String),
CancelConfirmed,
CancelDismissed,
CancelFinished,
ToggleAgentExpanded { agent_id: String, generation: u64 },
}
pub(crate) fn view(
workspaces: &HashMap<String, Workspace>,
pending_cancel: Option<&str>,
expanded: &HashSet<(String, u64)>,
) -> Element<'static, Message> {
let agents = crate::agent::registry::AGENT_REGISTRY.list();
let calls = crate::agent::registry::NON_AGENT_CALLS.list();
let sections = build_sections(build_groups(&agents, &calls), workspaces);
let body: Element<'_, RunningMessage> = if sections.is_empty() {
widgets::empty_state_placeholder(
lucide::radar::<iced::Theme, iced::Renderer>(),
"Nothing is currently running.",
theme::TEXT_MUTED,
)
} else {
let mut content = Column::new().spacing(theme::SPACE_20);
for section in §ions {
content = content.push(render_section(section, expanded));
}
widgets::vscroll(content)
};
let page: Element<'_, RunningMessage> = container(body)
.width(Length::Fill)
.height(Length::Fill)
.padding(theme::PAGE_PADDING)
.style(theme::base_container_style)
.into();
let confirm_layer: Element<'_, RunningMessage> = if let Some(run_key) = pending_cancel {
cancel_confirm_dialog(run_key)
} else {
stack([widgets::empty_stack_placeholder()]).into()
};
let stacked: Element<'_, RunningMessage> = stack([page, confirm_layer]).into();
stacked.map(Message::RunningAgents)
}
#[derive(Debug, Clone)]
enum DisplayItem {
Agent(Box<AgentCard>),
Call(CallRow),
}
#[derive(Debug, Clone)]
struct AgentCard {
handle: AgentHandle,
}
#[derive(Debug, Clone)]
struct CallRow {
handle: NonAgentCallHandle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum GroupKind {
Ticket,
AnalyzeRound,
Research,
Singleton,
Unattributed,
}
#[derive(Debug, Clone)]
struct DisplayGroup {
kind: GroupKind,
key: String,
workspace: String,
label: Option<String>,
items: Vec<DisplayItem>,
run_lifetime: bool,
}
#[derive(Debug, Clone)]
struct WorkspaceSection {
workspace: String,
label: String,
groups: Vec<DisplayGroup>,
}
impl DisplayGroup {
fn sort_key(&self) -> (u8, String) {
let order = match self.kind {
GroupKind::Ticket => 0,
GroupKind::AnalyzeRound => 1,
GroupKind::Research => 2,
GroupKind::Singleton => 3,
GroupKind::Unattributed => 4,
};
(order, self.key.clone())
}
}
fn parent_group_kind(parent: &ParentKey) -> GroupKind {
match parent {
ParentKey::Ticket(_) => GroupKind::Ticket,
ParentKey::AnalyzeRound(_) => GroupKind::AnalyzeRound,
ParentKey::Research(_) => GroupKind::Research,
}
}
fn parent_group_key(parent: &ParentKey) -> &str {
match parent {
ParentKey::Ticket(id) => id,
ParentKey::AnalyzeRound(key) | ParentKey::Research(key) => key,
}
}
fn build_groups(agents: &[AgentHandle], calls: &[NonAgentCallHandle]) -> Vec<DisplayGroup> {
let mut groups: Vec<DisplayGroup> = Vec::new();
let find_group =
|kind: GroupKind, key: &str, workspace: &str, groups: &mut Vec<DisplayGroup>| -> usize {
let research = kind == GroupKind::Research;
if let Some(idx) = groups.iter().position(|g| {
g.kind == kind && g.key == key && (research || g.workspace == workspace)
}) {
if research && groups[idx].workspace == key && workspace != key {
groups[idx].workspace = workspace.to_string();
}
return idx;
}
groups.push(DisplayGroup {
kind,
key: key.to_string(),
workspace: workspace.to_string(),
label: None,
items: Vec::new(),
run_lifetime: false,
});
groups.len() - 1
};
let adopt_label = |label: &Option<String>, group: &mut DisplayGroup| {
if group.label.is_none() && label.is_some() {
group.label.clone_from(label);
}
};
for agent in agents {
let workspace = agent.workspace_name.clone();
if let Some(parent) = &agent.parent_key {
let idx = find_group(
parent_group_kind(parent),
parent_group_key(parent),
&workspace,
&mut groups,
);
adopt_label(&agent.parent_label, &mut groups[idx]);
groups[idx]
.items
.push(DisplayItem::Agent(Box::new(AgentCard {
handle: agent.clone(),
})));
} else {
let idx = find_group(GroupKind::Singleton, &workspace, &workspace, &mut groups);
groups[idx]
.items
.push(DisplayItem::Agent(Box::new(AgentCard {
handle: agent.clone(),
})));
}
}
for call in calls {
let workspace = call.workspace.clone();
if let Some(parent) = &call.parent_key {
let idx = find_group(
parent_group_kind(parent),
parent_group_key(parent),
&workspace,
&mut groups,
);
adopt_label(&call.parent_label, &mut groups[idx]);
if call.run_lifetime {
groups[idx].run_lifetime = true;
} else {
groups[idx].items.push(DisplayItem::Call(CallRow {
handle: call.clone(),
}));
}
} else {
let idx = find_group(GroupKind::Unattributed, &workspace, &workspace, &mut groups);
groups[idx].items.push(DisplayItem::Call(CallRow {
handle: call.clone(),
}));
}
}
groups
}
fn build_sections(
groups: Vec<DisplayGroup>,
workspaces: &std::collections::HashMap<String, Workspace>,
) -> Vec<WorkspaceSection> {
let mut sections: Vec<WorkspaceSection> = Vec::new();
for group in groups {
let label = workspace_label_for(&group.workspace, workspaces);
if let Some(section) = sections.iter_mut().find(|s| s.workspace == group.workspace) {
section.groups.push(group);
} else {
sections.push(WorkspaceSection {
workspace: group.workspace.clone(),
label,
groups: vec![group],
});
}
}
sections.sort_by(|a, b| a.label.cmp(&b.label));
for section in &mut sections {
section.groups.sort_by_key(DisplayGroup::sort_key);
}
sections
}
fn render_section(
section: &WorkspaceSection,
expanded: &HashSet<(String, u64)>,
) -> Element<'static, RunningMessage> {
let mut groups = Column::new().spacing(theme::SPACE_10);
for group in §ion.groups {
groups = groups.push(render_group(group, expanded));
}
groups.into()
}
fn group_title(group: &DisplayGroup) -> (String, Option<String>) {
match &group.kind {
GroupKind::Ticket => match &group.label {
Some(title) => (title.clone(), Some(group.key.clone())),
None => (group.key.clone(), None),
},
GroupKind::AnalyzeRound => (
group.label.as_deref().map_or_else(
|| "Analyze round".to_string(),
|l| truncate_at_boundary(l, MAX_GROUP_LABEL_CHARS),
),
None,
),
GroupKind::Research => (
group.label.as_deref().map_or_else(
|| "Research run".to_string(),
|l| truncate_at_boundary(l, MAX_GROUP_LABEL_CHARS),
),
None,
),
GroupKind::Singleton => ("Standalone".to_string(), None),
GroupKind::Unattributed => ("Other LLM work".to_string(), None),
}
}
fn render_group(
group: &DisplayGroup,
expanded: &HashSet<(String, u64)>,
) -> Element<'static, RunningMessage> {
let (title, id) = group_title(group);
let mut header_parts: Vec<Element<'_, RunningMessage>> = Vec::new();
let header = if group.kind == GroupKind::Ticket {
match id {
Some(id) => format!("[{id}] {title}"),
None => title.clone(),
}
} else {
title.clone()
};
header_parts.push(
text(header)
.size(theme::TEXT_12)
.color(theme::TEXT_SECONDARY)
.into(),
);
if group.run_lifetime {
header_parts.push(
text("run active")
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY)
.into(),
);
}
if group.kind == GroupKind::Research {
header_parts.push(Space::new().width(Length::Fill).into());
header_parts.push(
button(text("Cancel run").size(theme::TEXT_11))
.style(theme::button_danger)
.on_press(RunningMessage::CancelRequest(group.key.clone()))
.into(),
);
}
let mut items = Column::new().spacing(theme::SPACE_6);
for item in &group.items {
match item {
DisplayItem::Agent(card) => {
let is_expanded =
expanded.contains(&(card.handle.agent_id.clone(), card.handle.generation));
items = items.push(render_agent_card(card, is_expanded));
}
DisplayItem::Call(call) => {
items = items.push(render_call_row(call));
}
}
}
column![
Row::with_children(header_parts)
.spacing(theme::SPACE_8)
.align_y(Alignment::Center),
items,
]
.spacing(theme::SPACE_6)
.into()
}
fn cancel_confirm_dialog(run_key: &str) -> Element<'static, RunningMessage> {
let _ = run_key;
widgets::modal_backdrop(
dialog::confirm_dialog(
dialog::dialog_title("Cancel this research run?"),
dialog::dialog_body([
"Confirming will:",
"• stop all agents of this run — an in-flight tool or LLM call \
may finish, but no further work happens;\n\
• stop the orchestrator — no more rounds, no report, and \
nothing is delivered to the Manager;\n\
• remove the run's temporary traces and its run folder — \
dispatching the cleanup agent while the orchestrator is still \
running, a direct sweep otherwise;\n\
• remove the run permanently — it can never resume.",
]),
[
dialog::DialogAction::secondary("Keep run", RunningMessage::CancelDismissed),
dialog::DialogAction::danger("Cancel run", RunningMessage::CancelConfirmed),
],
),
RunningMessage::CancelDismissed,
0.5,
)
}
fn fallback_workspace_label(workspace: &str) -> String {
if workspace.is_empty() {
"workspace".to_string()
} else {
format!("{workspace} (external)")
}
}
fn workspace_label_for(
name: &str,
workspaces: &std::collections::HashMap<String, Workspace>,
) -> String {
if name.is_empty() {
"workspace".to_string()
} else if workspaces.contains_key(name) {
name.to_string()
} else {
fallback_workspace_label(name)
}
}
fn render_agent_card(card: &AgentCard, expanded: bool) -> Element<'static, RunningMessage> {
let h = &card.handle;
let (fg, _bg) = theme::role_badge_color(&h.role);
let role: crate::Role = h.role.parse().unwrap_or(crate::Role::Engineer);
let icon = theme::role_icon(&role).size(theme::TEXT_24).color(fg);
let elapsed = format_elapsed(h.started_at);
let snapshot = crate::session::TRANSCRIPT_REGISTRY.snapshot(&h.agent_id);
let groups = snapshot
.as_ref()
.map(|s| derive_trace_groups(current_turn_entries(&build_ledger(&s.history))))
.unwrap_or_default();
let token_count = snapshot.as_ref().and_then(|s| s.token_count);
let mut content = Column::new()
.spacing(theme::SPACE_6)
.align_x(Alignment::Start)
.width(Length::Fill);
let first_row = Row::new()
.spacing(theme::SPACE_12)
.align_y(Alignment::Center)
.push(icon)
.push(render_metrics(token_count, &elapsed));
content = content.push(first_row);
if groups.is_empty() {
content = content.push(clanking_placeholder());
}
for group in render_visible_groups(&groups, expanded) {
content = content.push(group);
}
if let Some(activity) = &h.activity {
content = content.push(
text(activity.to_owned())
.size(theme::TEXT_13)
.color(theme::ACCENT),
);
}
let on_press = RunningMessage::ToggleAgentExpanded {
agent_id: h.agent_id.clone(),
generation: h.generation,
};
mouse_area(
container(content)
.width(Length::Fill)
.padding(theme::PAD_8)
.style(theme::surface_card_style),
)
.on_press(on_press)
.into()
}
fn narration_text(narration: &str) -> iced::widget::Text<'static, iced::Theme, iced::Renderer> {
text(narration.to_owned())
.size(theme::NARRATION_TEXT_SIZE)
.font(theme::FONT_ITALIC)
.color(theme::TEXT_SECONDARY)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
}
const MAX_PROMOTED_LABEL_CHARS: usize = 200;
fn promoted_reasoning_label(reasoning: &str) -> String {
truncate_at_boundary(
&collapse_control_chars(reasoning, MAX_PROMOTED_LABEL_CHARS),
MAX_PROMOTED_LABEL_CHARS,
)
}
fn clanking_placeholder() -> iced::widget::Text<'static, iced::Theme, iced::Renderer> {
text("clanking")
.size(theme::TEXT_13)
.color(theme::TEXT_SECONDARY)
}
fn render_metrics(token_count: Option<u64>, elapsed: &str) -> Element<'static, RunningMessage> {
let mut metrics = Row::new()
.spacing(theme::SPACE_12)
.align_y(Alignment::Center);
if let Some(token_count) = token_count {
metrics = metrics.push(
tooltip(
text(theme::format_compact_tokens(token_count))
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
render_metric_tooltip(format!("Session tokens: {token_count}")),
tooltip::Position::Top,
)
.gap(theme::SPACE_4)
.style(theme::tooltip_style),
);
}
metrics = metrics.push(
tooltip(
text(elapsed.to_owned())
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
render_metric_tooltip("Elapsed run time since the agent started".to_string()),
tooltip::Position::Top,
)
.gap(theme::SPACE_4)
.style(theme::tooltip_style),
);
metrics.into()
}
fn render_visible_groups(
groups: &[TraceGroup],
expanded: bool,
) -> Vec<Element<'static, RunningMessage>> {
let mut rendered = Vec::new();
let Some((current, previous)) = groups.split_last() else {
return rendered;
};
if expanded {
for group in previous.iter().rev().take(5).rev() {
rendered.push(render_trace_group(group, false));
}
}
rendered.push(render_trace_group(current, true));
rendered
}
enum GroupLabelText<'a> {
Narration(&'a str),
Promoted(String),
Placeholder,
}
fn group_label_text(group: &TraceGroup) -> GroupLabelText<'_> {
if let Some(reasoning) = promoted_reasoning(Some(&group.narration), group.reasoning.as_deref())
{
GroupLabelText::Promoted(promoted_reasoning_label(reasoning))
} else if !group.narration.trim().is_empty() {
GroupLabelText::Narration(&group.narration)
} else {
GroupLabelText::Placeholder
}
}
fn render_trace_group(
group: &TraceGroup,
expand_current: bool,
) -> Element<'static, RunningMessage> {
let mut column = Column::new()
.spacing(theme::SPACE_4)
.align_x(Alignment::Start);
let label: Element<'static, RunningMessage> = match group_label_text(group) {
GroupLabelText::Narration(narration) => {
reasoning_tooltip(group.reasoning.as_deref(), narration_text(narration).into())
}
GroupLabelText::Promoted(promoted) => narration_text(&promoted).into(),
GroupLabelText::Placeholder => clanking_placeholder().into(),
};
column = column.push(label);
if expand_current {
let (newest, earlier) = group
.rounds
.split_last()
.expect("a trace group always carries at least one round");
if !earlier.is_empty() {
column = column.push(
text(collapsed_calls_line(earlier))
.size(theme::TEXT_10)
.color(theme::TEXT_SECONDARY),
);
}
for entry in newest {
column = column.push(tool_row(entry));
}
} else {
column = column.push(
text(collapsed_calls_line(&group.rounds))
.size(theme::TEXT_10)
.color(theme::TEXT_SECONDARY),
);
}
column.into()
}
fn tool_row(entry: &ToolCallEntry) -> Element<'static, RunningMessage> {
let block = tool_block(&entry.tool, ToolBlockView::Compact);
if entry.result.is_none() {
Row::new()
.spacing(theme::SPACE_6)
.align_y(Alignment::Center)
.push(block)
.push(
lucide::loader_circle::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_14)
.color(theme::ACCENT),
)
.into()
} else {
block
}
}
fn reasoning_tooltip<'a>(
reasoning: Option<&str>,
label: Element<'a, RunningMessage>,
) -> Element<'a, RunningMessage> {
let Some(reasoning) = reasoning.filter(|r| !r.is_empty()) else {
return label;
};
let tooltip_content: Element<'static, RunningMessage> = container(
text(reasoning.to_string())
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
)
.max_width(MAX_TOOL_TOOLTIP_WIDTH)
.into();
tooltip(label, tooltip_content, tooltip::Position::Top)
.gap(theme::SPACE_4)
.style(theme::tooltip_style)
.into()
}
fn collapsed_calls_line(rounds: &[Vec<ToolCallEntry>]) -> String {
let mut order: Vec<String> = Vec::new();
let mut counts: HashMap<String, usize> = HashMap::new();
for round in rounds {
for call in round {
let base = call.tool.name.replace('_', " ");
let display = if call.result.is_none() {
format!("{base} …")
} else {
base
};
if !counts.contains_key(&display) {
order.push(display.clone());
}
*counts.entry(display).or_default() += 1;
}
}
order
.iter()
.map(|name| {
let c = counts[name];
if c > 1 {
format!("{name} x{c}")
} else {
name.clone()
}
})
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn prune_expanded(expanded: &mut HashSet<(String, u64)>, agents: &[AgentHandle]) {
let live: HashSet<(String, u64)> = agents
.iter()
.map(|h| (h.agent_id.clone(), h.generation))
.collect();
expanded.retain(|key| live.contains(key));
}
struct TraceGroup {
narration: String,
reasoning: Option<String>,
rounds: Vec<Vec<ToolCallEntry>>,
}
fn is_user_turn_boundary(entry: &SessionEntry) -> bool {
matches!(entry, SessionEntry::Message { role: ChatRole::User, content, .. }
if content.as_deref().is_some_and(|c| {
!c.trim().is_empty() && !c.starts_with(crate::util::INJECTED_IMAGE_TAG)
}))
}
fn current_turn_entries(entries: &[SessionEntry]) -> &[SessionEntry] {
let start = entries
.iter()
.rposition(is_user_turn_boundary)
.map_or(0, |i| i + 1);
&entries[start..]
}
fn derive_trace_groups(entries: &[SessionEntry]) -> Vec<TraceGroup> {
let mut groups: Vec<TraceGroup> = Vec::new();
let mut current: Option<TraceGroup> = None;
for entry in entries {
match entry {
SessionEntry::ToolRound {
narration,
reasoning,
calls,
} => {
let narration = narration.clone().unwrap_or_default();
let new_group = !narration.is_empty() || current.is_none();
if new_group {
if let Some(group) = current.take() {
groups.push(group);
}
}
let group = current.get_or_insert_with(|| TraceGroup {
narration,
reasoning: None,
rounds: Vec::new(),
});
if group.reasoning.is_none()
&& let Some(reasoning) = reasoning
&& !reasoning.is_empty()
{
group.reasoning = Some(reasoning.clone());
}
group.rounds.push(calls.clone());
}
SessionEntry::Message { .. } => {
if is_user_turn_boundary(entry)
&& let Some(group) = current.take()
{
groups.push(group);
}
}
}
}
if let Some(group) = current.take() {
groups.push(group);
}
groups
}
fn render_metric_tooltip(label: String) -> Element<'static, RunningMessage> {
container(
text(label)
.size(theme::TEXT_11)
.color(theme::TEXT_PRIMARY)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
)
.max_width(MAX_METRIC_TOOLTIP_WIDTH)
.into()
}
fn render_call_row(call: &CallRow) -> Element<'static, RunningMessage> {
let h = &call.handle;
let elapsed = format_elapsed(h.started_at);
let purpose = crate::agent::registry::call_kind_label(h.kind);
row![
lucide::zap::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_16)
.color(theme::ACCENT),
text(purpose)
.size(theme::TEXT_12)
.color(theme::TEXT_SECONDARY),
Space::new().width(Length::Fill),
text(elapsed)
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY),
]
.spacing(theme::SPACE_6)
.align_y(Alignment::Center)
.into()
}
fn format_elapsed(started_at: DateTime<Utc>) -> String {
let now = Utc::now();
let secs = now.signed_duration_since(started_at).num_seconds().max(0);
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {:02}s", secs / 60, secs % 60)
} else {
format!("{}h {:02}m", secs / 3600, (secs % 3600) / 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ChatMessage;
use crate::agent::registry::{AgentHandle, RunningTool};
use crate::gui::session_view::build_ledger;
fn agent_handle(
id: &str,
role: &str,
ticket_id: Option<String>,
workspace: &str,
parent: Option<ParentKey>,
) -> AgentHandle {
AgentHandle {
agent_id: id.to_string(),
role: role.to_string(),
ticket_id,
workspace_path: format!("/ws/{workspace}"),
workspace_name: workspace.to_string(),
parent_key: parent,
parent_label: None,
started_at: Utc::now(),
label: role.to_string(),
generation: 0,
activity: None,
}
}
fn call_handle(
kind: &'static str,
workspace: &str,
parent: Option<ParentKey>,
run_lifetime: bool,
) -> NonAgentCallHandle {
NonAgentCallHandle {
kind,
workspace: workspace.to_string(),
started_at: Utc::now(),
parent_key: parent,
parent_label: None,
run_lifetime,
}
}
#[test]
fn groups_ticket_agents_and_synthesis_together() {
let agents = vec![
agent_handle(
"a1",
"analyst",
Some("T1".to_string()),
"ws1",
Some(ParentKey::Ticket("T1".to_string())),
),
agent_handle("mgr", "manager", None, "ws1", None),
];
let calls = vec![call_handle(
"synthesis",
"ws1",
Some(ParentKey::Ticket("T1".to_string())),
false,
)];
let groups = build_groups(&agents, &calls);
assert_eq!(groups.len(), 2, "ticket group + singleton group");
let ticket = &groups[0];
assert_eq!(ticket.kind, GroupKind::Ticket);
assert_eq!(ticket.key, "T1");
assert_eq!(ticket.items.len(), 2, "analyst agent + synthesis call");
}
#[test]
fn two_analyze_rounds_never_mix_members() {
let agents = vec![
agent_handle(
"analyze_ws_AAA_0_analyst",
"analyst",
None,
"ws1",
Some(ParentKey::AnalyzeRound("roundA".to_string())),
),
agent_handle(
"analyze_ws_AAA_1_analyst",
"analyst",
None,
"ws1",
Some(ParentKey::AnalyzeRound("roundA".to_string())),
),
agent_handle(
"analyze_ws_BBB_0_analyst",
"analyst",
None,
"ws1",
Some(ParentKey::AnalyzeRound("roundB".to_string())),
),
];
let calls = vec![call_handle(
"consolidate",
"ws1",
Some(ParentKey::AnalyzeRound("roundA".to_string())),
false,
)];
let groups = build_groups(&agents, &calls);
let analyze_groups: Vec<_> = groups
.iter()
.filter(|g| g.kind == GroupKind::AnalyzeRound)
.collect();
assert_eq!(analyze_groups.len(), 2, "two distinct analyze round groups");
let round_a = analyze_groups
.iter()
.find(|g| g.key == "roundA")
.expect("round A exists");
assert_eq!(round_a.items.len(), 3, "2 analysts + consolidation call");
let round_b = analyze_groups
.iter()
.find(|g| g.key == "roundB")
.expect("round B exists");
assert_eq!(round_b.items.len(), 1, "only its own analyst");
}
#[test]
fn research_run_members_share_one_key_across_phases() {
let agents = vec![
agent_handle(
"research_ws_x1_decompose_0",
"analyst",
None,
"ws1",
Some(ParentKey::Research("run1".to_string())),
),
agent_handle(
"research_ws_x2_r1_0",
"analyst",
None,
"ws1",
Some(ParentKey::Research("run1".to_string())),
),
agent_handle(
"research_ws_y1_decompose_0",
"analyst",
None,
"ws1",
Some(ParentKey::Research("run2".to_string())),
),
];
let calls = vec![
call_handle(
"synthesize",
"ws1",
Some(ParentKey::Research("run1".to_string())),
false,
),
call_handle(
"research_orchestrator",
"ws1",
Some(ParentKey::Research("run1".to_string())),
true,
),
];
let groups = build_groups(&agents, &calls);
let research_groups: Vec<_> = groups
.iter()
.filter(|g| g.kind == GroupKind::Research)
.collect();
assert_eq!(research_groups.len(), 2);
let run1 = research_groups
.iter()
.find(|g| g.key == "run1")
.expect("run 1 exists");
assert_eq!(run1.items.len(), 3, "2 analysts + synthesize call");
assert!(
run1.run_lifetime,
"run-lifetime orchestrator marker attached"
);
let run2 = research_groups
.iter()
.find(|g| g.key == "run2")
.expect("run 2 exists");
assert_eq!(run2.items.len(), 1, "only its own member");
assert!(!run2.run_lifetime);
}
#[test]
fn singletons_and_unattributed_calls_group_by_workspace() {
let agents = vec![
agent_handle("manager_ws1", "manager", None, "ws1", None),
agent_handle("maintainer_ws2_abc", "maintainer", None, "ws2", None),
];
let calls = vec![call_handle("some_orchestrator_call", "ws1", None, false)];
let groups = build_groups(&agents, &calls);
let singleton: Vec<_> = groups
.iter()
.filter(|g| g.kind == GroupKind::Singleton)
.collect();
assert_eq!(singleton.len(), 2, "one per workspace");
let unattributed: Vec<_> = groups
.iter()
.filter(|g| g.kind == GroupKind::Unattributed)
.collect();
assert_eq!(unattributed.len(), 1);
assert_eq!(unattributed[0].workspace, "ws1");
}
#[test]
fn sort_key_orders_groups_by_kind() {
let mut groups = [
DisplayGroup {
kind: GroupKind::Unattributed,
key: "ws".to_string(),
workspace: "ws".to_string(),
label: None,
items: Vec::new(),
run_lifetime: false,
},
DisplayGroup {
kind: GroupKind::Ticket,
key: "T1".to_string(),
workspace: "ws".to_string(),
label: None,
items: Vec::new(),
run_lifetime: false,
},
DisplayGroup {
kind: GroupKind::Research,
key: "r".to_string(),
workspace: "ws".to_string(),
label: None,
items: Vec::new(),
run_lifetime: false,
},
];
groups.sort_by_key(DisplayGroup::sort_key);
assert_eq!(groups[0].kind, GroupKind::Ticket);
assert_eq!(groups[1].kind, GroupKind::Research);
assert_eq!(groups[2].kind, GroupKind::Unattributed);
}
#[test]
fn research_groups_resolve_workspace_across_ephemeral_coder_members() {
let agents = vec![
agent_handle(
"research_ws1_run1_coder0",
"coder",
None,
"run1",
Some(ParentKey::Research("run1".to_string())),
),
agent_handle(
"research_ws1_run1_r1_0",
"analyst",
None,
"ws1",
Some(ParentKey::Research("run1".to_string())),
),
agent_handle(
"research_ws1_run2_r1_0",
"analyst",
None,
"ws1",
Some(ParentKey::Research("run2".to_string())),
),
agent_handle(
"research_ws1_run2_coder0",
"coder",
None,
"run2",
Some(ParentKey::Research("run2".to_string())),
),
];
let groups = build_groups(&agents, &[]);
let runs: Vec<_> = groups
.iter()
.filter(|g| g.kind == GroupKind::Research)
.collect();
assert_eq!(runs.len(), 2, "two concurrent runs never mix members");
for run in runs {
assert_eq!(
run.workspace, "ws1",
"the real workspace wins over the ephemeral per-run workspace"
);
assert_eq!(run.items.len(), 2, "coder + analyst in one group");
}
}
#[test]
fn cross_workspace_round_keys_never_merge_groups() {
let agents = vec![
agent_handle(
"analyze_ws_AAA_0_analyst",
"analyst",
None,
"ws1",
Some(ParentKey::AnalyzeRound("abc123".to_string())),
),
agent_handle(
"analyze_ws_BBB_0_analyst",
"analyst",
None,
"ws2",
Some(ParentKey::AnalyzeRound("abc123".to_string())),
),
];
let groups = build_groups(&agents, &[]);
let analyze_groups: Vec<_> = groups
.iter()
.filter(|g| g.kind == GroupKind::AnalyzeRound)
.collect();
assert_eq!(
analyze_groups.len(),
2,
"same round key in two workspaces stays separate"
);
}
fn agent_handle_labeled(
id: &str,
role: &str,
workspace: &str,
parent: Option<ParentKey>,
parent_label: Option<&str>,
) -> AgentHandle {
let mut h = agent_handle(id, role, None, workspace, parent);
h.parent_label = parent_label.map(ToString::to_string);
h
}
fn call_handle_labeled(
kind: &'static str,
workspace: &str,
parent: Option<ParentKey>,
parent_label: Option<&str>,
) -> NonAgentCallHandle {
let mut h = call_handle(kind, workspace, parent, false);
h.parent_label = parent_label.map(ToString::to_string);
h
}
#[test]
fn ticket_group_label_comes_from_members_not_the_key() {
let agents = vec![agent_handle_labeled(
"a1",
"analyst",
"ws1",
Some(ParentKey::Ticket("T1".to_string())),
Some("Fix the login flow"),
)];
let calls = vec![call_handle_labeled(
"synthesis",
"ws1",
Some(ParentKey::Ticket("T1".to_string())),
Some("Fix the login flow"),
)];
let groups = build_groups(&agents, &calls);
let ticket = groups
.iter()
.find(|g| g.kind == GroupKind::Ticket)
.expect("ticket group exists");
assert_eq!(
ticket.label.as_deref(),
Some("Fix the login flow"),
"group label adopted from a member's parent label"
);
let (title, secondary) = group_title(ticket);
assert_eq!(title, "Fix the login flow");
assert_eq!(secondary.as_deref(), Some("T1"));
}
#[test]
fn analyze_and_research_groups_render_question_not_raw_key() {
let agents = vec![agent_handle_labeled(
"a1",
"analyst",
"ws1",
Some(ParentKey::AnalyzeRound("job_abc".to_string())),
Some("Why is CI flaky?"),
)];
let groups = build_groups(&agents, &[]);
let analyze = groups
.iter()
.find(|g| g.kind == GroupKind::AnalyzeRound)
.expect("analyze group exists");
let (title, secondary) = group_title(analyze);
assert_eq!(title, "Why is CI flaky?");
assert!(secondary.is_none(), "no id secondary for analyze groups");
assert!(!title.contains("job_abc"), "raw key never leaks");
let mut no_label = analyze.clone();
no_label.label = None;
let (fallback, _) = group_title(&no_label);
assert_eq!(fallback, "Analyze round");
assert!(!fallback.contains("job_abc"), "generic fallback, no key");
}
#[test]
fn sections_group_by_workspace_and_keep_kind_order() {
let mut ws_map = std::collections::HashMap::new();
ws_map.insert(
"ws2".to_string(),
Workspace {
path: "/ws/ws2".to_string(),
paused: false,
maintenance_enabled: false,
..Default::default()
},
);
let agents = vec![
agent_handle(
"t_agent",
"engineer",
Some("T1".to_string()),
"ws1",
Some(ParentKey::Ticket("T1".to_string())),
),
agent_handle("mgr_ws2", "manager", None, "ws2", None),
agent_handle("mgr_ws1", "manager", None, "ws1", None),
];
let groups = build_groups(&agents, &[]);
let sections = build_sections(groups, &ws_map);
assert_eq!(sections.len(), 2, "one section per workspace");
assert_eq!(sections[0].workspace, "ws1");
assert_eq!(sections[1].workspace, "ws2");
assert_eq!(sections[0].groups[0].kind, GroupKind::Ticket);
assert_eq!(sections[0].groups[1].kind, GroupKind::Singleton);
assert_eq!(sections[1].label, "ws2");
assert_eq!(sections[0].label, "ws1 (external)");
}
#[test]
fn singleton_and_unattributed_headers_are_generic_not_workspace_titled() {
let agents = vec![agent_handle("mgr", "manager", None, "ws1", None)];
let calls = vec![call_handle("some_orchestrator_call", "ws1", None, false)];
let groups = build_groups(&agents, &calls);
for group in &groups {
let (title, _) = group_title(group);
assert_ne!(title, "ws1", "group header must not duplicate the section");
assert!(
!title.contains("ws1"),
"no workspace name in group headers: {title}"
);
}
}
fn assistant_tool_call(narration: &str, calls: &[(&str, serde_json::Value)]) -> ChatMessage {
assistant_tool_call_reasoning(narration, calls, None)
}
fn assistant_tool_call_reasoning(
narration: &str,
calls: &[(&str, serde_json::Value)],
reasoning: Option<&str>,
) -> ChatMessage {
let calls_json: Vec<serde_json::Value> = calls
.iter()
.map(|(name, args)| {
serde_json::json!({
"id": "call_1",
"name": name,
"arguments": serde_json::to_string(args).unwrap_or_default(),
})
})
.collect();
let content = if narration.is_empty() {
serde_json::Value::Null
} else {
serde_json::Value::String(narration.to_string())
};
let mut body = serde_json::json!({ "content": content, "tool_calls": calls_json });
if let Some(reasoning) = reasoning {
body["reasoning"] = serde_json::json!(reasoning);
}
ChatMessage::assistant(body.to_string())
}
#[test]
fn empty_and_non_tool_history_produce_no_groups() {
assert!(derive_trace_groups(&[]).is_empty());
let history = vec![
ChatMessage::user("hello\n\n<timestamp>2026-01-01 00:00:00 (UTC)</timestamp>"),
ChatMessage::assistant("Final answer here."),
];
assert!(derive_trace_groups(&build_ledger(&history)).is_empty());
}
#[test]
fn narration_starts_and_boundary_groups() {
let history = vec![
assistant_tool_call(
"Reading the file",
&[("read", serde_json::json!({"path": "a.rs"}))],
),
assistant_tool_call(
"Editing it",
&[("edit", serde_json::json!({"path": "b.rs"}))],
),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 2, "new narration starts a new group");
assert_eq!(groups[0].narration, "Reading the file");
assert_eq!(groups[1].narration, "Editing it");
}
#[test]
fn consecutive_no_narration_rounds_accumulate() {
let history = vec![
assistant_tool_call("", &[("read", serde_json::json!({"path": "a.rs"}))]),
assistant_tool_call("", &[("list", serde_json::json!({"path": "."}))]),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 1, "no narration → same group");
assert_eq!(groups[0].rounds.len(), 2);
assert!(groups[0].narration.is_empty());
}
#[test]
fn real_user_turn_resets_group() {
let history = vec![
assistant_tool_call(
"Narration",
&[("read", serde_json::json!({"path": "a.rs"}))],
),
ChatMessage::user("ok now fix it\n\n<timestamp>2026-01-01 00:00:00 (UTC)</timestamp>"),
assistant_tool_call("", &[("edit", serde_json::json!({"path": "b.rs"}))]),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 2, "user turn closes the group");
assert_eq!(groups[0].narration, "Narration");
assert_eq!(groups[1].rounds.len(), 1);
}
#[test]
fn injected_image_user_message_does_not_reset_group() {
let history = vec![
assistant_tool_call("", &[("read", serde_json::json!({"path": "a.rs"}))]),
ChatMessage::user(crate::util::injected_image_user_message(
"data:image/png;base64,xxx",
)),
assistant_tool_call("", &[("read", serde_json::json!({"path": "a.rs"}))]),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 1, "synthetic image stays in the same group");
assert_eq!(groups[0].rounds.len(), 2);
}
#[test]
fn empty_user_message_is_not_a_turn_boundary() {
let history = vec![
assistant_tool_call("", &[("read", serde_json::json!({"path": "a.rs"}))]),
ChatMessage::user(""),
assistant_tool_call("", &[("edit", serde_json::json!({"path": "b.rs"}))]),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 1, "empty user message is not a boundary");
assert_eq!(groups[0].rounds.len(), 2);
}
#[test]
fn trace_group_reasoning_is_first_non_empty_round() {
let history = vec![
assistant_tool_call_reasoning(
"",
&[("read", serde_json::json!({"path": "a.rs"}))],
None,
),
assistant_tool_call_reasoning(
"",
&[("edit", serde_json::json!({"path": "b.rs"}))],
Some("first"),
),
assistant_tool_call_reasoning(
"",
&[("list", serde_json::json!({"path": "."}))],
Some("second"),
),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 1, "no narration → same group");
assert_eq!(groups[0].reasoning.as_deref(), Some("first"));
}
#[test]
fn trace_group_without_reasoning_is_none() {
let history = vec![assistant_tool_call_reasoning(
"Narration",
&[("read", serde_json::json!({"path": "a.rs"}))],
None,
)];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups[0].reasoning, None);
}
fn entry(name: &str, args: serde_json::Value, result: Option<&str>) -> ToolCallEntry {
ToolCallEntry {
tool_call_id: String::new(),
tool: RunningTool::from_tool_call(&crate::ToolCall {
id: String::new(),
name: name.to_string(),
arguments: args,
}),
result: result.map(ToString::to_string),
}
}
#[test]
fn collapsed_calls_line_counts_and_orders() {
let rounds = vec![
vec![
entry("read_file", serde_json::json!({"path": "a.rs"}), Some("ok")),
entry("list_files", serde_json::json!({"path": "."}), Some("ok")),
],
vec![
entry("read_file", serde_json::json!({"path": "b.rs"}), Some("ok")),
entry("read_file", serde_json::json!({"path": "c.rs"}), None),
],
];
assert_eq!(
collapsed_calls_line(&rounds),
"read file x2, list files, read file …",
"underscores become spaces; settled names stay exact, an in-flight call gets a running suffix; `xN` counts collapsed repetitions; first-appearance order"
);
}
#[test]
fn card_shows_only_current_turn_groups() {
let history = vec![
assistant_tool_call("First", &[("read", serde_json::json!({"path": "a.rs"}))]),
ChatMessage::user("ok now do it\n\n<timestamp>2026-01-01 00:00:00 (UTC)</timestamp>"),
assistant_tool_call("Second", &[("edit", serde_json::json!({"path": "b.rs"}))]),
];
let ledger = build_ledger(&history);
let groups = derive_trace_groups(current_turn_entries(&ledger));
assert_eq!(groups.len(), 1, "only the post-turn group is shown");
assert_eq!(groups[0].narration, "Second");
}
#[test]
fn no_user_turn_shows_whole_history() {
let history = vec![
assistant_tool_call("A", &[("read", serde_json::json!({"path": "a.rs"}))]),
assistant_tool_call("B", &[("edit", serde_json::json!({"path": "b.rs"}))]),
];
let ledger = build_ledger(&history);
let groups = derive_trace_groups(current_turn_entries(&ledger));
assert_eq!(groups.len(), 2, "no boundary → whole ledger is current");
assert_eq!(groups[0].narration, "A");
assert_eq!(groups[1].narration, "B");
}
#[test]
fn current_turn_without_tool_rounds_is_empty() {
let history = vec![
assistant_tool_call("A", &[("read", serde_json::json!({"path": "a.rs"}))]),
ChatMessage::user("stop now\n\n<timestamp>2026-01-01 00:00:00 (UTC)</timestamp>"),
];
let ledger = build_ledger(&history);
let groups = derive_trace_groups(current_turn_entries(&ledger));
assert!(
groups.is_empty(),
"no tool rounds after the last user turn → empty (clanking placeholder)"
);
}
#[test]
fn injected_image_and_empty_user_do_not_start_a_turn() {
let history = vec![
assistant_tool_call("", &[("read", serde_json::json!({"path": "a.rs"}))]),
ChatMessage::user(crate::util::injected_image_user_message(
"data:image/png;base64,xxx",
)),
ChatMessage::user(""),
assistant_tool_call("", &[("edit", serde_json::json!({"path": "b.rs"}))]),
];
let ledger = build_ledger(&history);
let groups = derive_trace_groups(current_turn_entries(&ledger));
assert_eq!(
groups.len(),
1,
"injected + empty user messages are not boundaries"
);
assert_eq!(groups[0].rounds.len(), 2);
}
#[test]
fn rounds_carry_results_for_in_flight_distinction() {
let calls_json = serde_json::json!([
{"id": "call_a", "name": "read", "arguments": "{\"path\": \"a.rs\"}"},
{"id": "call_b", "name": "edit", "arguments": "{\"path\": \"b.rs\"}"},
]);
let body = serde_json::json!({ "content": null, "tool_calls": calls_json });
let history = vec![
ChatMessage::assistant(body.to_string()),
ChatMessage::tool_result("call_a", "contents of a.rs"),
];
let ledger = build_ledger(&history);
let groups = derive_trace_groups(&ledger);
assert_eq!(groups.len(), 1);
let round = &groups[0].rounds[0];
assert_eq!(round.len(), 2, "both calls preserved in the round");
let settled = round
.iter()
.find(|c| c.tool_call_id == "call_a")
.expect("call_a present");
assert_eq!(
settled.result.as_deref(),
Some("contents of a.rs"),
"matched tool result attaches to call_a"
);
let in_flight = round
.iter()
.find(|c| c.tool_call_id == "call_b")
.expect("call_b present");
assert_eq!(
in_flight.result, None,
"call_b has no result yet → in-flight"
);
}
#[test]
fn promoted_reasoning_is_group_label() {
let history = vec![
assistant_tool_call_reasoning(
"",
&[("read", serde_json::json!({"path": "a.rs"}))],
Some("Checking the file first"),
),
assistant_tool_call_reasoning(
"Now editing",
&[("edit", serde_json::json!({"path": "b.rs"}))],
None,
),
];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 2, "new narration starts a second group");
assert!(
groups[0].narration.is_empty(),
"silent round keeps empty narration"
);
assert!(
matches!(
group_label_text(&groups[0]),
GroupLabelText::Promoted(s) if s == "Checking the file first"
),
"silent round with reasoning labels as its promoted reasoning"
);
assert_eq!(groups[1].narration, "Now editing");
}
#[test]
fn silent_round_without_reasoning_is_placeholder() {
let history = vec![assistant_tool_call(
"",
&[("read", serde_json::json!({"path": "a.rs"}))],
)];
let groups = derive_trace_groups(&build_ledger(&history));
assert_eq!(groups.len(), 1);
assert!(
matches!(group_label_text(&groups[0]), GroupLabelText::Placeholder),
"no narration and no reasoning → neutral placeholder"
);
}
#[test]
fn promoted_label_caps_at_limit() {
let long = "word ".repeat(80); let capped = promoted_reasoning_label(&long);
assert!(capped.ends_with('…'));
assert!(
capped.chars().count() <= MAX_PROMOTED_LABEL_CHARS + 1,
"cap adds at most the trailing ellipsis"
);
assert_eq!(promoted_reasoning_label("do it now"), "do it now");
assert_eq!(promoted_reasoning_label("line1\nline2"), "line1 line2");
}
#[test]
fn prune_expanded_drops_stale_keys() {
let mut expanded: HashSet<(String, u64)> =
HashSet::from([("a".to_string(), 0), ("b".to_string(), 2)]);
let agents = vec![agent_handle("a", "analyst", None, "ws1", None)];
prune_expanded(&mut expanded, &agents);
assert_eq!(expanded.len(), 1);
assert!(
expanded.contains(&("a".to_string(), 0)),
"live agent keeps its expanded key"
);
}
}