use crate::call_registry::NonAgentCallHandle;
use crate::gui::theme;
use crate::gui::widgets;
use crate::registry::{AgentHandle, ParentKey};
use chrono::{DateTime, Utc};
use iced::widget::{
Column, Row, Space, button, column, container, row, scrollable, stack, text, tooltip,
};
use iced::{Alignment, Element, Length};
use iced_fonts::lucide;
use super::widget_helpers;
use super::{Message, WorkspaceInfo};
const MAX_GROUP_LABEL_CHARS: usize = 80;
const MAX_TOOL_VALUE_CHARS: usize = 20;
const MAX_TOOL_PAIRS_LINE_CHARS: usize = 80;
const MAX_TOOL_TOOLTIP_WIDTH: f32 = 560.0;
#[derive(Debug, Clone)]
#[expect(clippy::enum_variant_names)] pub(crate) enum RunningMessage {
CancelRequest(String),
CancelConfirmed,
CancelDismissed,
CancelFinished(Result<(), String>),
}
pub(crate) fn view(
workspaces: &std::collections::HashMap<String, WorkspaceInfo>,
pending_cancel: Option<&str>,
) -> Element<'static, Message> {
let agents = crate::registry::AGENT_REGISTRY.list();
let calls = crate::call_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.",
)
} else {
let mut content = Column::new().spacing(20);
for section in §ions {
content = content.push(render_section(section));
}
scrollable(container(content).width(Length::Fill).padding([0, 4]))
.height(Length::Fill)
.direction(theme::vertical_scrollbar())
.style(theme::scrollbar_style)
.into()
};
let page: Element<'_, RunningMessage> = container(body)
.width(Length::Fill)
.height(Length::Fill)
.padding(24)
.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([widget_helpers::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, WorkspaceInfo>,
) -> 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) -> Element<'static, RunningMessage> {
let mut groups = Column::new().spacing(10);
for group in §ion.groups {
groups = groups.push(render_group(group));
}
column![
text(section.label.clone())
.size(18)
.color(theme::TEXT_PRIMARY),
groups,
]
.spacing(10)
.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| crate::util::truncate(l, MAX_GROUP_LABEL_CHARS),
),
None,
),
GroupKind::Research => (
group.label.as_deref().map_or_else(
|| "Research run".to_string(),
|l| crate::util::truncate(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) -> Element<'static, RunningMessage> {
let (title, secondary) = group_title(group);
let mut header_parts: Vec<Element<'_, RunningMessage>> =
vec![text(title).size(15).color(theme::ACCENT).into()];
if let Some(secondary) = secondary {
header_parts.push(text(secondary).size(11).color(theme::TEXT_MUTED).into());
}
if group.run_lifetime {
header_parts.push(text("run active").size(11).color(theme::ACCENT).into());
}
if group.kind == GroupKind::Research {
header_parts.push(Space::new().width(Length::Fill).into());
header_parts.push(
button(text("Cancel run").size(11))
.style(theme::button_danger)
.on_press(RunningMessage::CancelRequest(group.key.clone()))
.into(),
);
}
let mut items = Column::new().spacing(6);
for item in &group.items {
match item {
DisplayItem::Agent(card) => {
items = items.push(render_agent_card(card));
}
DisplayItem::Call(call) => {
items = items.push(render_call_row(call));
}
}
}
column![
Row::with_children(header_parts)
.spacing(8)
.align_y(Alignment::Center),
container(items)
.width(Length::Fill)
.padding(10)
.style(theme::elevated_card_style),
]
.spacing(6)
.into()
}
fn cancel_confirm_dialog(run_key: &str) -> Element<'static, RunningMessage> {
let _ = run_key;
let dialog = container(
column![
text("Cancel this research run?")
.size(16)
.color(theme::TEXT_PRIMARY)
.font(theme::FONT_BOLD),
Space::new().height(12),
text("Confirming will:")
.size(13)
.color(theme::TEXT_SECONDARY),
Space::new().height(8),
text(
"• 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, no \
cleanup agent;\n\
• delete the run's temporary folder and archived result;\n\
• remove the run permanently — nothing is delivered to the \
Manager, and it can never resume.",
)
.size(13)
.color(theme::TEXT_SECONDARY),
Space::new().height(16),
row![
Space::new().width(Length::Fill),
button(text("Keep run").size(13))
.style(theme::button_secondary)
.on_press(RunningMessage::CancelDismissed),
Space::new().width(8),
button(text("Cancel run").size(13))
.style(theme::button_danger)
.on_press(RunningMessage::CancelConfirmed),
]
.align_y(Alignment::Center),
]
.width(Length::Fill),
)
.width(Length::Fixed(480.0))
.padding(24)
.style(theme::dialog_container_style);
widget_helpers::modal_backdrop(dialog, 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, WorkspaceInfo>,
) -> 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) -> 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(20).color(fg);
let elapsed = format_elapsed(h.started_at);
let mut fill = Column::new().spacing(4).align_x(Alignment::Start);
for tool in &h.current_tools {
fill = fill.push(render_tool_block(tool));
}
if h.current_tools.is_empty() {
if let Some(activity) = &h.activity {
fill = fill.push(text(activity.clone()).size(11).color(theme::ACCENT));
} else if let Some(last) = &h.last_tool {
fill = fill.push(render_tool_block(last));
}
}
let mut first_row = row![icon, fill.width(Length::Fill)]
.spacing(8)
.align_y(Alignment::Center);
if let Some(len) = h.session_tokens {
first_row = first_row.push(
text(theme::format_compact_tokens(len))
.size(11)
.color(theme::TEXT_SECONDARY),
);
}
first_row = first_row.push(text(elapsed).size(11).color(theme::TEXT_SECONDARY));
container(first_row)
.width(Length::Fill)
.padding(8)
.style(theme::surface_card_style)
.into()
}
fn render_tool_block(tool: &crate::registry::RunningTool) -> Element<'static, RunningMessage> {
let mut block = Column::new().spacing(2).align_x(Alignment::Start).push(
text(tool.name.clone())
.size(11)
.font(theme::FONT_BOLD)
.color(theme::TEXT_PRIMARY),
);
if !tool.args.is_empty() {
block = block.push(
text(render_tool_pairs_line(&tool.args))
.size(11)
.color(theme::TEXT_SECONDARY),
);
}
tooltip(block, render_tool_tooltip(tool), tooltip::Position::Top)
.gap(4)
.style(theme::tooltip_style)
.into()
}
fn render_tool_pairs_line(pairs: &[(String, String)]) -> String {
let rendered: Vec<String> = pairs
.iter()
.map(|(k, v)| format!("{k}: {}", value_display(v)))
.collect();
let joined = rendered.join(", ");
if joined.chars().count() <= MAX_TOOL_PAIRS_LINE_CHARS {
return joined;
}
let mut out = String::new();
for pair in &rendered {
let sep = if out.is_empty() { "" } else { ", " };
let candidate_len = out.chars().count() + sep.chars().count() + pair.chars().count();
if candidate_len + 1 > MAX_TOOL_PAIRS_LINE_CHARS {
break;
}
out.push_str(sep);
out.push_str(pair);
}
if out.is_empty() {
crate::util::truncate(&rendered[0], MAX_TOOL_PAIRS_LINE_CHARS)
} else {
format!("{out}…")
}
}
fn value_display(value: &str) -> String {
let single_line: String = value
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
crate::util::truncate(&single_line, MAX_TOOL_VALUE_CHARS)
}
fn render_tool_tooltip(tool: &crate::registry::RunningTool) -> Element<'static, RunningMessage> {
let mut pairs = tool.args.clone();
pairs.sort_by_key(|(_, v)| v.chars().count());
let mut content = Column::new().spacing(2).push(
text(tool.name.clone())
.size(11)
.font(theme::FONT_BOLD)
.color(theme::TEXT_PRIMARY),
);
for (k, v) in &pairs {
content = content.push(
text(format!("{k}: {v}"))
.size(11)
.font(super::JETBRAINS_MONO)
.color(theme::TEXT_SECONDARY)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
}
container(content).max_width(MAX_TOOL_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::call_registry::call_kind_label(h.kind);
row![
lucide::zap::<iced::Theme, iced::Renderer>()
.size(16)
.color(theme::ACCENT),
text(purpose).size(12).color(theme::TEXT_SECONDARY),
Space::new().width(Length::Fill),
text(elapsed).size(11).color(theme::TEXT_SECONDARY),
]
.spacing(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::registry::AgentHandle;
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(),
current_tools: Vec::new(),
last_tool: None,
activity: None,
session_tokens: 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(),
WorkspaceInfo::test_new("/ws/ws2".to_string(), false, false),
);
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}"
);
}
}
}