Skip to main content

recursive/tui/
backend.rs

1//! In-process agent backend for the TUI.
2//!
3//! [`Backend`] owns one tokio task that holds an [`recursive::AgentRuntime`].
4//! The UI thread sends [`UserAction`]s into the worker via `action_tx` and
5//! the worker pushes [`UiEvent`]s back via `event_rx`.
6//!
7//! Runtime construction and bash-mode dispatch live in sibling modules
8//! (`runtime_builder`, `bash`) to keep this file focused on event bridging.
9
10use 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
27/// Local helper to fan-out from two channels in the worker loop.
28enum Either<L, R> {
29    Left(L),
30    #[allow(dead_code)]
31    Right(R),
32}
33
34/// A handle to the agent worker task.
35pub struct Backend {
36    pub action_tx: mpsc::UnboundedSender<UserAction>,
37    pub event_rx: mpsc::UnboundedReceiver<UiEvent>,
38    /// Shared cancel flag: the UI flips this to `true` to interrupt an
39    /// in-flight turn; the worker's `tokio::select!` wakes and aborts.
40    pub cancel_flag: Arc<AtomicBool>,
41    /// Goal-161: side-channel for runtime permission requests.
42    /// Separate from `event_rx` because `PermissionRequest` carries a
43    /// `oneshot::Sender<bool>` which is not `PartialEq`/`Clone`.
44    pub perm_rx: mpsc::UnboundedReceiver<PermissionRequest>,
45    /// Goal-161: shared flag that enables/disables the runtime permission
46    /// hook. The UI thread can flip this via `/permissions on|off`.
47    pub permission_enabled: Arc<AtomicBool>,
48    /// WeChat side-channel: the daemon sends `WeixinBackendRequest`s here.
49    /// The UI loop passes this into [`Backend::weixin_tx`] to the daemon.
50    #[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        // Goal-202: plan-mode pre-confirmation events.
157        AgentEvent::PlanModeRequested { reason } => Some(UiEvent::PlanModeRequested { reason }),
158        AgentEvent::PlanModeApproved => Some(UiEvent::PlanModeApproved),
159        AgentEvent::PlanModeRejected { reason } => Some(UiEvent::PlanModeRejected { reason }),
160        // Goal-167: forward todo updates to the UI.
161        AgentEvent::TodoUpdated { todos } => Some(UiEvent::TodoUpdated { todos }),
162
163        // Goal-168: forward goal-loop events.
164        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        // Goal-210: hook progress events.
173        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
208// ── Goal-161: TuiPermissionHook ──────────────────────────────────────────────
209
210/// Forwards tool-permission requests to the UI via a side-channel and blocks
211/// until the user responds. When `enabled` is `false`, auto-allows all calls.
212struct 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; // UI dropped — allow so agent isn't stuck.
237        }
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        // Goal-161: wire up the permission hook.
261        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    // Lazily-created session writer for TUI interactive sessions.
271    // Created on the first SendMessage so that sessions without any
272    // actual user messages don't leave empty files on disk.
273    // Wrapped in Arc<Mutex<>> so SessionPersistenceSink can share it
274    // and write to disk in real-time on every MessageAppended event.
275    let mut session_writer: Option<Arc<std::sync::Mutex<SessionWriter>>> = None;
276
277    loop {
278        // Select on both the user-action channel and the WeChat side-channel.
279        // WeChat messages processed here behave like SendMessage turns but
280        // without plan-mode interaction.
281        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            // Notify TUI of the incoming WeChat message.
311            let _ = event_tx.send(UiEvent::WeixinMessage {
312                user_id: wx_req.user_id.clone(),
313                text: wx_req.text.clone(),
314            });
315            // Run the turn and send response back to WeChat daemon.
316            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                    // On the first user message, create a SessionWriter and wire
362                    // it into the runtime's event sink via SessionPersistenceSink
363                    // so every MessageAppended event is written to disk in real-time.
364                    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                            // Build a composite sink: TUI display + session persistence.
371                            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                    // Clone the gate before moving the runtime into the spawned task.
384                    // This lets us signal plan approval/rejection via action_rx while
385                    // the task is blocked inside exit_plan_mode.
386                    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            // Goal-202: plan-mode pre-confirmation responses.
467            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            // Goal-168: start a condition-based autonomous loop.
509            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                    // Suppress goal-loop errors; they are surfaced via GoalContinuing/GoalAchieved.
540                    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            // Goal-168: clear the active goal.
557            UserAction::ClearGoal => {
558                if let RuntimeBuild::Ready(rt_opt) = &mut state {
559                    rt_opt.as_mut().unwrap().clear_goal().await;
560                }
561            }
562
563            // Goal-171: load a saved session transcript into the runtime.
564            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            // Goal-173: list MCP servers.
591            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            // Goal-169: run an already-expanded skill prompt.
620            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
678/// Drive a spawned agent turn to completion while remaining responsive to
679/// plan-approval and interrupt actions from the UI.
680///
681/// Returns `true` if the turn was aborted (cancel flag set or Shutdown received),
682/// `false` if the task completed normally.
683///
684/// While a turn runs, `action_rx` is polled concurrently so that
685/// `UserAction::ConfirmPlan` / `UserAction::RejectPlan` can signal the
686/// `PlanApprovalGate` directly — unblocking `exit_plan_mode` without
687/// requiring a new turn. `UserAction::Interrupt` sets the cancel flag.
688/// Any other actions received during the turn are silently discarded
689/// (they cannot be acted on without the runtime, which is inside the task).
690async 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                    // Other actions cannot be serviced while the runtime is
729                    // inside the spawned task. Discard them — in normal usage
730                    // only plan/interrupt actions arrive during a running turn.
731                    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(&registry, &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(&registry, &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}