1mod config_watch;
35mod middleware;
36mod turn_scope;
37
38use std::collections::HashMap;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::time::Instant;
42
43use tokio::sync::mpsc;
44
45use crate::app::{Config, MemoryConfig};
46use crate::domain::{
47 Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
48};
49use crate::models::{ModelError, TokenUsage};
50use crate::providers::ctx::{ExecContext, StreamContext};
51use crate::providers::model::ModelProvider;
52use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
53
54pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
55pub use turn_scope::TurnScope;
56
57#[cfg(not(test))]
58const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
59#[cfg(test)]
60const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
61
62pub type MsgSender = mpsc::Sender<Msg>;
68
69pub const MSG_CHANNEL_CAPACITY: usize = 512;
75
76pub struct EffectRunner {
79 msg_tx: MsgSender,
80 scopes: HashMap<TurnId, TurnScope>,
86 detached: tokio::task::JoinSet<()>,
90 workdir: PathBuf,
94 providers: Option<Arc<ProviderFactory>>,
99 tools: Option<Arc<ToolRegistry>>,
102 task_id: Option<String>,
104 terminal_title_enabled: bool,
108 approval: Option<crate::providers::ApprovalBroker>,
112 config_watch: Option<tokio::task::AbortHandle>,
116}
117
118impl EffectRunner {
119 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
121 Self {
122 msg_tx,
123 scopes: HashMap::new(),
124 detached: tokio::task::JoinSet::new(),
125 workdir,
126 providers: None,
127 tools: None,
128 task_id: None,
129 terminal_title_enabled: true,
130 approval: None,
131 config_watch: None,
132 }
133 }
134
135 pub fn with_interactive_approvals(mut self) -> Self {
139 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
140 self
141 }
142
143 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
149 let handle = self.detached.spawn(config_watch::config_watcher(
150 self.msg_tx.clone(),
151 cwd,
152 memory,
153 ));
154 self.config_watch = Some(handle);
155 }
156
157 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
160 self.task_id = task_id;
161 self
162 }
163
164 pub fn without_terminal_title(mut self) -> Self {
166 self.terminal_title_enabled = false;
167 self
168 }
169
170 pub fn with_bindings(
175 mut self,
176 providers: Arc<ProviderFactory>,
177 tools: Arc<ToolRegistry>,
178 ) -> Self {
179 self.providers = Some(providers);
180 self.tools = Some(tools);
181 self
182 }
183
184 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
188 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
189 (Self::new(tx, workdir), rx)
190 }
191
192 pub fn pair_with_bindings(
195 workdir: PathBuf,
196 config: Config,
197 tools: Arc<ToolRegistry>,
198 ) -> (Self, mpsc::Receiver<Msg>) {
199 let providers = Arc::new(ProviderFactory::new(config));
200 Self::pair_from(workdir, providers, tools)
201 }
202
203 pub fn pair_from(
208 workdir: PathBuf,
209 providers: Arc<ProviderFactory>,
210 tools: Arc<ToolRegistry>,
211 ) -> (Self, mpsc::Receiver<Msg>) {
212 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
213 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
214 }
215
216 pub fn pair_from_with_task(
217 workdir: PathBuf,
218 providers: Arc<ProviderFactory>,
219 tools: Arc<ToolRegistry>,
220 task_id: Option<String>,
221 ) -> (Self, mpsc::Receiver<Msg>) {
222 let (runner, rx) = Self::pair_from(workdir, providers, tools);
223 (runner.with_task_id(task_id), rx)
224 }
225
226 pub fn new_child(
231 msg_tx: MsgSender,
232 workdir: PathBuf,
233 providers: Arc<ProviderFactory>,
234 tools: Arc<ToolRegistry>,
235 ) -> Self {
236 Self::new(msg_tx, workdir)
241 .with_bindings(providers, tools)
242 .without_terminal_title()
243 }
244
245 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
249 self.scopes
250 .entry(turn)
251 .or_insert_with(|| TurnScope::new(turn))
252 }
253
254 fn drop_scope(&mut self, turn: TurnId) {
264 if let Some(mut scope) = self.scopes.remove(&turn) {
265 scope.cancel();
266 let tx = self.msg_tx.clone();
267 self.detached.spawn(async move {
268 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
269 .await
270 .is_err()
271 {
272 tracing::warn!(
273 turn = %turn,
274 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
275 "cancel drain timed out; aborting remaining scoped tasks"
276 );
277 }
278 let _ = tx.send(Msg::TurnCancelled(turn)).await;
279 });
280 } else {
281 let tx = self.msg_tx.clone();
288 self.detached.spawn(async move {
289 let _ = tx.send(Msg::TurnCancelled(turn)).await;
290 });
291 }
292 }
293
294 pub fn scope_count(&self) -> usize {
297 self.scopes.len()
298 }
299
300 fn reap_empty_scopes(&mut self) {
309 self.reap_detached();
310 self.scopes.retain(|_, scope| {
311 scope.drain_completed();
312 !scope.is_empty()
313 });
314 }
315
316 fn reap_detached(&mut self) {
321 while let Some(result) = self.detached.try_join_next() {
322 if let Err(e) = result
323 && !e.is_cancelled()
324 {
325 tracing::warn!(error = %e, "effect: detached task panicked");
326 }
327 }
328 }
329
330 pub fn dispatch(&mut self, cmd: Cmd) {
334 self.reap_empty_scopes();
337 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
338
339 match cmd {
340 Cmd::CallModel { turn, mut request } => {
341 let tx = self.msg_tx.clone();
342 let providers = self.providers.clone();
343 if let Some(tools) = &self.tools {
350 let mut enriched = tools.describe_all();
351 let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
355 let _ = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens));
356 enriched.append(&mut request.tools);
357 request.tools = enriched;
358 }
359 self.detached.spawn(fire_plugin_hooks(
362 "prompt_submit",
363 serde_json::json!({
364 "turn_id": turn.0,
365 "model_id": request.model_id.clone(),
366 "message_count": request.messages.len(),
367 "tool_count": request.tools.len(),
368 }),
369 ));
370 let scope = self.scope_mut(turn);
371 let token = scope.token();
372 scope.spawn(async move {
373 use futures::FutureExt;
374 let fallback_tx = tx.clone();
375 if std::panic::AssertUnwindSafe(dispatch_call_model(
376 tx, providers, turn, request, token,
377 ))
378 .catch_unwind()
379 .await
380 .is_err()
381 {
382 tracing::error!(turn = %turn, "dispatch_call_model panicked");
387 let _ = fallback_tx
388 .send(Msg::UpstreamError {
389 turn,
390 error: crate::models::UserFacingError {
391 summary: "Internal error".to_string(),
392 message: "The model dispatch task panicked unexpectedly."
393 .to_string(),
394 suggestion: "This is a bug. Please retry; if it persists, \
395 check the logs."
396 .to_string(),
397 category: crate::models::ErrorCategory::Internal,
398 recoverable: true,
399 },
400 })
401 .await;
402 }
403 });
404 },
405 Cmd::CompactConversation { turn, mut request } => {
406 let tx = self.msg_tx.clone();
407 let providers = self.providers.clone();
408 if let Some(tools) = &self.tools {
409 let mut enriched = tools.describe_all();
410 enriched.append(&mut request.chat.tools);
411 request.chat.tools = enriched;
412 }
413 let scope = self.scope_mut(turn);
414 let token = scope.token();
415 scope.spawn(async move {
416 dispatch_compact_conversation(tx, providers, turn, request, token).await;
417 });
418 },
419 Cmd::ExecuteTool {
420 turn,
421 call_id,
422 source,
423 model_id,
424 safety_mode,
425 intent,
426 } => {
427 let tx = self.msg_tx.clone();
428 let tools = self.tools.clone();
429 let workdir = self.workdir.clone();
430 let config = self
435 .providers
436 .as_ref()
437 .map(|p| Arc::new(p.config().clone()))
438 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
439 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
444 if safety_mode == crate::runtime::SafetyMode::Auto {
445 self.providers.as_ref().map(|p| {
446 let model = config
447 .safety
448 .auto_classifier_model
449 .clone()
450 .unwrap_or_else(|| model_id.clone());
451 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
452 as Arc<dyn crate::providers::AutoClassifier>
453 })
454 } else {
455 None
456 };
457 let task_id = self.task_id.clone();
458 let approval = self.approval.clone();
459 let scope = self.scope_mut(turn);
460 let token = scope.token();
461 let background = scope.background_token();
462 scope.spawn(async move {
463 use futures::FutureExt;
464 let fallback_tx = tx.clone();
465 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
466 tx,
467 tools,
468 workdir,
469 turn,
470 call_id,
471 source,
472 token,
473 background,
474 config,
475 model_id,
476 task_id,
477 safety_mode,
478 intent,
479 classifier,
480 approval,
481 ))
482 .catch_unwind()
483 .await
484 .is_err()
485 {
486 tracing::error!(
491 turn = %turn,
492 call_id = call_id.0,
493 "dispatch_execute_tool panicked"
494 );
495 let _ = fallback_tx
496 .send(Msg::ToolFinished {
497 turn,
498 call_id,
499 outcome: crate::domain::ToolOutcome::error(
500 "internal error: the tool execution task panicked".to_string(),
501 0.0,
502 ),
503 })
504 .await;
505 }
506 });
507 },
508 Cmd::ResolveApproval { call_id, decision } => {
509 if let Some(broker) = &self.approval {
512 broker.resolve(call_id, decision.into());
513 }
514 },
515 Cmd::CancelScope(turn) => {
516 self.drop_scope(turn);
517 },
518 Cmd::BackgroundScope(turn) => {
519 self.scope_mut(turn).background();
523 },
524 Cmd::SaveConversation(history) => {
525 let tx = self.msg_tx.clone();
526 let workdir = self.workdir.clone();
527 self.detached.spawn(async move {
528 if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
529 && manager.save_conversation(&history).is_ok()
530 {
531 let _ = tx.send(Msg::SessionSaved).await;
532 } else {
533 tracing::warn!("SaveConversation: failed to write to disk");
534 }
535 });
536 },
537 Cmd::SaveCompactionArchive {
538 archive,
539 record,
540 conversation,
541 } => {
542 let tx = self.msg_tx.clone();
543 let workdir = self.workdir.clone();
544 let task_id = self.task_id.clone();
545 self.detached.spawn(async move {
546 let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
547 return;
548 };
549 match manager.save_compaction_archive(&archive) {
554 Ok(path) => {
555 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
556 let compaction_id = record.id.clone();
557 let conversation_id = archive.conversation_id.clone();
558 let _ =
559 store.compactions().create(crate::runtime::NewCompaction {
560 id: Some(record.id),
561 task_id: task_id.clone(),
562 session_id: Some(archive.conversation_id),
563 source_token_estimate: Some(record.before_tokens as i64),
564 summary_token_count: Some(record.summary_tokens as i64),
565 preserved_turns: Some(record.preserved_message_count as i64),
566 archive_path: Some(path.display().to_string()),
567 verification_status: Some("verified".to_string()),
568 });
569 fire_plugin_hooks(
570 "compaction",
571 serde_json::json!({
572 "id": compaction_id,
573 "task_id": task_id.clone(),
574 "session_id": conversation_id,
575 "archive_path": path.display().to_string(),
576 }),
577 )
578 .await;
579 }
580 if manager.save_conversation(&conversation).is_ok() {
581 let _ = tx.send(Msg::SessionSaved).await;
582 } else {
583 tracing::warn!(
584 "SaveCompactionArchive: archive persisted but conversation save failed"
585 );
586 }
587 },
588 Err(err) => {
589 tracing::warn!(
590 error = %err,
591 "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
592 );
593 },
594 }
595 });
596 },
597 Cmd::SaveProcess(process) => {
598 let task_id = self.task_id.clone();
599 self.detached.spawn(async move {
600 let status = match process.status {
601 crate::domain::ManagedProcessStatus::Running => {
602 crate::runtime::ProcessStatus::Running
603 },
604 crate::domain::ManagedProcessStatus::Exited => {
605 crate::runtime::ProcessStatus::Exited
606 },
607 crate::domain::ManagedProcessStatus::Unknown => {
608 crate::runtime::ProcessStatus::Unknown
609 },
610 };
611 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
612 let _ = store.processes().upsert(crate::runtime::NewProcess {
613 id: Some(process.id),
614 task_id,
615 pid: process.pid,
616 command: process.command,
617 cwd: process.cwd,
618 log_path: Some(process.log_path),
619 detected_url: process.detected_url,
620 status,
621 health: None,
622 });
623 }
624 });
625 },
626 Cmd::PersistLastModel(model) => {
627 self.detached.spawn(async move {
628 let _ = crate::app::persist_last_model(&model);
629 });
630 },
631 Cmd::PersistReasoningFor { model_id, level } => {
632 self.detached.spawn(async move {
633 let _ = crate::app::persist_reasoning_for_model(&model_id, level);
634 });
635 },
636 Cmd::RefreshInstructions => {
637 let tx = self.msg_tx.clone();
638 let workdir = self.workdir.clone();
639 self.detached.spawn(async move {
640 let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
641 let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
642 });
643 },
644 Cmd::RefreshMemory => {
645 let tx = self.msg_tx.clone();
646 let workdir = self.workdir.clone();
647 self.detached.spawn(async move {
648 let cfg = crate::app::load_config().unwrap_or_default().memory;
649 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
650 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
651 });
652 },
653 Cmd::ListMemory => {
654 let tx = self.msg_tx.clone();
655 let workdir = self.workdir.clone();
656 self.detached.spawn(async move {
657 let cfg = crate::app::load_config().unwrap_or_default().memory;
658 let text = match crate::app::memory::load(&workdir, &cfg) {
659 Some(mem) => mem.index,
660 None => "No memories saved yet. Durable facts (yours or mine) show up here — use `/remember <fact>` or just ask me to remember something.".to_string(),
661 };
662 let _ = tx.send(Msg::RuntimeText(text)).await;
663 });
664 },
665 Cmd::RememberMemory { text } => {
666 let tx = self.msg_tx.clone();
667 let workdir = self.workdir.clone();
668 self.detached.spawn(async move {
669 let cfg = crate::app::load_config().unwrap_or_default().memory;
670 let name = memory_title_from_text(&text);
671 let (status, kind) = match crate::app::memory::write_memory(
672 &workdir,
673 crate::app::memory::MemoryScope::ProjectPrivate,
674 &name,
675 &text,
676 &[],
677 &text,
678 ) {
679 Ok(_) => (
680 format!("Remembered: {name}"),
681 crate::domain::StatusKind::Info,
682 ),
683 Err(e) => (
684 format!("Couldn't save memory: {e}"),
685 crate::domain::StatusKind::Error,
686 ),
687 };
688 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
689 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
690 let _ = tx
691 .send(Msg::TransientStatus {
692 text: status,
693 kind,
694 dismiss_ms: 3_000,
695 })
696 .await;
697 });
698 },
699 Cmd::ForgetMemory { id } => {
700 let tx = self.msg_tx.clone();
701 let workdir = self.workdir.clone();
702 self.detached.spawn(async move {
703 let cfg = crate::app::load_config().unwrap_or_default().memory;
704 let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
705 Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
706 Ok(None) => (
707 format!("No memory named '{id}'"),
708 crate::domain::StatusKind::Info,
709 ),
710 Err(e) => (
711 format!("Couldn't forget memory: {e}"),
712 crate::domain::StatusKind::Error,
713 ),
714 };
715 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
716 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
717 let _ = tx
718 .send(Msg::TransientStatus {
719 text: status,
720 kind,
721 dismiss_ms: 3_000,
722 })
723 .await;
724 });
725 },
726 Cmd::ConsolidateMemory { model_id } => {
727 let tx = self.msg_tx.clone();
728 let workdir = self.workdir.clone();
729 let providers = self.providers.clone();
730 self.detached.spawn(async move {
731 consolidate_memory(tx, providers, workdir, model_id).await;
732 });
733 },
734 Cmd::LoadConversation(id) => {
735 let tx = self.msg_tx.clone();
736 let workdir = self.workdir.clone();
737 self.detached.spawn(async move {
738 match crate::session::ConversationManager::new(&workdir) {
739 Ok(mgr) => match mgr.load_conversation(&id) {
740 Ok(history) => {
741 let _ = tx.send(Msg::ConversationLoaded(history)).await;
742 },
743 Err(e) => {
744 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
745 },
746 },
747 Err(e) => {
748 tracing::warn!(error = %e, "ConversationManager init failed");
749 },
750 }
751 });
752 },
753 Cmd::ListConversations => {
754 let tx = self.msg_tx.clone();
755 let workdir = self.workdir.clone();
756 self.detached.spawn(async move {
757 let summaries = match crate::session::ConversationManager::new(&workdir) {
758 Ok(mgr) => mgr
759 .list_conversations()
760 .unwrap_or_default()
761 .into_iter()
762 .map(|h| crate::domain::ConversationSummary {
763 id: h.id.clone(),
764 title: h.title.clone(),
765 message_count: h.messages.len(),
766 updated_at: h.updated_at.to_rfc3339(),
767 })
768 .collect(),
769 Err(_) => Vec::new(),
770 };
771 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
772 });
773 },
774 Cmd::ListRuntimeTasks { limit } => {
775 let tx = self.msg_tx.clone();
776 self.detached.spawn_blocking(move || {
779 let tasks = crate::runtime::RuntimeClient::auto()
780 .list_tasks(limit)
781 .map(|read| read.value)
782 .unwrap_or_default();
783 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
784 });
785 },
786 Cmd::LoadRuntimeTask { id } => {
787 let tx = self.msg_tx.clone();
788 self.detached.spawn_blocking(move || {
789 let (task, events) = crate::runtime::RuntimeClient::auto()
790 .task_detail(&id)
791 .map(|read| (Some(read.value.task), read.value.events))
792 .unwrap_or((None, Vec::new()));
793 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
794 });
795 },
796 Cmd::ListRuntimeProcesses { limit } => {
797 let tx = self.msg_tx.clone();
798 self.detached.spawn_blocking(move || {
799 let processes = crate::runtime::RuntimeClient::auto()
800 .list_processes(limit)
801 .map(|read| read.value)
802 .unwrap_or_default();
803 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
804 });
805 },
806 Cmd::ShowRuntimeProcessLogs { id } => {
807 let tx = self.msg_tx.clone();
808 self.detached.spawn_blocking(move || {
809 let text = crate::runtime::RuntimeClient::auto()
810 .process_log(&id, None)
811 .map(|log| format!("Process log {}\n\n{}", id, log.content))
812 .unwrap_or_else(|err| format!("Process log error: {}", err));
813 let _ = tx.blocking_send(Msg::RuntimeText(text));
814 });
815 },
816 Cmd::StopRuntimeProcess { id } => {
817 let tx = self.msg_tx.clone();
818 self.detached.spawn_blocking(move || {
819 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
820 Ok(response) => Msg::TransientStatus {
821 text: format!("Stopped process {} (pid {})", id, response.item.pid),
822 kind: crate::domain::StatusKind::Info,
823 dismiss_ms: 3_000,
824 },
825 Err(err) => Msg::TransientStatus {
826 text: format!("Process stop failed: {}", err),
827 kind: crate::domain::StatusKind::Warn,
828 dismiss_ms: 5_000,
829 },
830 };
831 let _ = tx.blocking_send(msg);
832 });
833 },
834 Cmd::RestartRuntimeProcess { id } => {
835 let tx = self.msg_tx.clone();
836 self.detached.spawn_blocking(move || {
837 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
838 Ok(response) => Msg::TransientStatus {
839 text: format!("Restarted process {} (pid {})", id, response.item.pid),
840 kind: crate::domain::StatusKind::Info,
841 dismiss_ms: 3_000,
842 },
843 Err(err) => Msg::TransientStatus {
844 text: format!("Process restart failed: {}", err),
845 kind: crate::domain::StatusKind::Warn,
846 dismiss_ms: 5_000,
847 },
848 };
849 let _ = tx.blocking_send(msg);
850 });
851 },
852 Cmd::OpenRuntimeTarget { target } => {
853 self.detached.spawn_blocking(move || {
854 let resolved = crate::runtime::RuntimeService::open_default()
855 .and_then(|service| service.resolve_open_target(&target))
856 .unwrap_or(target);
857 if let Err(err) = crate::runtime::validate_open_target(&resolved) {
861 tracing::warn!(error = %err, "refusing to open runtime target");
862 return;
863 }
864 crate::utils::open_file(resolved);
865 });
866 },
867 Cmd::ShowRuntimePorts => {
868 let tx = self.msg_tx.clone();
869 self.detached.spawn_blocking(move || {
870 let text = crate::runtime::RuntimeClient::auto()
871 .ports()
872 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
873 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
874 let _ = tx.blocking_send(Msg::RuntimeText(text));
875 });
876 },
877 Cmd::ListRuntimeApprovals => {
878 let tx = self.msg_tx.clone();
879 self.detached.spawn_blocking(move || {
880 let approvals = crate::runtime::RuntimeClient::auto()
881 .list_approvals()
882 .map(|read| read.value)
883 .unwrap_or_default();
884 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
885 });
886 },
887 Cmd::DecideRuntimeApproval { id, decision } => {
888 let tx = self.msg_tx.clone();
889 self.detached.spawn_blocking(move || {
890 let result = if decision == "approved" {
891 crate::runtime::RuntimeClient::auto().approve(&id)
892 } else {
893 crate::runtime::RuntimeClient::auto().deny(&id)
894 };
895 let msg = match result {
896 Ok(result) => Msg::TransientStatus {
897 text: if result.replayed {
898 format!("Approval {} {}: {}", id, decision, result.summary)
899 } else {
900 format!("Approval {} {}", id, decision)
901 },
902 kind: crate::domain::StatusKind::Info,
903 dismiss_ms: 3_000,
904 },
905 Err(err) => Msg::TransientStatus {
906 text: format!("Approval update failed: {}", err),
907 kind: crate::domain::StatusKind::Warn,
908 dismiss_ms: 5_000,
909 },
910 };
911 let _ = tx.blocking_send(msg);
912 });
913 },
914 Cmd::ListRuntimeCheckpoints { limit } => {
915 let tx = self.msg_tx.clone();
916 self.detached.spawn_blocking(move || {
917 let checkpoints = crate::runtime::RuntimeClient::auto()
918 .list_checkpoints(limit)
919 .map(|read| read.value)
920 .unwrap_or_default();
921 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
922 });
923 },
924 Cmd::ListRuntimePlugins => {
925 let tx = self.msg_tx.clone();
926 self.detached.spawn_blocking(move || {
927 let plugins = crate::runtime::RuntimeClient::auto()
928 .list_plugins()
929 .map(|read| read.value)
930 .unwrap_or_default();
931 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
932 });
933 },
934 Cmd::UpdateRuntimeTaskStatus {
935 id,
936 status,
937 final_report,
938 } => {
939 let tx = self.msg_tx.clone();
940 self.detached.spawn_blocking(move || {
941 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
942 store
943 .tasks()
944 .update_status(&id, status, final_report.as_deref())
945 }) {
946 Ok(()) => Msg::TransientStatus {
947 text: format!("Task {} -> {}", id, status),
948 kind: crate::domain::StatusKind::Info,
949 dismiss_ms: 3_000,
950 },
951 Err(err) => Msg::TransientStatus {
952 text: format!("Task update failed: {}", err),
953 kind: crate::domain::StatusKind::Warn,
954 dismiss_ms: 4_000,
955 },
956 };
957 let _ = tx.blocking_send(msg);
958 });
959 },
960 Cmd::CreateRuntimeCheckpoint { paths } => {
961 let tx = self.msg_tx.clone();
962 let workdir = self.workdir.clone();
963 self.detached.spawn_blocking(move || {
964 let pending_action = Some(serde_json::json!({
965 "source": "tui",
966 "command": "checkpoint",
967 }));
968 let msg =
969 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
970 Ok(manifest) => Msg::TransientStatus {
971 text: format!(
972 "Checkpoint {} created for {} path(s)",
973 manifest.id,
974 manifest.files.len()
975 ),
976 kind: crate::domain::StatusKind::Info,
977 dismiss_ms: 4_000,
978 },
979 Err(err) => Msg::TransientStatus {
980 text: format!("Checkpoint failed: {}", err),
981 kind: crate::domain::StatusKind::Warn,
982 dismiss_ms: 5_000,
983 },
984 };
985 let _ = tx.blocking_send(msg);
986 });
987 },
988 Cmd::RestoreRuntimeCheckpoint { id } => {
989 let tx = self.msg_tx.clone();
990 self.detached.spawn_blocking(move || {
991 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
992 Ok(result) => Msg::TransientStatus {
993 text: format!(
994 "Restored checkpoint {} ({} file(s)){}",
995 result.checkpoint.id,
996 result.checkpoint.files.len(),
997 if result.checkpoint.pending_action.is_some() {
998 "; pending action available in checkpoint manifest"
999 } else {
1000 ""
1001 }
1002 ),
1003 kind: crate::domain::StatusKind::Info,
1004 dismiss_ms: 4_000,
1005 },
1006 Err(err) => Msg::TransientStatus {
1007 text: format!("Restore failed: {}", err),
1008 kind: crate::domain::StatusKind::Warn,
1009 dismiss_ms: 5_000,
1010 },
1011 };
1012 let _ = tx.blocking_send(msg);
1013 });
1014 },
1015 Cmd::ShowRuntimeModelInfo { model } => {
1016 let tx = self.msg_tx.clone();
1017 self.detached.spawn_blocking(move || {
1018 let text = runtime_model_info_text(&model);
1019 let _ = tx.blocking_send(Msg::RuntimeText(text));
1020 });
1021 },
1022 Cmd::InitMcpServers(configs) => {
1023 let tx = self.msg_tx.clone();
1024 self.detached.spawn(async move {
1025 if configs.is_empty() {
1026 return;
1027 }
1028 crate::mcp::manager_ref::mark_init_started();
1029 let manager =
1030 std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1031 for (name, _cfg) in configs.iter() {
1035 let server_tools: Vec<crate::domain::McpToolSpec> = manager
1036 .get_all_tools()
1037 .iter()
1038 .filter(|(server, _)| server == name)
1039 .map(|(_, def)| crate::domain::McpToolSpec {
1040 name: def.name.clone(),
1041 description: def.description.clone(),
1042 input_schema: def.input_schema.clone(),
1043 })
1044 .collect();
1045 let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1046 let _ = tx.send(msg).await;
1047 }
1048 crate::mcp::manager_ref::set_manager(manager);
1049 crate::mcp::manager_ref::mark_init_complete();
1050 });
1051 },
1052 Cmd::StopMcpServer { name } => {
1053 let tx = self.msg_tx.clone();
1054 self.detached.spawn(async move {
1055 if let Some(mgr) = crate::mcp::manager_ref::get() {
1058 mgr.stop_server(&name).await;
1059 }
1060 let _ = tx.send(Msg::McpServerStopped { name }).await;
1061 });
1062 },
1063 Cmd::PullOllamaModel { model } => {
1064 let tx = self.msg_tx.clone();
1065 self.detached.spawn(async move {
1066 dispatch_pull_ollama_model(tx, model).await;
1067 });
1068 },
1069 Cmd::OpenInSystem(path) => {
1070 self.detached.spawn(async move {
1071 let _ = tokio::task::spawn_blocking(move || {
1072 crate::utils::open_file(&path);
1073 })
1074 .await;
1075 });
1076 },
1077 Cmd::DismissStatusAfter { ms } => {
1078 let tx = self.msg_tx.clone();
1079 self.detached.spawn(async move {
1080 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1081 let _ = tx.send(Msg::StatusDismiss).await;
1082 });
1083 },
1084 Cmd::WriteImageToTemp {
1085 path,
1086 bytes,
1087 format: _,
1088 } => {
1089 self.detached.spawn(async move {
1090 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1091 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1092 }
1093 });
1094 },
1095 Cmd::ReadClipboard => {
1096 let tx = self.msg_tx.clone();
1097 self.detached.spawn(async move {
1098 dispatch_read_clipboard(tx).await;
1099 });
1100 },
1101 Cmd::CopyToClipboard(text) => {
1102 let tx = self.msg_tx.clone();
1103 self.detached.spawn(async move {
1104 dispatch_copy_to_clipboard(text, tx).await;
1105 });
1106 },
1107 Cmd::Exit => {
1108 },
1113 Cmd::SetTerminalTitle(title) => {
1114 if !self.terminal_title_enabled {
1115 return;
1116 }
1117 self.detached.spawn_blocking(move || {
1123 use std::io::Write;
1124 let seq = format!("\x1b]2;{}\x07", title);
1125 let mut stdout = std::io::stdout();
1126 let _ = stdout.write_all(seq.as_bytes());
1127 let _ = stdout.flush();
1128 });
1129 },
1130 }
1131 }
1132
1133 pub async fn shutdown(mut self) {
1137 for (id, scope) in self.scopes.iter() {
1138 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1139 scope.cancel();
1140 }
1141
1142 if let Some(handle) = self.config_watch.take() {
1145 handle.abort();
1146 }
1147
1148 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1150
1151 let drain = async {
1152 let _ = tokio::time::timeout(
1157 std::time::Duration::from_secs(2),
1158 crate::mcp::manager_ref::wait_ready(),
1159 )
1160 .await;
1161 if let Some(mgr) = crate::mcp::manager_ref::get() {
1167 mgr.shutdown().await;
1168 }
1169 for (_, mut scope) in self.scopes.drain() {
1170 scope.drain().await;
1171 }
1172 while let Some(result) = self.detached.join_next().await {
1173 if let Err(e) = result
1174 && !e.is_cancelled()
1175 {
1176 tracing::warn!(error = %e, "shutdown: detached task panic");
1177 }
1178 }
1179 };
1180
1181 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1182 }
1183
1184 #[doc(hidden)]
1187 pub fn msg_sender(&self) -> MsgSender {
1188 self.msg_tx.clone()
1189 }
1190}
1191
1192async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1201 if let Err(e) = handle.await
1202 && !e.is_cancelled()
1203 {
1204 tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1205 }
1206}
1207
1208async fn dispatch_call_model(
1209 msg_tx: MsgSender,
1210 providers: Option<Arc<ProviderFactory>>,
1211 turn: TurnId,
1212 mut request: crate::domain::ChatRequest,
1213 token: tokio_util::sync::CancellationToken,
1214) {
1215 use crate::models::UserFacingError;
1216
1217 let Some(factory) = providers else {
1218 let error = UserFacingError {
1219 summary: "not wired".to_string(),
1220 message: "EffectRunner has no ProviderFactory bound".to_string(),
1221 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1222 category: crate::models::ErrorCategory::Internal,
1223 recoverable: false,
1224 };
1225 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1226 return;
1227 };
1228
1229 let provider = match factory.resolve(&request.model_id).await {
1231 Ok(p) => p,
1232 Err(e) => {
1233 let error = classify_error_for_ui(&e);
1234 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1235 return;
1236 },
1237 };
1238 {
1239 let model_id = request.model_id.clone();
1243 let caps = provider.capabilities().clone();
1244 tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps));
1245 }
1246 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1247 let _ = msg_tx
1248 .send(Msg::TransientStatus {
1249 text: format!(
1250 "{} does not advertise tool support; Mermaid will send the turn without tools",
1251 request.model_id
1252 ),
1253 kind: crate::domain::StatusKind::Warn,
1254 dismiss_ms: 6_000,
1255 })
1256 .await;
1257 request.tools.clear();
1258 }
1259
1260 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1261 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1262 });
1263 let context_snapshot =
1264 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1265 let _ = msg_tx
1266 .send(Msg::ContextUsageEstimated {
1267 turn,
1268 snapshot: context_snapshot.clone(),
1269 })
1270 .await;
1271
1272 let policy = CompactionPolicy::default();
1273 let mut compacted_before_stream = false;
1274 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1275 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1276 match run_compaction(
1277 Arc::clone(&provider),
1278 turn,
1279 compaction,
1280 context_snapshot.clone(),
1281 max_context_tokens,
1282 token.clone(),
1283 )
1284 .await
1285 {
1286 Ok(result) => {
1287 request.messages = result.replacement_messages.clone();
1288 compacted_before_stream = true;
1289 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1290 },
1291 Err(err) => {
1292 if token.is_cancelled() {
1300 return;
1301 }
1302 tracing::warn!(
1303 turn = %turn,
1304 error = %err,
1305 "auto-compaction failed; proceeding with the un-compacted request",
1306 );
1307 let _ = msg_tx
1308 .send(Msg::CompactionFailed {
1309 turn,
1310 trigger: CompactionTrigger::AutoThreshold,
1311 message: err.to_string(),
1312 kind: crate::domain::StatusKind::Warn,
1313 })
1314 .await;
1315 },
1316 }
1317 }
1318
1319 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1322 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1323
1324 let relay_tx = msg_tx.clone();
1331 let relay_token = token.clone();
1332 let relay = tokio::spawn(async move {
1333 loop {
1334 let event = tokio::select! {
1335 biased;
1336 _ = relay_token.cancelled() => break,
1337 ev = stream_rx.recv() => match ev {
1338 Some(ev) => ev,
1339 None => break,
1340 },
1341 };
1342 let msg = match event {
1343 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1344 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1345 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1346 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1348 usage,
1349 thinking_signature,
1350 stop_reason,
1351 } => Msg::StreamDone {
1352 turn,
1353 usage,
1354 thinking_signature,
1355 stop_reason,
1356 },
1357 };
1358 if relay_tx.send(msg).await.is_err() {
1359 break;
1360 }
1361 }
1362 });
1363
1364 match provider.chat(request.clone(), ctx).await {
1373 Ok(_final_response) => {
1374 },
1376 Err(crate::models::ModelError::Cancelled) => {
1377 },
1379 Err(e) => {
1380 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1381 if retry_context_limit {
1382 let latest_snapshot =
1383 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1384 let compaction =
1385 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1386 match run_compaction(
1387 Arc::clone(&provider),
1388 turn,
1389 compaction,
1390 latest_snapshot,
1391 max_context_tokens,
1392 token.clone(),
1393 )
1394 .await
1395 {
1396 Ok(result) => {
1397 let mut retry_request = request;
1398 retry_request.messages = result.replacement_messages.clone();
1399 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1400 join_logged(relay, "stream_relay").await;
1401 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1402 .await;
1403 return;
1404 },
1405 Err(compact_err) => {
1406 let _ = msg_tx
1407 .send(Msg::CompactionFailed {
1408 turn,
1409 trigger: CompactionTrigger::ContextLimitRetry,
1410 message: compact_err.to_string(),
1411 kind: crate::domain::StatusKind::Error,
1412 })
1413 .await;
1414 },
1415 }
1416 }
1417 let error = classify_error_for_ui(&e);
1418 run_provider_error_hook(&request.model_id, &error).await;
1419 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1420 },
1421 }
1422
1423 join_logged(relay, "stream_relay").await;
1424}
1425
1426async fn dispatch_provider_stream(
1427 msg_tx: MsgSender,
1428 provider: Arc<dyn ModelProvider>,
1429 turn: TurnId,
1430 request: crate::domain::ChatRequest,
1431 token: tokio_util::sync::CancellationToken,
1432) {
1433 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1434 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1435 let relay_tx = msg_tx.clone();
1436 let relay_token = token.clone();
1437 let relay = tokio::spawn(async move {
1438 loop {
1439 let event = tokio::select! {
1440 biased;
1441 _ = relay_token.cancelled() => break,
1442 ev = stream_rx.recv() => match ev {
1443 Some(ev) => ev,
1444 None => break,
1445 },
1446 };
1447 let msg = match event {
1448 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1449 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1450 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1451 StreamEvent::ThinkingSignature(_) => continue,
1452 StreamEvent::Done {
1453 usage,
1454 thinking_signature,
1455 stop_reason,
1456 } => Msg::StreamDone {
1457 turn,
1458 usage,
1459 thinking_signature,
1460 stop_reason,
1461 },
1462 };
1463 if relay_tx.send(msg).await.is_err() {
1464 break;
1465 }
1466 }
1467 });
1468
1469 let model_id = request.model_id.clone();
1470 match provider.chat(request, ctx).await {
1471 Ok(_) | Err(ModelError::Cancelled) => {},
1472 Err(e) => {
1473 let error = classify_error_for_ui(&e);
1474 run_provider_error_hook(&model_id, &error).await;
1475 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1476 },
1477 }
1478
1479 join_logged(relay, "stream_relay").await;
1480}
1481
1482async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1488 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1489 .await;
1490}
1491
1492async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1493 fire_plugin_hooks(
1494 "provider_error",
1495 serde_json::json!({
1496 "model_id": model_id,
1497 "summary": &error.summary,
1498 "message": &error.message,
1499 "category": format!("{:?}", error.category),
1500 "recoverable": error.recoverable,
1501 }),
1502 )
1503 .await;
1504}
1505
1506fn memory_title_from_text(text: &str) -> String {
1510 let first = text
1511 .lines()
1512 .find(|l| !l.trim().is_empty())
1513 .unwrap_or("memory")
1514 .trim();
1515 let title: String = first
1516 .split_whitespace()
1517 .take(8)
1518 .collect::<Vec<_>>()
1519 .join(" ")
1520 .chars()
1521 .take(60)
1522 .collect();
1523 if title.trim().is_empty() {
1524 "memory".to_string()
1525 } else {
1526 title
1527 }
1528}
1529
1530const CONSOLIDATE_SYSTEM_PROMPT: &str = "You maintain a coding agent's durable memory: a set of atomic facts. Your only job is to find facts that are EXACT DUPLICATES or CLEARLY OBSOLETE/SUPERSEDED by another fact, and list their ids for pruning. Never prune facts that are merely related or similar but carry distinct information. Never rewrite or merge facts. When in doubt, keep. Reply with ONLY a JSON object: {\"prune\": [\"id1\", \"id2\"], \"reason\": \"one short sentence\"}. If nothing should be pruned, return an empty prune list.";
1531
1532#[derive(Debug)]
1533struct PrunePlan {
1534 prune: Vec<String>,
1535 reason: String,
1536}
1537
1538fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1541 let start = text.find('{')?;
1542 let end = text.rfind('}')?;
1543 if end < start {
1544 return None;
1545 }
1546 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1547 let prune = json
1548 .get("prune")?
1549 .as_array()?
1550 .iter()
1551 .filter_map(|v| v.as_str().map(str::to_string))
1552 .collect();
1553 let reason = json
1554 .get("reason")
1555 .and_then(|v| v.as_str())
1556 .unwrap_or("")
1557 .to_string();
1558 Some(PrunePlan { prune, reason })
1559}
1560
1561async fn consolidate_memory(
1565 tx: MsgSender,
1566 providers: Option<Arc<ProviderFactory>>,
1567 workdir: std::path::PathBuf,
1568 model_id: String,
1569) {
1570 let items = crate::app::memory::entries_with_bodies(&workdir);
1571 if items.len() < 2 {
1572 let _ = tx
1573 .send(Msg::RuntimeText(format!(
1574 "Nothing to consolidate — {} memor{} saved.",
1575 items.len(),
1576 if items.len() == 1 { "y" } else { "ies" }
1577 )))
1578 .await;
1579 return;
1580 }
1581 let Some(factory) = providers else {
1582 let _ = tx
1583 .send(Msg::RuntimeText(
1584 "Memory consolidation needs a model provider, which isn't bound in this session."
1585 .to_string(),
1586 ))
1587 .await;
1588 return;
1589 };
1590
1591 let mut listing = String::new();
1592 for (entry, body) in &items {
1593 let id = entry
1594 .path
1595 .file_stem()
1596 .and_then(|s| s.to_str())
1597 .unwrap_or(entry.name.as_str());
1598 listing.push_str(&format!(
1599 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
1600 entry.scope.as_str(),
1601 entry.description,
1602 body.replace('\n', " ").trim(),
1603 ));
1604 }
1605 let user = format!(
1606 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1607 items.len(),
1608 listing
1609 );
1610 let request = crate::domain::ChatRequest {
1611 model_id: model_id.clone(),
1612 messages: vec![crate::models::ChatMessage::user(user)],
1613 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1614 instructions: None,
1615 reasoning: crate::models::ReasoningLevel::None,
1616 temperature: 0.0,
1617 max_tokens: 1024,
1618 tools: Vec::new(),
1619 };
1620
1621 let provider = match factory.resolve(&model_id).await {
1622 Ok(p) => p,
1623 Err(e) => {
1624 let _ = tx
1625 .send(Msg::RuntimeText(format!(
1626 "Memory consolidation failed: {e}"
1627 )))
1628 .await;
1629 return;
1630 },
1631 };
1632 let token = tokio_util::sync::CancellationToken::new();
1633 let text =
1634 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1635 Ok((t, _)) => t,
1636 Err(e) => {
1637 let _ = tx
1638 .send(Msg::RuntimeText(format!(
1639 "Memory consolidation failed: {e}"
1640 )))
1641 .await;
1642 return;
1643 },
1644 };
1645
1646 let Some(plan) = parse_prune_plan(&text) else {
1647 let _ = tx
1648 .send(Msg::RuntimeText(
1649 "Memory consolidation: couldn't parse the model's plan; nothing changed."
1650 .to_string(),
1651 ))
1652 .await;
1653 return;
1654 };
1655 if plan.prune.is_empty() {
1656 let reason = if plan.reason.is_empty() {
1657 String::new()
1658 } else {
1659 format!(" {}", plan.reason)
1660 };
1661 let _ = tx
1662 .send(Msg::RuntimeText(format!(
1663 "Memory consolidation: nothing to prune.{reason}"
1664 )))
1665 .await;
1666 return;
1667 }
1668
1669 let paths: Vec<std::path::PathBuf> = plan
1671 .prune
1672 .iter()
1673 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1674 .collect();
1675 if !paths.is_empty() {
1676 let _ = crate::runtime::create_checkpoint(
1677 &workdir,
1678 &paths,
1679 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1680 );
1681 }
1682
1683 let mut pruned = Vec::new();
1684 for id in &plan.prune {
1685 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1686 pruned.push(id.clone());
1687 }
1688 }
1689
1690 let cfg = crate::app::load_config().unwrap_or_default().memory;
1691 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1692 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1693
1694 let report = if pruned.is_empty() {
1695 "Memory consolidation: the model named facts to prune, but none matched existing memories."
1696 .to_string()
1697 } else {
1698 format!(
1699 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1700 pruned.len(),
1701 if pruned.len() == 1 { "" } else { "s" },
1702 pruned.join(", "),
1703 if plan.reason.is_empty() {
1704 String::new()
1705 } else {
1706 format!(" Reason: {}.", plan.reason)
1707 },
1708 )
1709 };
1710 let _ = tx.send(Msg::RuntimeText(report)).await;
1711}
1712
1713async fn dispatch_compact_conversation(
1714 msg_tx: MsgSender,
1715 providers: Option<Arc<ProviderFactory>>,
1716 turn: TurnId,
1717 request: CompactionRequest,
1718 token: tokio_util::sync::CancellationToken,
1719) {
1720 let Some(factory) = providers else {
1721 let _ = msg_tx
1722 .send(Msg::CompactionFailed {
1723 turn,
1724 trigger: request.trigger,
1725 message: "EffectRunner has no ProviderFactory bound".to_string(),
1726 kind: crate::domain::StatusKind::Error,
1727 })
1728 .await;
1729 return;
1730 };
1731
1732 let provider = match factory.resolve(&request.chat.model_id).await {
1733 Ok(provider) => provider,
1734 Err(err) => {
1735 let _ = msg_tx
1736 .send(Msg::CompactionFailed {
1737 turn,
1738 trigger: request.trigger,
1739 message: err.to_string(),
1740 kind: crate::domain::StatusKind::Error,
1741 })
1742 .await;
1743 return;
1744 },
1745 };
1746
1747 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1748 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1749 });
1750 let before_snapshot =
1751 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1752
1753 let trigger = request.trigger;
1754 match run_compaction(
1755 provider,
1756 turn,
1757 request,
1758 before_snapshot,
1759 max_context_tokens,
1760 token,
1761 )
1762 .await
1763 {
1764 Ok(result) => {
1765 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1766 },
1767 Err(err) => {
1768 let _ = msg_tx
1769 .send(Msg::CompactionFailed {
1770 turn,
1771 trigger,
1772 message: err.to_string(),
1773 kind: crate::domain::StatusKind::Error,
1774 })
1775 .await;
1776 },
1777 }
1778}
1779
1780async fn run_compaction(
1781 provider: Arc<dyn ModelProvider>,
1782 turn: TurnId,
1783 request: CompactionRequest,
1784 before_snapshot: crate::domain::ContextUsageSnapshot,
1785 max_context_tokens: Option<usize>,
1786 token: tokio_util::sync::CancellationToken,
1787) -> Result<CompactionResult, ModelError> {
1788 let started = Instant::now();
1789 let prepared = crate::domain::prepare_compaction(&request, max_context_tokens)
1790 .map_err(|skip| ModelError::InvalidRequest(skip.to_string()))?;
1791
1792 let summary_request = crate::domain::build_summary_request(
1793 &request.chat,
1794 &prepared,
1795 request.instructions.as_deref(),
1796 request.policy,
1797 );
1798 let (draft, draft_usage) =
1799 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1800 .await?;
1801 let draft_summary = crate::domain::normalize_summary(&draft);
1802 if draft_summary.trim().is_empty() {
1803 return Err(ModelError::InvalidRequest(
1804 "compaction produced an empty summary".to_string(),
1805 ));
1806 }
1807
1808 let verify_request = crate::domain::build_verification_request(
1809 &request.chat,
1810 &prepared,
1811 &draft_summary,
1812 request.instructions.as_deref(),
1813 request.policy,
1814 );
1815 let (final_summary, verify_usage, verified, verification_error) =
1816 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1817 Ok((verified_text, verify_usage)) => {
1818 let verified_summary = crate::domain::normalize_summary(&verified_text);
1819 if verified_summary.trim().is_empty() {
1820 (
1821 draft_summary,
1822 verify_usage,
1823 false,
1824 Some("verification returned an empty summary".to_string()),
1825 )
1826 } else {
1827 (verified_summary, verify_usage, true, None)
1828 }
1829 },
1830 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1831 Err(err) => (draft_summary, None, false, Some(err.to_string())),
1832 };
1833
1834 let id = format!(
1835 "compact_{}",
1836 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1837 );
1838 let mut record = crate::domain::CompactionRecord {
1839 id,
1840 trigger: request.trigger,
1841 created_at: chrono::Local::now(),
1842 before_tokens: before_snapshot.used_tokens,
1843 after_tokens: 0,
1844 archived_message_count: prepared.archived_messages.len(),
1845 preserved_message_count: prepared.preserved_messages.len(),
1846 summary_tokens: final_summary.len().div_ceil(4),
1847 duration_secs: started.elapsed().as_secs_f64(),
1848 verified,
1849 verification_error,
1850 focus: request.instructions.clone(),
1851 archive_path: None,
1852 };
1853
1854 let mut replacement =
1855 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1856 let mut compacted_request = request.chat.clone();
1857 compacted_request.messages = replacement.clone();
1858 let mut after_snapshot =
1859 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1860 record.after_tokens = after_snapshot.used_tokens;
1861 record.duration_secs = started.elapsed().as_secs_f64();
1862 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1863 compacted_request.messages = replacement.clone();
1864 after_snapshot =
1865 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1866 record.after_tokens = after_snapshot.used_tokens;
1867
1868 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1869 return Err(ModelError::InvalidRequest(format!(
1870 "compaction did not reduce context ({} -> {} tokens)",
1871 before_snapshot.used_tokens, after_snapshot.used_tokens
1872 )));
1873 }
1874
1875 if crate::domain::context_exceeds_hard_limit(
1876 &after_snapshot,
1877 &compacted_request,
1878 request.policy,
1879 ) {
1880 return Err(ModelError::InvalidRequest(format!(
1881 "compacted context still exceeds response reserve ({} tokens used)",
1882 after_snapshot.used_tokens
1883 )));
1884 }
1885
1886 Ok(CompactionResult {
1887 record,
1888 replacement_messages: replacement,
1889 archived_messages: prepared.archived_messages,
1890 before_snapshot,
1891 after_snapshot,
1892 usage: crate::domain::combine_usage(draft_usage, verify_usage),
1893 })
1894}
1895
1896async fn collect_compaction_text(
1897 provider: Arc<dyn ModelProvider>,
1898 turn: TurnId,
1899 request: crate::domain::ChatRequest,
1900 token: tokio_util::sync::CancellationToken,
1901) -> Result<(String, Option<TokenUsage>), ModelError> {
1902 crate::providers::model::collect_text(provider, turn, request, token).await
1905}
1906
1907fn record_provider_capabilities(
1908 model_id: &str,
1909 caps: &crate::providers::capabilities::Capabilities,
1910) {
1911 let (provider, model) = split_model_id(model_id);
1912 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1913 for (key, value) in [
1914 ("tools_support", caps.supports_tools.to_string()),
1915 ("vision_support", caps.supports_vision.to_string()),
1916 (
1917 "context_limit",
1918 caps.max_context_tokens
1919 .map(|v| v.to_string())
1920 .unwrap_or_else(|| "unknown".to_string()),
1921 ),
1922 (
1923 "reasoning_parameter_shape",
1924 format!("{:?}", caps.supports_reasoning),
1925 ),
1926 (
1927 "streaming_usage_available",
1928 "provider_dependent".to_string(),
1929 ),
1930 ("token_usage_field_shape", "normalized".to_string()),
1931 ] {
1932 let _ = store
1933 .provider_probes()
1934 .upsert(crate::runtime::NewProviderProbe {
1935 provider: provider.clone(),
1936 model_id: model.clone(),
1937 capability_key: key.to_string(),
1938 capability_value: value,
1939 confidence: "verified".to_string(),
1940 error: None,
1941 });
1942 }
1943 }
1944}
1945
1946fn split_model_id(model_id: &str) -> (String, String) {
1947 match model_id.split_once('/') {
1948 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
1949 (provider.to_ascii_lowercase(), model.to_string())
1950 },
1951 _ => ("ollama".to_string(), model_id.to_string()),
1952 }
1953}
1954
1955fn is_context_limit_error(error: &ModelError) -> bool {
1956 let text = error.to_string().to_lowercase();
1957 text.contains("context")
1958 && (text.contains("too large")
1959 || text.contains("exceed")
1960 || text.contains("maximum")
1961 || text.contains("token"))
1962}
1963
1964#[allow(clippy::too_many_arguments)]
1966async fn dispatch_execute_tool(
1967 msg_tx: MsgSender,
1968 tools: Option<Arc<ToolRegistry>>,
1969 workdir: PathBuf,
1970 turn: TurnId,
1971 call_id: crate::domain::ToolCallId,
1972 source: crate::models::tool_call::ToolCall,
1973 token: tokio_util::sync::CancellationToken,
1974 background: tokio_util::sync::CancellationToken,
1975 config: Arc<crate::app::Config>,
1976 model_id: String,
1977 task_id: Option<String>,
1978 safety_mode: crate::runtime::SafetyMode,
1979 intent: Option<String>,
1980 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
1981 approval: Option<crate::providers::ApprovalBroker>,
1982) {
1983 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
1984
1985 let Some(registry) = tools else {
1986 let _ = msg_tx
1987 .send(Msg::ToolFinished {
1988 turn,
1989 call_id,
1990 outcome: crate::domain::ToolOutcome::error(
1991 "EffectRunner has no ToolRegistry bound",
1992 0.0,
1993 ),
1994 })
1995 .await;
1996 return;
1997 };
1998
1999 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2003 let rest = &source.function.name[5..];
2004 if let Some((server, tool)) = rest.split_once("__") {
2005 (
2006 "mcp_proxy",
2007 serde_json::json!({
2008 "server_name": server,
2009 "tool_name": tool,
2010 "arguments": source.function.arguments.clone(),
2011 }),
2012 )
2013 } else {
2014 let _ = msg_tx
2015 .send(Msg::ToolFinished {
2016 turn,
2017 call_id,
2018 outcome: crate::domain::ToolOutcome::error(
2019 format!("invalid MCP tool name: {}", source.function.name),
2020 0.0,
2021 ),
2022 })
2023 .await;
2024 return;
2025 }
2026 } else {
2027 (
2028 source.function.name.as_str(),
2029 source.function.arguments.clone(),
2030 )
2031 };
2032 let tool_run_id =
2033 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2034
2035 let Some(tool) = registry.get(tool_key) else {
2036 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2037 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2038 let _ = msg_tx
2039 .send(Msg::ToolFinished {
2040 turn,
2041 call_id,
2042 outcome,
2043 })
2044 .await;
2045 return;
2046 };
2047
2048 let (progress_tx, mut progress_rx) = mpsc::channel(16);
2054 let relay_tx = msg_tx.clone();
2055 let relay_token = token.clone();
2056 let progress_relay = tokio::spawn(async move {
2057 loop {
2058 let event = tokio::select! {
2059 biased;
2060 _ = relay_token.cancelled() => break,
2061 ev = progress_rx.recv() => match ev {
2062 Some(ev) => ev,
2063 None => break,
2064 },
2065 };
2066 if relay_tx
2067 .send(Msg::ToolProgress {
2068 turn,
2069 call_id,
2070 event,
2071 })
2072 .await
2073 .is_err()
2074 {
2075 break;
2076 }
2077 }
2078 });
2079
2080 let mut ctx = ExecContext::new(
2081 token,
2082 progress_tx,
2083 call_id,
2084 turn,
2085 workdir,
2086 config,
2087 model_id,
2088 task_id,
2089 safety_mode,
2090 intent,
2091 classifier,
2092 approval,
2093 );
2094 ctx.background = background;
2095 let before_payload = serde_json::json!({
2096 "turn_id": turn.0,
2097 "call_id": call_id.0,
2098 "tool": tool_key,
2099 });
2100 fire_plugin_hooks("before_tool_use", before_payload).await;
2101 let outcome = tool.execute(args, ctx).await;
2102 let after_payload = serde_json::json!({
2103 "turn_id": turn.0,
2104 "call_id": call_id.0,
2105 "tool": tool_key,
2106 "status": tool_status_label(outcome.status),
2107 "summary": &outcome.summary,
2108 });
2109 fire_plugin_hooks("after_tool_use", after_payload).await;
2110 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2111 join_logged(progress_relay, "tool_progress_relay").await;
2112 let _ = msg_tx
2113 .send(Msg::ToolFinished {
2114 turn,
2115 call_id,
2116 outcome,
2117 })
2118 .await;
2119}
2120
2121async fn start_runtime_tool_run(
2122 task_id: Option<&str>,
2123 turn: TurnId,
2124 call_id: crate::domain::ToolCallId,
2125 tool_name: &str,
2126 args: &serde_json::Value,
2127) -> Option<String> {
2128 let task_id = task_id.map(str::to_string);
2132 let tool_name = tool_name.to_string();
2133 let args_json = serde_json::to_string(args).ok();
2134 tokio::task::spawn_blocking(move || {
2135 crate::runtime::RuntimeStore::open_default()
2136 .and_then(|store| {
2137 store.tool_runs().start(crate::runtime::NewToolRun {
2138 id: None,
2139 task_id,
2140 turn_id: Some(turn.0.to_string()),
2141 call_id: Some(call_id.0.to_string()),
2142 tool_name,
2143 args_json,
2144 })
2145 })
2146 .map(|record| record.id)
2147 .ok()
2148 })
2149 .await
2150 .ok()
2151 .flatten()
2152}
2153
2154fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2155 let Some(tool_run_id) = tool_run_id else {
2156 return;
2157 };
2158 let tool_run_id = tool_run_id.to_string();
2159 let status = tool_status_label(outcome.status).to_string();
2160 let output_json = serde_json::to_string(&serde_json::json!({
2161 "status": tool_status_label(outcome.status),
2162 "summary": &outcome.summary,
2163 "model_content": &outcome.model_content,
2164 "error": &outcome.error,
2165 "metadata": &outcome.metadata,
2166 "artifacts": &outcome.artifacts,
2167 "duration_secs": outcome.duration_secs,
2168 }))
2169 .ok();
2170 tokio::task::spawn_blocking(move || {
2173 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2174 let _ = store
2175 .tool_runs()
2176 .finish(&tool_run_id, &status, output_json.as_deref());
2177 }
2178 });
2179}
2180
2181fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2182 match status {
2183 crate::domain::ToolStatus::Success => "success",
2184 crate::domain::ToolStatus::Error => "error",
2185 crate::domain::ToolStatus::Cancelled => "cancelled",
2186 }
2187}
2188
2189fn runtime_model_info_text(model: &str) -> String {
2190 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2191 let mut lines = vec![
2192 format!("Model info: {}", model),
2193 format!("- provider: {}", snapshot.provider),
2194 format!("- model: {}", snapshot.model),
2195 format!("- supports tools: {}", snapshot.supports_tools),
2196 format!("- supports vision: {}", snapshot.supports_vision),
2197 format!("- reasoning: {}", snapshot.reasoning),
2198 format!(
2199 "- context limit: {}",
2200 snapshot
2201 .max_context_tokens
2202 .map(|value: usize| value.to_string())
2203 .unwrap_or_else(|| "unknown".to_string())
2204 ),
2205 ];
2206 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2207 && let Ok(probes) = store
2208 .provider_probes()
2209 .list(Some(&snapshot.provider), Some(&snapshot.model))
2210 && !probes.is_empty()
2211 {
2212 lines.push(String::new());
2213 lines.push("Cached provider reality records:".to_string());
2214 for probe in probes {
2215 lines.push(format!(
2216 "- {} = {} ({})",
2217 probe.capability_key, probe.capability_value, probe.confidence
2218 ));
2219 }
2220 }
2221 lines.join("\n")
2222}
2223
2224async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2229 use tokio::io::{AsyncBufReadExt, BufReader};
2230 use tokio::process::Command;
2231
2232 let mut cmd = Command::new("ollama");
2233 cmd.arg("pull")
2234 .arg(&model)
2235 .stdin(std::process::Stdio::null())
2236 .stdout(std::process::Stdio::piped())
2237 .stderr(std::process::Stdio::piped())
2238 .kill_on_drop(true);
2239
2240 let mut child = match cmd.spawn() {
2241 Ok(c) => c,
2242 Err(e) => {
2243 let _ = tx
2244 .send(Msg::ModelPullProgress(format!(
2245 "ollama pull failed to start: {}",
2246 e
2247 )))
2248 .await;
2249 return;
2250 },
2251 };
2252
2253 let reader_handle = child.stdout.take().map(|stdout| {
2257 let tx_inner = tx.clone();
2258 tokio::spawn(async move {
2259 let mut reader = BufReader::new(stdout).lines();
2260 while let Ok(Some(line)) = reader.next_line().await {
2261 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2262 }
2263 })
2264 });
2265
2266 match child.wait().await {
2267 Ok(status) if status.success() => {
2268 let _ = tx.send(Msg::ModelPullFinished { model }).await;
2269 },
2270 Ok(status) => {
2271 let _ = tx
2272 .send(Msg::ModelPullProgress(format!(
2273 "ollama pull exited with status {}",
2274 status.code().unwrap_or(-1)
2275 )))
2276 .await;
2277 },
2278 Err(e) => {
2279 let _ = tx
2280 .send(Msg::ModelPullProgress(format!(
2281 "ollama pull wait error: {}",
2282 e
2283 )))
2284 .await;
2285 },
2286 }
2287
2288 if let Some(handle) = reader_handle {
2291 join_logged(handle, "ollama_pull_reader").await;
2292 }
2293}
2294
2295fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2296 if started {
2297 Msg::McpServerReady {
2298 name: name.to_string(),
2299 tools,
2300 }
2301 } else {
2302 Msg::McpServerErrored {
2303 name: name.to_string(),
2304 reason: "server failed to start or initialize".to_string(),
2305 }
2306 }
2307}
2308
2309async fn dispatch_read_clipboard(tx: MsgSender) {
2319 use crate::domain::{Paste, StatusKind};
2320
2321 enum Outcome {
2322 Image { bytes: Vec<u8>, format: String },
2323 Text(String),
2324 Empty,
2325 Error(String),
2326 }
2327
2328 let outcome = tokio::task::spawn_blocking(|| {
2329 if crate::clipboard::has_image() {
2330 match crate::clipboard::read_image_bytes() {
2331 Ok((bytes, format)) => Outcome::Image { bytes, format },
2332 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2333 }
2334 } else {
2335 match crate::clipboard::read_text() {
2336 Ok(t) if !t.is_empty() => Outcome::Text(t),
2337 Ok(_) => Outcome::Empty,
2338 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2339 }
2340 }
2341 })
2342 .await
2343 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2344
2345 let msg = match outcome {
2346 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2347 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2348 Outcome::Empty => Msg::TransientStatus {
2349 text: "Clipboard is empty".to_string(),
2350 kind: StatusKind::Info,
2351 dismiss_ms: 2_000,
2352 },
2353 Outcome::Error(text) => Msg::TransientStatus {
2354 text,
2355 kind: StatusKind::Warn,
2356 dismiss_ms: 4_000,
2357 },
2358 };
2359 let _ = tx.send(msg).await;
2360}
2361
2362async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2365 use crate::domain::StatusKind;
2366
2367 let char_count = text.chars().count();
2368 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2369 .await
2370 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2371
2372 let msg = match result {
2373 Ok(()) => Msg::TransientStatus {
2374 text: format!("Copied {char_count} chars to clipboard"),
2375 kind: StatusKind::Info,
2376 dismiss_ms: 2_000,
2377 },
2378 Err(e) => Msg::TransientStatus {
2379 text: format!("Copy failed: {e}"),
2380 kind: StatusKind::Warn,
2381 dismiss_ms: 4_000,
2382 },
2383 };
2384 let _ = tx.send(msg).await;
2385}
2386
2387fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2388 use crate::models::{ErrorCategory, ModelError, UserFacingError};
2389 match e {
2390 ModelError::Backend(b) => UserFacingError {
2391 summary: "Backend error".to_string(),
2392 message: b.to_string(),
2393 suggestion: "Check the provider endpoint / API key.".to_string(),
2394 category: ErrorCategory::Connection,
2395 recoverable: true,
2396 },
2397 ModelError::Authentication(msg) => UserFacingError {
2398 summary: "Auth error".to_string(),
2399 message: msg.clone(),
2400 suggestion: "Set the env var the provider expects.".to_string(),
2401 category: ErrorCategory::Auth,
2402 recoverable: false,
2403 },
2404 ModelError::RateLimit { retry_after } => UserFacingError {
2405 summary: "Rate limit".to_string(),
2406 message: format!("retry after {:?}", retry_after),
2407 suggestion: "Wait and try again.".to_string(),
2408 category: ErrorCategory::Temporary,
2409 recoverable: true,
2410 },
2411 ModelError::StreamError(msg) => UserFacingError {
2412 summary: "Stream error".to_string(),
2413 message: msg.clone(),
2414 suggestion: "Retry the request.".to_string(),
2415 category: ErrorCategory::Connection,
2416 recoverable: true,
2417 },
2418 other => UserFacingError {
2419 summary: "Model error".to_string(),
2420 message: other.to_string(),
2421 suggestion: String::new(),
2422 category: ErrorCategory::Internal,
2423 recoverable: false,
2424 },
2425 }
2426}
2427
2428#[cfg(test)]
2429mod tests {
2430 use super::*;
2431 use crate::domain::ToolCallId;
2432 use std::time::Duration;
2433
2434 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2435 EffectRunner::pair(PathBuf::from("/tmp"))
2436 }
2437
2438 #[test]
2439 fn new_child_suppresses_terminal_title() {
2440 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2444 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2445 let tools = Arc::new(ToolRegistry::new());
2446 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2447 assert!(
2448 !child.terminal_title_enabled,
2449 "subagent child runner must suppress terminal-title escapes"
2450 );
2451 }
2452
2453 #[test]
2454 fn parse_prune_plan_extracts_json_amid_prose() {
2455 let plan = parse_prune_plan(
2456 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2457 )
2458 .expect("should parse");
2459 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2460 assert_eq!(plan.reason, "dupes");
2461 }
2462
2463 #[test]
2464 fn parse_prune_plan_handles_empty_and_garbage() {
2465 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2466 .expect("empty plan parses");
2467 assert!(empty.prune.is_empty());
2468 assert!(parse_prune_plan("no json here").is_none());
2469 }
2470
2471 #[test]
2472 fn memory_title_from_text_is_short_and_nonempty() {
2473 assert_eq!(
2474 memory_title_from_text("prefer ripgrep over grep"),
2475 "prefer ripgrep over grep"
2476 );
2477 assert_eq!(memory_title_from_text(" "), "memory");
2478 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2479 assert!(long.split_whitespace().count() <= 8);
2480 }
2481
2482 #[tokio::test]
2483 async fn dispatch_exit_is_noop_on_runner_state() {
2484 let (mut r, _rx) = runner();
2485 r.dispatch(Cmd::Exit);
2486 assert_eq!(r.scope_count(), 0);
2487 }
2488
2489 #[tokio::test]
2490 async fn dispatch_save_emits_session_saved() {
2491 let (mut r, mut rx) = runner();
2492 r.dispatch(Cmd::SaveConversation(
2493 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2494 ));
2495 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2496 .await
2497 .expect("sender emits")
2498 .expect("channel alive");
2499 assert!(matches!(msg, Msg::SessionSaved));
2500 }
2501
2502 #[tokio::test]
2503 async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2504 let (mut r, mut rx) = runner();
2505 let t0 = std::time::Instant::now();
2506 r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2507 let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2508 .await
2509 .expect("sender emits")
2510 .expect("channel alive");
2511 assert!(matches!(msg, Msg::StatusDismiss));
2512 assert!(t0.elapsed() >= Duration::from_millis(25));
2513 }
2514
2515 #[test]
2516 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2517 let msg = mcp_startup_msg("empty", true, Vec::new());
2518 assert!(matches!(
2519 msg,
2520 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2521 ));
2522 }
2523
2524 #[test]
2525 fn mcp_startup_msg_reports_unstarted_server_as_error() {
2526 let msg = mcp_startup_msg("bad", false, Vec::new());
2527 assert!(matches!(
2528 msg,
2529 Msg::McpServerErrored { name, reason }
2530 if name == "bad" && reason.contains("failed to start")
2531 ));
2532 }
2533
2534 #[tokio::test]
2535 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2536 let (mut r, mut rx) = runner();
2537 let turn = TurnId(77);
2538 {
2539 let scope = r.scope_mut(turn);
2540 scope.spawn(async {
2541 std::future::pending::<()>().await;
2542 });
2543 }
2544 assert_eq!(r.scope_count(), 1);
2545
2546 let start = std::time::Instant::now();
2547 r.dispatch(Cmd::CancelScope(turn));
2548 assert_eq!(r.scope_count(), 0);
2549 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2550 .await
2551 .expect("bounded cancel should emit terminal message")
2552 .expect("channel alive");
2553 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2554 assert!(
2555 start.elapsed() < Duration::from_millis(500),
2556 "cancel terminal message took {:?}",
2557 start.elapsed()
2558 );
2559 }
2560
2561 #[tokio::test]
2562 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2563 let (mut r, mut rx) = runner();
2569 let turn = TurnId(88);
2570 {
2571 let scope = r.scope_mut(turn);
2572 scope.spawn(async {}); }
2574 assert_eq!(r.scope_count(), 1);
2575
2576 tokio::time::sleep(Duration::from_millis(20)).await;
2578 r.dispatch(Cmd::Exit);
2579 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2580
2581 r.dispatch(Cmd::CancelScope(turn));
2584 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2585 .await
2586 .expect("cancel on a reaped scope must still emit a terminal message")
2587 .expect("channel alive");
2588 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2589 }
2590
2591 #[tokio::test]
2592 async fn dispatch_call_model_creates_scope() {
2593 let (mut r, _rx) = runner();
2594 let turn = TurnId(7);
2595 let request = crate::domain::ChatRequest {
2596 model_id: "test/m".to_string(),
2597 messages: vec![],
2598 system_prompt: String::new(),
2599 instructions: None,
2600 reasoning: crate::models::ReasoningLevel::Medium,
2601 temperature: 0.7,
2602 max_tokens: 4096,
2603 tools: vec![],
2604 };
2605 r.dispatch(Cmd::CallModel { turn, request });
2606 assert_eq!(r.scope_count(), 1);
2607 }
2608
2609 #[tokio::test]
2613 async fn empty_scopes_are_reaped_on_next_dispatch() {
2614 let (mut r, mut rx) = runner();
2615 let turn = TurnId(42);
2616 let request = crate::domain::ChatRequest {
2617 model_id: "test/m".to_string(),
2618 messages: vec![],
2619 system_prompt: String::new(),
2620 instructions: None,
2621 reasoning: crate::models::ReasoningLevel::Medium,
2622 temperature: 0.7,
2623 max_tokens: 4096,
2624 tools: vec![],
2625 };
2626 r.dispatch(Cmd::CallModel { turn, request });
2627 assert_eq!(r.scope_count(), 1);
2628
2629 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2634 .await
2635 .expect("upstream error arrived")
2636 .expect("channel alive");
2637 assert!(matches!(msg, Msg::UpstreamError { .. }));
2638
2639 tokio::task::yield_now().await;
2641
2642 r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2644 assert_eq!(
2645 r.scope_count(),
2646 0,
2647 "completed scope must be reaped on next dispatch"
2648 );
2649 }
2650
2651 #[tokio::test]
2652 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2653 let (mut r, mut rx) = runner();
2654 let turn = TurnId(7);
2655 let call_id = ToolCallId(1);
2656 let source = crate::models::tool_call::ToolCall {
2657 id: Some("c1".to_string()),
2658 function: crate::models::tool_call::FunctionCall {
2659 name: "read_file".to_string(),
2660 arguments: serde_json::json!({"path": "x"}),
2661 },
2662 };
2663 r.dispatch(Cmd::ExecuteTool {
2664 turn,
2665 call_id,
2666 source,
2667 model_id: "ollama/test".to_string(),
2668 safety_mode: crate::runtime::SafetyMode::Ask,
2669 intent: None,
2670 });
2671 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2672 .await
2673 .expect("some msg")
2674 .expect("channel alive");
2675 assert!(matches!(
2676 first,
2677 Msg::ToolStarted {
2678 turn: t,
2679 call_id: c,
2680 } if t == turn && c == call_id
2681 ));
2682 }
2683
2684 #[tokio::test]
2685 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2686 let (mut r, _rx) = runner();
2687 let turn = TurnId(9);
2688 r.dispatch(Cmd::CallModel {
2689 turn,
2690 request: crate::domain::ChatRequest {
2691 model_id: "m".to_string(),
2692 messages: vec![],
2693 system_prompt: String::new(),
2694 instructions: None,
2695 reasoning: crate::models::ReasoningLevel::Medium,
2696 temperature: 0.7,
2697 max_tokens: 4096,
2698 tools: vec![],
2699 },
2700 });
2701 assert_eq!(r.scope_count(), 1);
2702
2703 r.dispatch(Cmd::CancelScope(turn));
2704 assert_eq!(r.scope_count(), 0);
2705 }
2706
2707 #[tokio::test]
2708 async fn shutdown_drains_pending_saves() {
2709 let (mut r, _rx) = runner();
2710 for _ in 0..5 {
2711 r.dispatch(Cmd::SaveConversation(
2712 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2713 ));
2714 }
2715 let start = std::time::Instant::now();
2717 r.shutdown().await;
2718 assert!(start.elapsed() < Duration::from_secs(2));
2719 }
2720}