1use std::time::Instant;
5
6use tokio::sync::mpsc;
7
8use crate::event::{AgentEvent, AppEvent};
9
10use super::{App, ChatMessage, ConfirmState, ElicitationState, MessageRole, debug};
11
12impl App {
13 pub fn handle_event(&mut self, event: AppEvent) {
17 match event {
18 AppEvent::Key(key) => self.handle_key(key),
19 AppEvent::Tick => {
20 self.throbber_state.calc_next();
21 self.wave_tick = self.wave_tick.saturating_add(1);
22 self.tick_delights();
23 }
24 AppEvent::Resize(_, _) => {
25 self.sessions.current_mut().render_cache.clear();
26 }
27 AppEvent::Agent(agent_event) => self.handle_agent_event(agent_event),
28 AppEvent::Paste(text) => {
34 let effects =
35 crate::app::reducer::reduce(self, crate::app::action::Action::InsertText(text));
36 crate::app::reducer::run_effects(self, effects);
37 }
38 AppEvent::Mouse(m) => self.handle_mouse(m),
39 }
40 }
41
42 pub fn poll_agent_event(&mut self) -> impl Future<Output = Option<AgentEvent>> + use<'_> {
47 self.agent_event_rx.recv()
48 }
49
50 pub fn try_recv_agent_event(&mut self) -> Result<AgentEvent, mpsc::error::TryRecvError> {
60 self.agent_event_rx.try_recv()
61 }
62
63 #[allow(clippy::too_many_lines)] pub fn handle_agent_event(&mut self, event: AgentEvent) {
70 match event {
71 AgentEvent::Chunk(text) => {
72 self.sessions.current_mut().status_label = None;
73 self.last_progress_at = Instant::now();
75 if let Some(last) = self.sessions.current_mut().messages.last_mut()
76 && last.role == MessageRole::Assistant
77 && last.streaming
78 {
79 last.content.push_str(&text);
80 } else {
81 self.sessions
82 .current_mut()
83 .messages
84 .push(ChatMessage::new(MessageRole::Assistant, text).streaming());
85 self.trim_messages();
86 }
87 let completion_tokens = self.metrics.completion_tokens;
89 self.stream_rate.on_token_chunk(completion_tokens);
90 self.auto_scroll();
93 }
94 AgentEvent::FullMessage(text) => {
95 self.sessions.current_mut().status_label = None;
96 if !text.starts_with("[tool output") {
97 self.sessions
98 .current_mut()
99 .messages
100 .push(ChatMessage::new(MessageRole::Assistant, text));
101 self.trim_messages();
102 }
103 self.auto_scroll();
104 }
105 AgentEvent::Flush => {
106 if let Some(last) = self.sessions.current_mut().messages.last_mut()
107 && last.streaming
108 {
109 last.streaming = false;
110 let last_idx = self.sessions.current().messages.len().saturating_sub(1);
111 self.sessions
112 .current_mut()
113 .render_cache
114 .invalidate(last_idx);
115 }
116 }
117 AgentEvent::Typing => {
118 self.pending_count = self.pending_count.saturating_sub(1);
119 self.sessions.current_mut().status_label = Some("thinking...".to_owned());
120 self.last_progress_at = Instant::now();
122 self.stream_rate.on_turn_start();
124 }
125 AgentEvent::Status(text) => {
126 self.sessions.current_mut().status_label =
127 if text.is_empty() { None } else { Some(text) };
128 if self.sessions.current().status_label.is_some() {
130 self.last_progress_at = Instant::now();
131 }
132 self.auto_scroll();
133 }
134 AgentEvent::ToolStart {
135 tool_name,
136 command,
137 tool_call_id,
138 is_mcp,
139 } => {
140 self.sessions.current_mut().status_label = None;
141 self.sessions.current_mut().messages.push(
142 ChatMessage::new(MessageRole::Tool, format!("$ {command}\n"))
143 .streaming()
144 .with_tool(tool_name)
145 .with_tool_call_id(tool_call_id)
146 .with_is_mcp(is_mcp),
147 );
148 self.trim_messages();
149 self.auto_scroll();
150 }
151 AgentEvent::ToolOutputChunk {
152 chunk,
153 tool_call_id,
154 ..
155 } => {
156 self.last_progress_at = Instant::now();
157 let pos = if tool_call_id.is_empty() {
158 self.sessions
161 .current()
162 .messages
163 .iter()
164 .rposition(|m| m.role == MessageRole::Tool && m.streaming)
165 } else {
166 let found =
167 self.sessions.current().messages.iter().rposition(|m| {
168 m.tool_call_id.as_deref() == Some(tool_call_id.as_str())
169 });
170 if found.is_none() {
171 tracing::warn!(
172 %tool_call_id,
173 "ToolOutputChunk: no message with matching tool_call_id — dropping chunk"
174 );
175 }
176 found
177 };
178 if let Some(pos) = pos {
179 self.sessions.current_mut().messages[pos]
180 .content
181 .push_str(&chunk);
182 self.sessions.current_mut().render_cache.invalidate(pos);
183 }
184 self.auto_scroll();
185 }
186 AgentEvent::ToolOutput {
187 tool_name,
188 output,
189 diff,
190 filter_stats,
191 kept_lines,
192 success,
193 tool_call_id,
194 ..
195 } => {
196 self.handle_tool_output_event(
197 tool_name,
198 output,
199 diff,
200 filter_stats,
201 kept_lines,
202 success,
203 tool_call_id,
204 );
205 }
206 AgentEvent::ConfirmRequest {
207 prompt,
208 response_tx,
209 } => {
210 self.confirm_state = Some(ConfirmState {
211 prompt,
212 response_tx: Some(response_tx),
213 });
214 }
215 AgentEvent::ElicitationRequest {
216 request,
217 response_tx,
218 } => {
219 let dialog = crate::widgets::elicitation::ElicitationDialogState::new(request);
220 self.elicitation_state = Some(ElicitationState {
221 dialog,
222 response_tx: Some(response_tx),
223 });
224 }
225 AgentEvent::QueueCount(count) => {
226 self.queued_count = count;
227 self.pending_count = count;
228 }
229 AgentEvent::DiffReady { diff, tool_call_id } => {
230 self.handle_diff_ready(diff, &tool_call_id);
231 }
232 AgentEvent::CommandResult { output, .. } => {
233 self.command_palette = None;
234 self.sessions
235 .current_mut()
236 .messages
237 .push(ChatMessage::new(MessageRole::System, output));
238 self.trim_messages();
239 self.auto_scroll();
240 }
241 AgentEvent::SetCancelSignal(signal) => {
242 self.set_cancel_signal(signal);
243 }
244 AgentEvent::SetMetricsRx(rx) => {
245 self.set_metrics_rx(rx);
246 }
247 AgentEvent::SetTaskSupervisor(supervisor) => {
248 self.set_task_supervisor(supervisor);
249 }
250 AgentEvent::ForegroundSubagentStarted { id, name } => {
251 self.sessions.current_mut().status_label =
252 Some(format!("Sub-agent '{name}' running..."));
253 self.last_progress_at = Instant::now();
255 self.set_view_target(super::AgentViewTarget::SubAgent { id, name });
256 }
257 AgentEvent::ForegroundSubagentCompleted { id, name, success } => {
258 if self.sessions.current().view_target.subagent_id() == Some(id.as_str()) {
261 self.set_view_target(super::AgentViewTarget::Main);
262 }
263 let label = if success {
264 format!("Sub-agent '{name}' completed")
265 } else {
266 format!("Sub-agent '{name}' failed")
267 };
268 self.sessions.current_mut().status_label = Some(label.clone());
269 self.last_progress_at = Instant::now();
271 self.sessions
272 .current_mut()
273 .messages
274 .push(ChatMessage::new(MessageRole::System, label));
275 self.trim_messages();
276 self.auto_scroll();
277 }
278 AgentEvent::BackgroundSubagentCompleted { id, name, success } => {
279 if self.sessions.current().view_target.subagent_id() == Some(id.as_str()) {
284 let label = if success {
285 format!("Sub-agent '{name}' completed")
286 } else {
287 format!("Sub-agent '{name}' failed")
288 };
289 self.set_view_target(super::AgentViewTarget::Main);
290 self.sessions.current_mut().status_label = Some(label.clone());
291 self.last_progress_at = Instant::now();
293 self.sessions
294 .current_mut()
295 .messages
296 .push(ChatMessage::new(MessageRole::System, label));
297 self.trim_messages();
298 self.auto_scroll();
299 }
300 }
301 AgentEvent::ContextEstimate(tokens) => {
302 self.context_token_estimate = tokens;
303 }
304 AgentEvent::FleetSnapshot(snapshot) => {
305 self.fleet_snapshot = snapshot;
306 }
307 AgentEvent::DurableSnapshot(snapshot) => {
308 self.durable_snapshot = snapshot;
309 }
310 AgentEvent::ResumeBanner(text) => {
311 self.resume_banner = Some(text);
312 }
313 AgentEvent::HistoryBackfill(entries) => {
314 self.backfill_history_display_only(&entries);
315 }
316 AgentEvent::SkillCatalog(items) => {
317 self.skill_catalog = Some(items);
318 if self.mention_picker.is_some() {
319 let query = crate::app::reducer::mention_picker_query(self);
320 let skills = self.skill_catalog.clone();
321 if let Some(picker) = self.mention_picker.as_mut() {
322 picker.catalog.skills = skills;
323 picker.refilter(&query);
324 }
325 }
326 }
327 }
328 }
329
330 fn handle_diff_ready(&mut self, diff: zeph_core::DiffData, tool_call_id: &str) {
331 if let Some(msg) = self
332 .sessions
333 .current_mut()
334 .messages
335 .iter_mut()
336 .rev()
337 .find(|m| {
338 m.role == MessageRole::Tool && m.tool_call_id.as_deref() == Some(tool_call_id)
339 })
340 {
341 msg.diff_data = Some(diff);
342 }
343 }
344
345 #[allow(clippy::too_many_arguments)]
346 fn handle_tool_output_event(
347 &mut self,
348 tool_name: zeph_common::ToolName,
349 output: String,
350 diff: Option<zeph_core::DiffData>,
351 filter_stats: Option<String>,
352 kept_lines: Option<Vec<usize>>,
353 success: bool,
354 tool_call_id: String,
355 ) {
356 debug!(
357 %tool_name,
358 has_diff = diff.is_some(),
359 has_filter_stats = filter_stats.is_some(),
360 output_len = output.len(),
361 "TUI ToolOutput event received"
362 );
363 let pos = if tool_call_id.is_empty() {
366 self.sessions
367 .current()
368 .messages
369 .iter()
370 .rposition(|m| m.role == MessageRole::Tool && m.streaming)
371 } else {
372 let found = self
373 .sessions
374 .current()
375 .messages
376 .iter()
377 .rposition(|m| {
378 m.role == MessageRole::Tool
379 && m.streaming
380 && m.tool_call_id.as_deref() == Some(tool_call_id.as_str())
381 })
382 .or_else(|| {
383 self.sessions
384 .current()
385 .messages
386 .iter()
387 .rposition(|m| m.role == MessageRole::Tool && m.streaming)
388 });
389 if found.is_none() {
390 tracing::warn!(
391 tool_call_id = %tool_call_id,
392 "ToolOutput: no streaming Tool message found — skipping finalization"
393 );
394 }
395 found
396 };
397
398 if let Some(pos) = pos {
399 debug!("finalizing existing streaming Tool message");
405 let header_end = self.sessions.current_mut().messages[pos]
406 .content
407 .find('\n')
408 .map_or(0, |i| i + 1);
409 self.sessions.current_mut().messages[pos]
410 .content
411 .truncate(header_end);
412 self.sessions.current_mut().messages[pos]
413 .content
414 .push_str(&output);
415 self.sessions.current_mut().messages[pos].streaming = false;
416 self.sessions.current_mut().messages[pos].diff_data = diff;
417 self.sessions.current_mut().messages[pos].filter_stats = filter_stats;
418 self.sessions.current_mut().messages[pos].kept_lines = kept_lines;
419 self.sessions.current_mut().messages[pos].success = Some(success);
420 self.sessions.current_mut().render_cache.invalidate(pos);
421 } else if diff.is_some() || filter_stats.is_some() || kept_lines.is_some() {
422 debug!("creating new Tool message with diff (no prior ToolStart)");
424 let mut msg = ChatMessage::new(MessageRole::Tool, output)
425 .with_tool(tool_name)
426 .with_tool_call_id(tool_call_id);
427 msg.diff_data = diff;
428 msg.filter_stats = filter_stats;
429 msg.kept_lines = kept_lines;
430 msg.success = Some(success);
431 self.sessions.current_mut().messages.push(msg);
432 self.trim_messages();
433 } else if let Some(msg) = self
434 .sessions
435 .current_mut()
436 .messages
437 .iter_mut()
438 .rev()
439 .find(|m| m.role == MessageRole::Tool)
440 {
441 msg.filter_stats = filter_stats;
442 }
443 self.auto_scroll();
444 self.maybe_flash_completed_group();
445 }
446
447 fn maybe_flash_completed_group(&mut self) {
454 if self.motion == zeph_config::Motion::Off || !self.delights.completion_flash {
455 return;
456 }
457
458 let messages = &self.sessions.current().messages;
459
460 let Some(last_tool_pos) = messages.iter().rposition(|m| m.role == MessageRole::Tool) else {
462 return;
463 };
464
465 let mut start_idx = last_tool_pos;
467 while start_idx > 0 && messages[start_idx - 1].role == MessageRole::Tool {
468 start_idx -= 1;
469 }
470
471 let all_done = messages[start_idx..=last_tool_pos]
473 .iter()
474 .all(|m| !m.streaming && m.success.is_some());
475 if !all_done {
476 return;
477 }
478
479 if self.sessions.current().flashed_groups.contains(&start_idx) {
481 return;
482 }
483
484 let group_size = last_tool_pos - start_idx + 1;
485 let now = self.anim_tick();
486 self.sessions.current_mut().flashed_groups.insert(start_idx);
487 self.sessions.current_mut().flash.insert(start_idx, now);
488
489 if self.delights.toasts {
491 let text = if group_size == 1 {
492 "Tool done".to_owned()
493 } else {
494 format!("{group_size} tools done")
495 };
496 self.push_toast(text, crate::delights::ToastKind::Success);
497 }
498 }
499
500 #[must_use]
501 pub fn confirm_state(&self) -> Option<&ConfirmState> {
502 self.confirm_state.as_ref()
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use tokio::sync::mpsc;
509
510 use crate::app::{AgentViewTarget, App};
511 use crate::event::AgentEvent;
512 use crate::types::{ChatMessage, MessageRole};
513 use zeph_core::DiffData;
514
515 fn make_app() -> App {
516 let (user_tx, agent_rx) = {
517 let (utx, _urx) = mpsc::channel(8);
518 let (_atx, arx) = mpsc::channel(8);
519 (utx, arx)
520 };
521 let mut app = App::new(user_tx, agent_rx);
522 app.sessions.current_mut().messages.clear();
523 app
524 }
525
526 fn push_tool_msg(app: &mut App, id: &str) {
528 let msg = ChatMessage::new(MessageRole::Tool, format!("$ cmd_{id}\n"))
529 .streaming()
530 .with_tool_call_id(id.to_owned());
531 app.sessions.current_mut().messages.push(msg);
532 }
533
534 fn tool_msg(id: &str) -> ChatMessage {
535 ChatMessage::new(MessageRole::Tool, "$ cmd\n".to_owned())
536 .with_tool("bash".into())
537 .with_tool_call_id(id.to_owned())
538 }
539
540 fn diff() -> DiffData {
541 DiffData {
542 file_path: "a.rs".into(),
543 old_content: "old".into(),
544 new_content: "new".into(),
545 }
546 }
547
548 #[test]
549 fn tool_output_chunk_routes_by_id_out_of_order() {
550 let mut app = make_app();
551 push_tool_msg(&mut app, "a");
552 push_tool_msg(&mut app, "b");
553 push_tool_msg(&mut app, "c");
554
555 for (id, chunk) in [
557 ("c", "c1"),
558 ("a", "a1"),
559 ("b", "b1"),
560 ("a", "a2"),
561 ("c", "c2"),
562 ] {
563 app.handle_agent_event(AgentEvent::ToolOutputChunk {
564 tool_name: "bash".into(),
565 command: String::new(),
566 chunk: chunk.to_owned(),
567 tool_call_id: id.to_owned(),
568 });
569 }
570
571 let msgs = app.messages();
572 assert_eq!(msgs.len(), 3);
573 assert_eq!(msgs[0].content, "$ cmd_a\na1a2");
575 assert_eq!(msgs[1].content, "$ cmd_b\nb1");
576 assert_eq!(msgs[2].content, "$ cmd_c\nc1c2");
577 }
578
579 #[test]
580 fn tool_output_chunk_with_unknown_id_is_dropped() {
581 let mut app = make_app();
582 push_tool_msg(&mut app, "known");
583
584 app.handle_agent_event(AgentEvent::ToolOutputChunk {
586 tool_name: "bash".into(),
587 command: String::new(),
588 chunk: "should-not-appear".to_owned(),
589 tool_call_id: "unknown-xyz".to_owned(),
590 });
591
592 assert_eq!(app.messages().len(), 1);
594 assert_eq!(app.messages()[0].content, "$ cmd_known\n");
595 }
596
597 #[test]
598 fn tool_output_finalizes_correct_message_by_id() {
599 let mut app = make_app();
600 push_tool_msg(&mut app, "t1");
601 push_tool_msg(&mut app, "t2");
602
603 app.handle_agent_event(AgentEvent::ToolOutput {
605 tool_name: "bash".into(),
606 command: "$ cmd_t1\n".into(),
607 output: "final-output-t1".to_owned(),
608 success: true,
609 diff: None,
610 filter_stats: None,
611 kept_lines: None,
612 tool_call_id: "t1".to_owned(),
613 });
614
615 let msgs = app.messages();
616 assert_eq!(msgs.len(), 2);
617 assert!(!msgs[0].streaming);
619 assert!(msgs[0].content.contains("final-output-t1"));
620 assert!(msgs[1].streaming);
622 assert_eq!(msgs[1].content, "$ cmd_t2\n");
623 }
624
625 #[test]
626 fn diff_ready_attaches_to_matching_id() {
627 let mut app = make_app();
628 app.sessions.current_mut().messages.push(tool_msg("call-1"));
629 app.sessions.current_mut().messages.push(tool_msg("call-2"));
630
631 app.handle_agent_event(AgentEvent::DiffReady {
632 diff: diff(),
633 tool_call_id: "call-2".into(),
634 });
635
636 assert!(app.sessions.current().messages[0].diff_data.is_none());
637 assert!(app.sessions.current().messages[1].diff_data.is_some());
638 }
639
640 #[test]
641 fn diff_ready_mismatched_id_does_not_attach() {
642 let mut app = make_app();
643 app.sessions.current_mut().messages.push(tool_msg("call-1"));
644
645 app.handle_agent_event(AgentEvent::DiffReady {
646 diff: diff(),
647 tool_call_id: "call-99".into(),
648 });
649
650 assert!(app.sessions.current().messages[0].diff_data.is_none());
651 }
652
653 #[test]
654 fn diff_ready_empty_id_does_not_attach() {
655 let mut app = make_app();
656 app.sessions.current_mut().messages.push(tool_msg("call-1"));
657
658 app.handle_agent_event(AgentEvent::DiffReady {
659 diff: diff(),
660 tool_call_id: String::new(),
661 });
662
663 assert!(app.sessions.current().messages[0].diff_data.is_none());
664 }
665
666 #[test]
667 fn diff_ready_two_concurrent_attach_to_correct_messages() {
668 let mut app = make_app();
669 app.sessions.current_mut().messages.push(tool_msg("call-A"));
670 app.sessions.current_mut().messages.push(tool_msg("call-B"));
671 app.sessions.current_mut().messages.push(tool_msg("call-C"));
672
673 let diff_a = DiffData {
674 file_path: "a.rs".into(),
675 old_content: "old_a".into(),
676 new_content: "new_a".into(),
677 };
678 let diff_b = DiffData {
679 file_path: "b.rs".into(),
680 old_content: "old_b".into(),
681 new_content: "new_b".into(),
682 };
683
684 app.handle_agent_event(AgentEvent::DiffReady {
686 diff: diff_b,
687 tool_call_id: "call-B".into(),
688 });
689 app.handle_agent_event(AgentEvent::DiffReady {
690 diff: diff_a,
691 tool_call_id: "call-A".into(),
692 });
693
694 let msgs = &app.sessions.current().messages;
695 assert_eq!(
696 msgs[0].diff_data.as_ref().map(|d| d.file_path.as_str()),
697 Some("a.rs"),
698 "call-A diff must attach to message 0"
699 );
700 assert_eq!(
701 msgs[1].diff_data.as_ref().map(|d| d.file_path.as_str()),
702 Some("b.rs"),
703 "call-B diff must attach to message 1"
704 );
705 assert!(
706 msgs[2].diff_data.is_none(),
707 "call-C must remain without diff"
708 );
709 }
710
711 #[test]
712 fn foreground_subagent_started_switches_view_to_subagent() {
713 let mut app = make_app();
714 assert!(app.sessions.current().view_target.is_main());
715
716 app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
717 id: "sa-001".into(),
718 name: "planner".into(),
719 });
720
721 assert_eq!(
722 app.sessions.current().view_target.subagent_id(),
723 Some("sa-001"),
724 "view must switch to the started subagent"
725 );
726 assert_eq!(
727 app.sessions.current().status_label.as_deref(),
728 Some("Sub-agent 'planner' running...")
729 );
730 }
731
732 #[test]
733 fn foreground_subagent_completed_switches_back_when_viewing_subagent() {
734 let mut app = make_app();
735
736 app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
737 id: "sa-002".into(),
738 name: "coder".into(),
739 });
740 assert_eq!(
741 app.sessions.current().view_target.subagent_id(),
742 Some("sa-002")
743 );
744
745 app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
746 id: "sa-002".into(),
747 name: "coder".into(),
748 success: true,
749 });
750
751 assert!(
752 app.sessions.current().view_target.is_main(),
753 "view must return to Main after completion"
754 );
755 assert_eq!(
756 app.sessions.current().status_label.as_deref(),
757 Some("Sub-agent 'coder' completed")
758 );
759 let system_msg = app
760 .sessions
761 .current()
762 .messages
763 .iter()
764 .find(|m| m.role == MessageRole::System);
765 assert!(
766 system_msg.is_some(),
767 "completion system message must be pushed"
768 );
769 }
770
771 #[test]
772 fn foreground_subagent_completed_respects_manual_navigation() {
773 let mut app = make_app();
774
775 app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
776 id: "sa-003".into(),
777 name: "researcher".into(),
778 });
779
780 app.set_view_target(AgentViewTarget::SubAgent {
782 id: "sa-other".into(),
783 name: "other".into(),
784 });
785
786 app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
787 id: "sa-003".into(),
788 name: "researcher".into(),
789 success: false,
790 });
791
792 assert_eq!(
794 app.sessions.current().view_target.subagent_id(),
795 Some("sa-other"),
796 "user's manual navigation must be preserved"
797 );
798 }
799
800 #[test]
801 fn foreground_subagent_failed_shows_failed_label() {
802 let mut app = make_app();
803
804 app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
805 id: "sa-004".into(),
806 name: "builder".into(),
807 });
808 app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
809 id: "sa-004".into(),
810 name: "builder".into(),
811 success: false,
812 });
813
814 assert_eq!(
815 app.sessions.current().status_label.as_deref(),
816 Some("Sub-agent 'builder' failed")
817 );
818 }
819
820 #[test]
823 fn foreground_subagent_fast_complete_ends_on_main() {
824 let mut app = make_app();
825
826 app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
827 id: "sa-fast".into(),
828 name: "quick".into(),
829 });
830 app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
831 id: "sa-fast".into(),
832 name: "quick".into(),
833 success: true,
834 });
835
836 assert!(app.sessions.current().view_target.is_main());
837 }
838
839 #[test]
842 fn background_subagent_completed_switches_back_when_viewing_subagent() {
843 let mut app = make_app();
851 app.set_view_target(AgentViewTarget::SubAgent {
852 id: "sa-bg-1".into(),
853 name: "researcher".into(),
854 });
855 assert_eq!(
856 app.sessions.current().view_target.subagent_id(),
857 Some("sa-bg-1")
858 );
859
860 app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
861 id: "sa-bg-1".into(),
862 name: "researcher".into(),
863 success: true,
864 });
865
866 assert!(
867 app.sessions.current().view_target.is_main(),
868 "view must return to Main once the manually-viewed background subagent completes"
869 );
870 assert_eq!(
871 app.sessions.current().status_label.as_deref(),
872 Some("Sub-agent 'researcher' completed")
873 );
874 let system_msg = app
875 .sessions
876 .current()
877 .messages
878 .iter()
879 .find(|m| m.role == MessageRole::System);
880 assert!(
881 system_msg.is_some(),
882 "a terminal completion marker must be pushed into the chat"
883 );
884 }
885
886 #[test]
887 fn background_subagent_completed_shows_failed_label_when_viewing_subagent() {
888 let mut app = make_app();
889 app.set_view_target(AgentViewTarget::SubAgent {
890 id: "sa-bg-2".into(),
891 name: "worker".into(),
892 });
893
894 app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
895 id: "sa-bg-2".into(),
896 name: "worker".into(),
897 success: false,
898 });
899
900 assert!(app.sessions.current().view_target.is_main());
901 assert_eq!(
902 app.sessions.current().status_label.as_deref(),
903 Some("Sub-agent 'worker' failed")
904 );
905 }
906
907 #[test]
908 fn background_subagent_completed_ignored_when_not_viewing_that_subagent() {
909 let mut app = make_app();
914 assert!(app.sessions.current().view_target.is_main());
915 let messages_before = app.sessions.current().messages.len();
916
917 app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
918 id: "sa-bg-elsewhere".into(),
919 name: "other".into(),
920 success: true,
921 });
922
923 assert!(app.sessions.current().view_target.is_main());
924 assert_eq!(
925 app.sessions.current().messages.len(),
926 messages_before,
927 "no message should be pushed for a background subagent that isn't being viewed"
928 );
929 assert_eq!(app.sessions.current().status_label, None);
930 }
931
932 #[test]
933 fn background_subagent_completed_respects_different_subagent_being_viewed() {
934 let mut app = make_app();
935 app.set_view_target(AgentViewTarget::SubAgent {
936 id: "sa-other".into(),
937 name: "other".into(),
938 });
939
940 app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
941 id: "sa-bg-3".into(),
942 name: "worker".into(),
943 success: true,
944 });
945
946 assert_eq!(
947 app.sessions.current().view_target.subagent_id(),
948 Some("sa-other"),
949 "viewing a different subagent must not be disturbed by an unrelated completion"
950 );
951 }
952
953 #[test]
954 fn context_estimate_updates_cached_value() {
955 let mut app = make_app();
956 assert_eq!(
957 app.context_token_estimate(),
958 0,
959 "initial estimate must be 0"
960 );
961
962 app.handle_agent_event(AgentEvent::ContextEstimate(14_200));
963 assert_eq!(app.context_token_estimate(), 14_200);
964
965 app.handle_agent_event(AgentEvent::ContextEstimate(512));
966 assert_eq!(
967 app.context_token_estimate(),
968 512,
969 "estimate must update on each event"
970 );
971 }
972}