1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::sync::Arc;
12
13use crate::event::CompositeSink;
14use crate::session::{SessionPersistenceSink, SessionWriter};
15use crate::tools::PermissionHook;
16use crate::{AgentEvent, AgentRuntime, EventSink};
17use async_trait::async_trait;
18use tokio::sync::mpsc;
19use tokio::task::JoinHandle;
20
21use crate::tui::bash::{build_bash_registry, resolve_workspace_root, run_bash_command};
22#[cfg(feature = "weixin")]
23use crate::tui::events::WeixinBackendRequest;
24use crate::tui::events::{PermissionRequest, UiEvent, UserAction};
25use crate::tui::runtime_builder::{build_runtime, RuntimeBuild};
26
27enum Either<L, R> {
29 Left(L),
30 #[allow(dead_code)]
31 Right(R),
32}
33
34pub struct Backend {
36 pub action_tx: mpsc::UnboundedSender<UserAction>,
37 pub event_rx: mpsc::UnboundedReceiver<UiEvent>,
38 pub cancel_flag: Arc<AtomicBool>,
41 pub perm_rx: mpsc::UnboundedReceiver<PermissionRequest>,
45 pub permission_enabled: Arc<AtomicBool>,
48 #[cfg(feature = "weixin")]
51 pub weixin_tx: mpsc::UnboundedSender<WeixinBackendRequest>,
52 _worker: JoinHandle<()>,
53}
54
55impl Backend {
56 pub fn spawn() -> Self {
57 Self::spawn_with_state(build_runtime())
58 }
59
60 pub fn spawn_with_runtime(rt: AgentRuntime) -> Self {
61 Self::spawn_with_state(RuntimeBuild::Ready(Some(Box::new(rt))))
62 }
63
64 fn spawn_with_state(state: RuntimeBuild) -> Self {
65 let (action_tx, action_rx) = mpsc::unbounded_channel::<UserAction>();
66 let (event_tx, event_rx) = mpsc::unbounded_channel::<UiEvent>();
67 let (perm_tx, perm_rx) = mpsc::unbounded_channel::<PermissionRequest>();
68 let cancel_flag = Arc::new(AtomicBool::new(false));
69 let permission_enabled = Arc::new(AtomicBool::new(false));
70 #[cfg(feature = "weixin")]
71 let (weixin_tx, weixin_rx) = mpsc::unbounded_channel::<WeixinBackendRequest>();
72
73 let worker = tokio::spawn(worker_loop(
74 state,
75 action_rx,
76 event_tx,
77 perm_tx,
78 cancel_flag.clone(),
79 permission_enabled.clone(),
80 #[cfg(feature = "weixin")]
81 weixin_rx,
82 ));
83
84 Self {
85 action_tx,
86 event_rx,
87 perm_rx,
88 cancel_flag,
89 permission_enabled,
90 #[cfg(feature = "weixin")]
91 weixin_tx,
92 _worker: worker,
93 }
94 }
95}
96
97struct TuiEventSink {
98 tx: mpsc::UnboundedSender<UiEvent>,
99}
100
101#[async_trait]
102impl EventSink for TuiEventSink {
103 async fn emit(&self, event: AgentEvent) {
104 if let Some(ev) = map_agent_event(event) {
105 let _ = self.tx.send(ev);
106 }
107 }
108}
109
110pub fn map_agent_event(event: AgentEvent) -> Option<UiEvent> {
111 match event {
112 AgentEvent::PartialToken { text, .. } => Some(UiEvent::AssistantPartial { text }),
113 AgentEvent::Reasoning { text, .. } => Some(UiEvent::Reasoning { content: text }),
114 AgentEvent::AssistantText { text, .. } => Some(UiEvent::AssistantMessage { content: text }),
115 AgentEvent::ToolCall {
116 id,
117 name,
118 arguments,
119 ..
120 } => Some(UiEvent::ToolCall {
121 id,
122 name,
123 arguments,
124 }),
125 AgentEvent::ToolResult {
126 id, name, output, ..
127 } => {
128 let success = !output.starts_with("ERROR: ");
129 Some(UiEvent::ToolResult {
130 id,
131 name,
132 output,
133 success,
134 })
135 }
136 AgentEvent::Usage {
137 input_tokens,
138 output_tokens,
139 ..
140 } => Some(UiEvent::Usage {
141 input_tokens: input_tokens as u64,
142 output_tokens: output_tokens as u64,
143 }),
144 AgentEvent::Latency { llm_ms, .. } => Some(UiEvent::Latency { llm_ms }),
145 AgentEvent::Compacted { removed, kept, .. } => Some(UiEvent::Compacted { removed, kept }),
146 AgentEvent::TurnFinished { .. } => Some(UiEvent::TurnFinished),
147 AgentEvent::PlanProposed {
148 plan_text,
149 tool_calls,
150 } => Some(UiEvent::PlanProposed {
151 plan_text,
152 tool_calls,
153 }),
154 AgentEvent::PlanConfirmed => Some(UiEvent::PlanConfirmed),
155 AgentEvent::PlanRejected { reason } => Some(UiEvent::PlanRejected { reason }),
156 AgentEvent::PlanModeRequested { reason } => Some(UiEvent::PlanModeRequested { reason }),
158 AgentEvent::PlanModeApproved => Some(UiEvent::PlanModeApproved),
159 AgentEvent::PlanModeRejected { reason } => Some(UiEvent::PlanModeRejected { reason }),
160 AgentEvent::TodoUpdated { todos } => Some(UiEvent::TodoUpdated { todos }),
162
163 AgentEvent::GoalContinuing { reason, turns } => {
165 Some(UiEvent::GoalContinuing { reason, turns })
166 }
167 AgentEvent::GoalAchieved { condition, turns } => {
168 Some(UiEvent::GoalAchieved { condition, turns })
169 }
170 AgentEvent::GoalCleared => Some(UiEvent::GoalCleared),
171
172 AgentEvent::HookStarted {
174 hook_event,
175 hook_name,
176 status_message,
177 } => Some(UiEvent::HookStarted {
178 hook_event,
179 hook_name,
180 status_message,
181 }),
182 AgentEvent::HookProgress {
183 hook_event,
184 hook_name,
185 last_line,
186 } => Some(UiEvent::HookProgress {
187 hook_event,
188 hook_name,
189 last_line,
190 }),
191 AgentEvent::HookFinished {
192 hook_event,
193 hook_name,
194 outcome,
195 duration_ms,
196 } => Some(UiEvent::HookFinished {
197 hook_event,
198 hook_name,
199 outcome,
200 duration_ms,
201 }),
202 AgentEvent::HookSystemMessage { text } => Some(UiEvent::HookSystemMessage { text }),
203
204 _ => None,
205 }
206}
207
208struct TuiPermissionHook {
213 tx: mpsc::UnboundedSender<PermissionRequest>,
214 enabled: Arc<AtomicBool>,
215}
216
217#[async_trait]
218impl PermissionHook for TuiPermissionHook {
219 async fn check(
220 &self,
221 tool_name: &str,
222 args: &serde_json::Value,
223 ) -> crate::agent::PermissionDecision {
224 use crate::agent::PermissionDecision;
225 if !self.enabled.load(Ordering::Relaxed) {
226 return PermissionDecision::Allow;
227 }
228 let args_preview = crate::tools::args_preview_for_permission(args);
229 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<bool>();
230 let req = PermissionRequest {
231 tool_name: tool_name.to_string(),
232 args_preview,
233 reply: reply_tx,
234 };
235 if self.tx.send(req).is_err() {
236 return PermissionDecision::Allow; }
238 if reply_rx.await.unwrap_or(false) {
239 PermissionDecision::Allow
240 } else {
241 PermissionDecision::Deny("denied by user".to_string())
242 }
243 }
244}
245
246async fn worker_loop(
247 mut state: RuntimeBuild,
248 mut action_rx: mpsc::UnboundedReceiver<UserAction>,
249 event_tx: mpsc::UnboundedSender<UiEvent>,
250 perm_tx: mpsc::UnboundedSender<PermissionRequest>,
251 cancel_flag: Arc<AtomicBool>,
252 permission_enabled: Arc<AtomicBool>,
253 #[cfg(feature = "weixin")] mut weixin_rx: mpsc::UnboundedReceiver<WeixinBackendRequest>,
254) {
255 if let RuntimeBuild::Ready(rt_opt) = &mut state {
256 let rt = rt_opt.as_mut().unwrap();
257 rt.set_event_sink(Arc::new(TuiEventSink {
258 tx: event_tx.clone(),
259 }));
260 rt.set_permission_hook(Arc::new(TuiPermissionHook {
262 tx: perm_tx,
263 enabled: permission_enabled,
264 }));
265 }
266
267 let bash_registry = build_bash_registry(&resolve_workspace_root());
268 let bash_seq = AtomicU64::new(0);
269
270 let mut session_writer: Option<Arc<std::sync::Mutex<SessionWriter>>> = None;
276
277 loop {
278 let action = {
282 #[cfg(feature = "weixin")]
283 {
284 tokio::select! {
285 action = action_rx.recv() => {
286 match action {
287 Some(a) => Either::Left(a),
288 None => break,
289 }
290 }
291 wx_req = weixin_rx.recv() => {
292 match wx_req {
293 Some(r) => Either::Right(r),
294 None => continue,
295 }
296 }
297 }
298 }
299 #[cfg(not(feature = "weixin"))]
300 {
301 match action_rx.recv().await {
302 Some(a) => Either::<UserAction, ()>::Left(a),
303 None => break,
304 }
305 }
306 };
307
308 #[cfg(feature = "weixin")]
309 if let Either::Right(wx_req) = action {
310 let _ = event_tx.send(UiEvent::WeixinMessage {
312 user_id: wx_req.user_id.clone(),
313 text: wx_req.text.clone(),
314 });
315 if let RuntimeBuild::Ready(rt_opt) = &mut state {
317 let text = wx_req.text.clone();
318 let rt = rt_opt.take().unwrap();
319 let rt_shared = Arc::new(tokio::sync::Mutex::new(rt));
320 let rt_clone = rt_shared.clone();
321 let handle = tokio::task::spawn(async move {
322 let mut g = rt_clone.lock().await;
323 g.enqueue(text).await
324 });
325 let result = handle.await;
326 let recovered = Arc::try_unwrap(rt_shared)
327 .expect("single owner after weixin task")
328 .into_inner();
329 *rt_opt = Some(recovered);
330 let _ = event_tx.send(UiEvent::TurnFinished);
331 let final_text = match result {
332 Ok(Ok(Some(outcome))) => outcome.final_text,
333 _ => None,
334 };
335 let _ = wx_req.reply_tx.send(final_text);
336 } else {
337 let _ = wx_req.reply_tx.send(None);
338 }
339 continue;
340 }
341
342 let action = match action {
343 Either::Left(a) => a,
344 Either::Right(_) => unreachable!("weixin Right handled above"),
345 };
346
347 match action {
348 UserAction::Shutdown => {
349 if let Some(sw_arc) = session_writer.take() {
350 if let Ok(mut sw) = sw_arc.lock() {
351 let _ = sw.finish("success");
352 }
353 }
354 break;
355 }
356
357 UserAction::SendMessage(text) => {
358 if let RuntimeBuild::Ready(rt_opt) = &mut state {
359 let pre_turn_len = rt_opt.as_ref().unwrap().transcript().len();
360
361 if session_writer.is_none() {
365 let ws = resolve_workspace_root();
366 let goal: String = text.chars().take(200).collect();
367 let model = crate::tui::cost::detect_model_name();
368 if let Ok(sw) = SessionWriter::create(&ws, &goal, &model, "tui") {
369 let sw_arc = Arc::new(std::sync::Mutex::new(sw));
370 let composite = Arc::new(CompositeSink::new([
372 Box::new(TuiEventSink {
373 tx: event_tx.clone(),
374 }) as Box<dyn EventSink>,
375 Box::new(SessionPersistenceSink::new(sw_arc.clone())),
376 ]));
377 rt_opt.as_mut().unwrap().set_event_sink(composite);
378 session_writer = Some(sw_arc);
379 }
380 }
381
382 let rt = rt_opt.take().unwrap();
383 let gate = rt.plan_approval_gate();
387 let rt_shared = Arc::new(tokio::sync::Mutex::new(rt));
388 let rt_clone = rt_shared.clone();
389 cancel_flag.store(false, Ordering::SeqCst);
390 let cancel_clone = cancel_flag.clone();
391 let mut handle = tokio::task::spawn(async move {
392 let mut g = rt_clone.lock().await;
393 g.enqueue(text).await.map(|_| ())
394 });
395 let aborted = run_turn_select_loop(
396 &mut handle,
397 &mut action_rx,
398 &event_tx,
399 &cancel_flag,
400 cancel_clone,
401 &gate,
402 )
403 .await;
404 let mut recovered = Arc::try_unwrap(rt_shared)
405 .expect("single owner after task end")
406 .into_inner();
407 if aborted {
408 recovered.truncate_transcript(pre_turn_len);
409 }
410 *rt_opt = Some(recovered);
411 let _ = event_tx.send(UiEvent::TurnFinished);
412 cancel_flag.store(false, Ordering::SeqCst);
413 } else if let RuntimeBuild::Offline { reason } = &state {
414 let _ = event_tx.send(UiEvent::Error {
415 message: reason.clone(),
416 });
417 let _ = event_tx.send(UiEvent::TurnFinished);
418 }
419 }
420
421 UserAction::RunShell(cmd) => {
422 run_bash_command(&bash_registry, &bash_seq, cmd, &event_tx).await;
423 }
424
425 UserAction::ConfirmPlan => {
426 if let RuntimeBuild::Ready(rt_opt) = &mut state {
427 rt_opt.as_mut().unwrap().confirm_plan();
428 let pre_turn_len = rt_opt.as_ref().unwrap().transcript().len();
429 let rt = rt_opt.take().unwrap();
430 let gate = rt.plan_approval_gate();
431 let rt_shared = Arc::new(tokio::sync::Mutex::new(rt));
432 let rt_clone = rt_shared.clone();
433 cancel_flag.store(false, Ordering::SeqCst);
434 let cancel_clone = cancel_flag.clone();
435 let mut handle = tokio::task::spawn(async move {
436 let mut g = rt_clone.lock().await;
437 g.run("").await.map(|_| ())
438 });
439 let aborted = run_turn_select_loop(
440 &mut handle,
441 &mut action_rx,
442 &event_tx,
443 &cancel_flag,
444 cancel_clone,
445 &gate,
446 )
447 .await;
448 let mut recovered = Arc::try_unwrap(rt_shared)
449 .expect("single owner after task end")
450 .into_inner();
451 if aborted {
452 recovered.truncate_transcript(pre_turn_len);
453 }
454 *rt_opt = Some(recovered);
455 let _ = event_tx.send(UiEvent::TurnFinished);
456 cancel_flag.store(false, Ordering::SeqCst);
457 }
458 }
459
460 UserAction::RejectPlan(reason) => {
461 if let RuntimeBuild::Ready(rt_opt) = &mut state {
462 rt_opt.as_mut().unwrap().reject_plan(&reason);
463 }
464 }
465
466 UserAction::ApprovePlanMode => {
468 if let RuntimeBuild::Ready(rt_opt) = &mut state {
469 rt_opt.as_ref().unwrap().approve_plan_mode_request();
470 }
471 }
472 UserAction::RejectPlanMode(reason) => {
473 if let RuntimeBuild::Ready(rt_opt) = &mut state {
474 rt_opt.as_ref().unwrap().reject_plan_mode_request(&reason);
475 }
476 }
477
478 UserAction::Compact => match &mut state {
479 RuntimeBuild::Ready(rt_opt) => {
480 if let Err(e) = rt_opt.as_mut().unwrap().compact_now().await {
481 let _ = event_tx.send(UiEvent::Error {
482 message: format!("compact failed: {e}"),
483 });
484 }
485 }
486 RuntimeBuild::Offline { .. } => {
487 let _ = event_tx.send(UiEvent::Error {
488 message: "compact unavailable in offline mode".into(),
489 });
490 }
491 },
492
493 UserAction::SetPlanningMode(on) => {
494 if let RuntimeBuild::Ready(rt_opt) = &mut state {
495 let mode = if on {
496 crate::PlanningMode::PlanFirst
497 } else {
498 crate::PlanningMode::Immediate
499 };
500 rt_opt.as_mut().unwrap().set_planning_mode(mode);
501 }
502 }
503
504 UserAction::Interrupt => {
505 cancel_flag.store(true, Ordering::SeqCst);
506 }
507
508 UserAction::SetGoal {
510 condition,
511 max_turns,
512 } => {
513 if let RuntimeBuild::Ready(rt_opt) = &mut state {
514 let pre_turn_len = rt_opt.as_ref().unwrap().transcript().len();
515 let rt = rt_opt.take().unwrap();
516 let gate = rt.plan_approval_gate();
517 let rt_shared = Arc::new(tokio::sync::Mutex::new(rt));
518 let rt_clone = rt_shared.clone();
519 cancel_flag.store(false, Ordering::SeqCst);
520 let cancel_clone = cancel_flag.clone();
521 let prompt = format!(
522 "Start working towards the following goal: {condition}\n\nContinue until the goal is achieved."
523 );
524 let mut handle = tokio::task::spawn(async move {
525 let mut g = rt_clone.lock().await;
526 g.run_goal_loop(prompt, condition, max_turns)
527 .await
528 .map(|_| ())
529 });
530 let aborted = run_turn_select_loop(
531 &mut handle,
532 &mut action_rx,
533 &event_tx,
534 &cancel_flag,
535 cancel_clone,
536 &gate,
537 )
538 .await;
539 let mut recovered = Arc::try_unwrap(rt_shared)
541 .expect("single owner after task end")
542 .into_inner();
543 if aborted {
544 recovered.truncate_transcript(pre_turn_len);
545 }
546 *rt_opt = Some(recovered);
547 let _ = event_tx.send(UiEvent::TurnFinished);
548 cancel_flag.store(false, Ordering::SeqCst);
549 } else if let RuntimeBuild::Offline { reason } = &state {
550 let _ = event_tx.send(UiEvent::Error {
551 message: reason.clone(),
552 });
553 }
554 }
555
556 UserAction::ClearGoal => {
558 if let RuntimeBuild::Ready(rt_opt) = &mut state {
559 rt_opt.as_mut().unwrap().clear_goal().await;
560 }
561 }
562
563 UserAction::ResumeSession { session_dir } => {
565 if let RuntimeBuild::Ready(rt_opt) = &mut state {
566 let rt = rt_opt.as_mut().unwrap();
567 match crate::session::SessionReader::load_messages(&session_dir) {
568 Ok(messages) => {
569 let turn_count = messages.len();
570 rt.set_transcript(messages);
571 let session_id = session_dir
572 .file_name()
573 .and_then(|n| n.to_str())
574 .unwrap_or("unknown")
575 .to_string();
576 let _ = event_tx.send(UiEvent::SessionResumed {
577 session_id,
578 turn_count,
579 });
580 }
581 Err(e) => {
582 let _ = event_tx.send(UiEvent::Error {
583 message: format!("Failed to load session: {e}"),
584 });
585 }
586 }
587 }
588 }
589
590 UserAction::ListMcpServers => {
592 let workspace = resolve_workspace_root();
593 let tx = event_tx.clone();
594 tokio::spawn(async move {
595 let servers = crate::mcp::discover_mcp_servers(&workspace)
596 .await
597 .unwrap_or_default();
598 let entries: Vec<crate::tui::ui::modal::McpEntry> = servers
599 .iter()
600 .map(|s| {
601 let transport = if s.url.is_some() {
602 "http".to_string()
603 } else if !s.command.is_empty() {
604 "stdio".to_string()
605 } else {
606 "unknown".to_string()
607 };
608 crate::tui::ui::modal::McpEntry {
609 name: s.name.clone(),
610 transport,
611 enabled: true,
612 }
613 })
614 .collect();
615 let _ = tx.send(UiEvent::McpServersLoaded { entries });
616 });
617 }
618
619 UserAction::RunSkillPrompt { prompt } => {
621 if let RuntimeBuild::Ready(rt_opt) = &mut state {
622 let pre_turn_len = rt_opt.as_ref().unwrap().transcript().len();
623 let rt = rt_opt.take().unwrap();
624 let rt_shared = Arc::new(tokio::sync::Mutex::new(rt));
625 let rt_clone = rt_shared.clone();
626 cancel_flag.store(false, Ordering::SeqCst);
627 let cancel_clone = cancel_flag.clone();
628 let mut handle = tokio::task::spawn(async move {
629 let mut g = rt_clone.lock().await;
630 g.run(prompt).await.map(|_| ())
631 });
632 let aborted = tokio::select! {
633 res = &mut handle => {
634 if let Err(e) = res
635 .map_err(|e| crate::Error::Other(e.to_string()))
636 .and_then(|r| r)
637 {
638 let _ = event_tx.send(UiEvent::Error { message: e.to_string() });
639 }
640 false
641 },
642 _ = wait_for_cancel(cancel_clone) => {
643 handle.abort();
644 let _ = handle.await;
645 let _ = event_tx.send(UiEvent::Interrupted);
646 true
647 }
648 };
649 let mut recovered = Arc::try_unwrap(rt_shared)
650 .expect("single owner after task end")
651 .into_inner();
652 if aborted {
653 recovered.truncate_transcript(pre_turn_len);
654 }
655 *rt_opt = Some(recovered);
656 let _ = event_tx.send(UiEvent::TurnFinished);
657 cancel_flag.store(false, Ordering::SeqCst);
658 } else if let RuntimeBuild::Offline { reason } = &state {
659 let _ = event_tx.send(UiEvent::Error {
660 message: reason.clone(),
661 });
662 let _ = event_tx.send(UiEvent::TurnFinished);
663 }
664 }
665 }
666 }
667}
668
669pub async fn wait_for_cancel(flag: Arc<AtomicBool>) {
670 loop {
671 if flag.load(Ordering::SeqCst) {
672 return;
673 }
674 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
675 }
676}
677
678async fn run_turn_select_loop(
691 handle: &mut tokio::task::JoinHandle<Result<(), crate::Error>>,
692 action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<UserAction>,
693 event_tx: &tokio::sync::mpsc::UnboundedSender<UiEvent>,
694 cancel_flag: &Arc<AtomicBool>,
695 cancel_clone: Arc<AtomicBool>,
696 gate: &Arc<crate::tools::plan_mode::PlanApprovalGate>,
697) -> bool {
698 loop {
699 tokio::select! {
700 biased;
701 res = &mut *handle => {
702 if let Err(e) = res
703 .map_err(|e| crate::Error::Other(e.to_string()))
704 .and_then(|r| r)
705 {
706 let _ = event_tx.send(UiEvent::Error { message: e.to_string() });
707 }
708 return false;
709 }
710 _ = wait_for_cancel(cancel_clone.clone()) => {
711 handle.abort();
712 let _ = handle.await;
713 let _ = event_tx.send(UiEvent::Interrupted);
714 return true;
715 }
716 maybe_action = action_rx.recv() => {
717 match maybe_action {
718 Some(UserAction::ConfirmPlan) => gate.approve(),
719 Some(UserAction::RejectPlan(reason)) => gate.reject(&reason),
720 Some(UserAction::Interrupt) => {
721 cancel_flag.store(true, Ordering::SeqCst);
722 }
723 Some(UserAction::Shutdown) => {
724 handle.abort();
725 let _ = handle.await;
726 return true;
727 }
728 Some(_) => {}
732 None => return false,
733 }
734 }
735 }
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::tui::bash::build_bash_registry;
743
744 #[test]
745 fn map_partial_token_to_assistant_partial() {
746 let ev = AgentEvent::PartialToken {
747 text: "hel".into(),
748 step: 0,
749 };
750 assert_eq!(
751 map_agent_event(ev),
752 Some(UiEvent::AssistantPartial { text: "hel".into() })
753 );
754 }
755
756 #[test]
757 fn map_assistant_text_to_assistant_message() {
758 let ev = AgentEvent::AssistantText {
759 text: "hi".into(),
760 step: 0,
761 };
762 assert_eq!(
763 map_agent_event(ev),
764 Some(UiEvent::AssistantMessage {
765 content: "hi".into()
766 })
767 );
768 }
769
770 #[test]
771 fn map_tool_result_error_prefix_marks_failure() {
772 let ev = AgentEvent::ToolResult {
773 id: "1".into(),
774 name: "read_file".into(),
775 output: "ERROR: missing".into(),
776 step: 0,
777 };
778 let mapped = map_agent_event(ev).unwrap();
779 match mapped {
780 UiEvent::ToolResult { success, .. } => assert!(!success),
781 other => panic!("expected ToolResult, got {other:?}"),
782 }
783 }
784
785 #[test]
786 fn map_compacted_event() {
787 let ev = AgentEvent::Compacted {
788 removed: 5,
789 kept: 2,
790 summary_chars: 800,
791 step: 0,
792 };
793 assert_eq!(
794 map_agent_event(ev),
795 Some(UiEvent::Compacted {
796 removed: 5,
797 kept: 2
798 })
799 );
800 }
801
802 #[test]
803 fn map_plan_proposed_is_forwarded() {
804 let ev = AgentEvent::PlanProposed {
805 plan_text: "p".into(),
806 tool_calls: vec![],
807 };
808 match map_agent_event(ev) {
809 Some(UiEvent::PlanProposed {
810 plan_text,
811 tool_calls,
812 }) => {
813 assert_eq!(plan_text, "p");
814 assert!(tool_calls.is_empty());
815 }
816 other => panic!("expected PlanProposed forward, got {other:?}"),
817 }
818 }
819
820 #[test]
821 fn map_plan_confirmed_is_forwarded() {
822 let mapped = map_agent_event(AgentEvent::PlanConfirmed);
823 assert_eq!(mapped, Some(UiEvent::PlanConfirmed));
824 }
825
826 #[test]
827 fn map_plan_rejected_is_forwarded() {
828 let mapped = map_agent_event(AgentEvent::PlanRejected {
829 reason: "user rejected".into(),
830 });
831 assert_eq!(
832 mapped,
833 Some(UiEvent::PlanRejected {
834 reason: "user rejected".into(),
835 })
836 );
837 }
838
839 #[tokio::test]
840 #[cfg_attr(target_os = "windows", ignore)]
841 async fn run_shell_action_dispatches_tool_and_emits_events() {
842 let tmp = tempfile::TempDir::new().unwrap();
843 let registry = build_bash_registry(tmp.path());
844 let (tx, mut rx) = mpsc::unbounded_channel::<UiEvent>();
845 let seq = AtomicU64::new(0);
846 run_bash_command(®istry, &seq, "echo bash-mode-works".into(), &tx).await;
847
848 let call = rx.recv().await.expect("ToolCall event");
849 match call {
850 UiEvent::ToolCall {
851 ref name,
852 ref id,
853 ref arguments,
854 } => {
855 assert_eq!(name, "run_shell");
856 assert!(id.starts_with("ui-bash-"));
857 assert!(
858 arguments.contains("echo bash-mode-works"),
859 "arguments missing command: {arguments}"
860 );
861 }
862 other => panic!("expected ToolCall, got {other:?}"),
863 }
864
865 let res = rx.recv().await.expect("ToolResult event");
866 match res {
867 UiEvent::ToolResult {
868 ref name,
869 ref output,
870 success,
871 ..
872 } => {
873 assert_eq!(name, "run_shell");
874 assert!(success, "shell command should succeed");
875 assert!(
876 output.contains("bash-mode-works"),
877 "output missing stdout: {output}"
878 );
879 }
880 other => panic!("expected ToolResult, got {other:?}"),
881 }
882 }
883
884 #[tokio::test]
885 async fn run_shell_action_works_when_runtime_offline() {
886 let tmp = tempfile::TempDir::new().unwrap();
887 let registry = build_bash_registry(tmp.path());
888 let (tx, mut rx) = mpsc::unbounded_channel::<UiEvent>();
889 let seq = AtomicU64::new(42);
890 run_bash_command(®istry, &seq, "echo offline".into(), &tx).await;
891
892 let call = rx.recv().await.expect("ToolCall event");
893 if let UiEvent::ToolCall { id, .. } = call {
894 assert_eq!(id, "ui-bash-42");
895 } else {
896 panic!("expected ToolCall, got {call:?}");
897 }
898 let _ = rx.recv().await;
899 }
900
901 #[tokio::test]
902 async fn run_with_cancel_flag_true_returns_quickly() {
903 let flag = Arc::new(AtomicBool::new(true));
904 let started = std::time::Instant::now();
905 let timed = tokio::time::timeout(
906 std::time::Duration::from_millis(500),
907 wait_for_cancel(flag.clone()),
908 )
909 .await;
910 let elapsed = started.elapsed();
911 assert!(timed.is_ok(), "wait_for_cancel didn't return in time");
912 assert!(
913 elapsed < std::time::Duration::from_millis(500),
914 "wait_for_cancel was too slow: {elapsed:?}"
915 );
916 }
917
918 #[tokio::test]
919 async fn interrupt_action_sets_cancel_flag() {
920 let prev_recursive = std::env::var("RECURSIVE_API_KEY").ok();
921 let prev_openai = std::env::var("OPENAI_API_KEY").ok();
922 std::env::remove_var("RECURSIVE_API_KEY");
923 std::env::remove_var("OPENAI_API_KEY");
924
925 let backend = Backend::spawn();
926 assert!(!backend.cancel_flag.load(Ordering::SeqCst));
927 backend.action_tx.send(UserAction::Interrupt).unwrap();
928
929 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
930 while tokio::time::Instant::now() < deadline {
931 if backend.cancel_flag.load(Ordering::SeqCst) {
932 break;
933 }
934 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
935 }
936 assert!(
937 backend.cancel_flag.load(Ordering::SeqCst),
938 "Interrupt should set cancel_flag"
939 );
940 let _ = backend.action_tx.send(UserAction::Shutdown);
941
942 if let Some(v) = prev_recursive {
943 std::env::set_var("RECURSIVE_API_KEY", v);
944 }
945 if let Some(v) = prev_openai {
946 std::env::set_var("OPENAI_API_KEY", v);
947 }
948 }
949}