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::PersistOllamaNumCtxFor { model_id, num_ctx } => {
637 self.detached.spawn(async move {
638 let _ = crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx);
639 });
640 },
641 Cmd::PersistOllamaOffload(enabled) => {
642 self.detached.spawn(async move {
643 let _ = crate::app::persist_ollama_allow_ram_offload(enabled);
644 });
645 },
646 Cmd::RefreshInstructions => {
647 let tx = self.msg_tx.clone();
648 let workdir = self.workdir.clone();
649 self.detached.spawn(async move {
650 let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
651 let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
652 });
653 },
654 Cmd::RefreshMemory => {
655 let tx = self.msg_tx.clone();
656 let workdir = self.workdir.clone();
657 self.detached.spawn(async move {
658 let cfg = crate::app::load_config().unwrap_or_default().memory;
659 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
660 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
661 });
662 },
663 Cmd::ListMemory => {
664 let tx = self.msg_tx.clone();
665 let workdir = self.workdir.clone();
666 self.detached.spawn(async move {
667 let cfg = crate::app::load_config().unwrap_or_default().memory;
668 let text = match crate::app::memory::load(&workdir, &cfg) {
669 Some(mem) => mem.index,
670 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(),
671 };
672 let _ = tx.send(Msg::RuntimeText(text)).await;
673 });
674 },
675 Cmd::RememberMemory { text } => {
676 let tx = self.msg_tx.clone();
677 let workdir = self.workdir.clone();
678 self.detached.spawn(async move {
679 let cfg = crate::app::load_config().unwrap_or_default().memory;
680 let name = memory_title_from_text(&text);
681 let (status, kind) = match crate::app::memory::write_memory(
682 &workdir,
683 crate::app::memory::MemoryScope::ProjectPrivate,
684 &name,
685 &text,
686 &[],
687 &text,
688 ) {
689 Ok(_) => (
690 format!("Remembered: {name}"),
691 crate::domain::StatusKind::Info,
692 ),
693 Err(e) => (
694 format!("Couldn't save memory: {e}"),
695 crate::domain::StatusKind::Error,
696 ),
697 };
698 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
699 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
700 let _ = tx
701 .send(Msg::TransientStatus {
702 text: status,
703 kind,
704 dismiss_ms: 3_000,
705 })
706 .await;
707 });
708 },
709 Cmd::ForgetMemory { id } => {
710 let tx = self.msg_tx.clone();
711 let workdir = self.workdir.clone();
712 self.detached.spawn(async move {
713 let cfg = crate::app::load_config().unwrap_or_default().memory;
714 let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
715 Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
716 Ok(None) => (
717 format!("No memory named '{id}'"),
718 crate::domain::StatusKind::Info,
719 ),
720 Err(e) => (
721 format!("Couldn't forget memory: {e}"),
722 crate::domain::StatusKind::Error,
723 ),
724 };
725 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
726 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
727 let _ = tx
728 .send(Msg::TransientStatus {
729 text: status,
730 kind,
731 dismiss_ms: 3_000,
732 })
733 .await;
734 });
735 },
736 Cmd::ConsolidateMemory { model_id } => {
737 let tx = self.msg_tx.clone();
738 let workdir = self.workdir.clone();
739 let providers = self.providers.clone();
740 self.detached.spawn(async move {
741 consolidate_memory(tx, providers, workdir, model_id).await;
742 });
743 },
744 Cmd::LoadConversation(id) => {
745 let tx = self.msg_tx.clone();
746 let workdir = self.workdir.clone();
747 self.detached.spawn(async move {
748 match crate::session::ConversationManager::new(&workdir) {
749 Ok(mgr) => match mgr.load_conversation(&id) {
750 Ok(history) => {
751 let _ = tx.send(Msg::ConversationLoaded(history)).await;
752 },
753 Err(e) => {
754 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
755 },
756 },
757 Err(e) => {
758 tracing::warn!(error = %e, "ConversationManager init failed");
759 },
760 }
761 });
762 },
763 Cmd::ListConversations => {
764 let tx = self.msg_tx.clone();
765 let workdir = self.workdir.clone();
766 self.detached.spawn(async move {
767 let summaries = match crate::session::ConversationManager::new(&workdir) {
768 Ok(mgr) => mgr
769 .list_conversations()
770 .unwrap_or_default()
771 .into_iter()
772 .map(|h| crate::domain::ConversationSummary {
773 id: h.id.clone(),
774 title: h.title.clone(),
775 message_count: h.messages.len(),
776 updated_at: h.updated_at.to_rfc3339(),
777 })
778 .collect(),
779 Err(_) => Vec::new(),
780 };
781 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
782 });
783 },
784 Cmd::ListRuntimeTasks { limit } => {
785 let tx = self.msg_tx.clone();
786 self.detached.spawn_blocking(move || {
789 let tasks = crate::runtime::RuntimeClient::auto()
790 .list_tasks(limit)
791 .map(|read| read.value)
792 .unwrap_or_default();
793 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
794 });
795 },
796 Cmd::LoadRuntimeTask { id } => {
797 let tx = self.msg_tx.clone();
798 self.detached.spawn_blocking(move || {
799 let (task, events) = crate::runtime::RuntimeClient::auto()
800 .task_detail(&id)
801 .map(|read| (Some(read.value.task), read.value.events))
802 .unwrap_or((None, Vec::new()));
803 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
804 });
805 },
806 Cmd::ListRuntimeProcesses { limit } => {
807 let tx = self.msg_tx.clone();
808 self.detached.spawn_blocking(move || {
809 let processes = crate::runtime::RuntimeClient::auto()
810 .list_processes(limit)
811 .map(|read| read.value)
812 .unwrap_or_default();
813 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
814 });
815 },
816 Cmd::ShowRuntimeProcessLogs { id } => {
817 let tx = self.msg_tx.clone();
818 self.detached.spawn_blocking(move || {
819 let text = crate::runtime::RuntimeClient::auto()
820 .process_log(&id, None)
821 .map(|log| format!("Process log {}\n\n{}", id, log.content))
822 .unwrap_or_else(|err| format!("Process log error: {}", err));
823 let _ = tx.blocking_send(Msg::RuntimeText(text));
824 });
825 },
826 Cmd::StopRuntimeProcess { id } => {
827 let tx = self.msg_tx.clone();
828 self.detached.spawn_blocking(move || {
829 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
830 Ok(response) => Msg::TransientStatus {
831 text: format!("Stopped process {} (pid {})", id, response.item.pid),
832 kind: crate::domain::StatusKind::Info,
833 dismiss_ms: 3_000,
834 },
835 Err(err) => Msg::TransientStatus {
836 text: format!("Process stop failed: {}", err),
837 kind: crate::domain::StatusKind::Warn,
838 dismiss_ms: 5_000,
839 },
840 };
841 let _ = tx.blocking_send(msg);
842 });
843 },
844 Cmd::RestartRuntimeProcess { id } => {
845 let tx = self.msg_tx.clone();
846 self.detached.spawn_blocking(move || {
847 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
848 Ok(response) => Msg::TransientStatus {
849 text: format!("Restarted process {} (pid {})", id, response.item.pid),
850 kind: crate::domain::StatusKind::Info,
851 dismiss_ms: 3_000,
852 },
853 Err(err) => Msg::TransientStatus {
854 text: format!("Process restart failed: {}", err),
855 kind: crate::domain::StatusKind::Warn,
856 dismiss_ms: 5_000,
857 },
858 };
859 let _ = tx.blocking_send(msg);
860 });
861 },
862 Cmd::OpenRuntimeTarget { target } => {
863 self.detached.spawn_blocking(move || {
864 let resolved = crate::runtime::RuntimeService::open_default()
865 .and_then(|service| service.resolve_open_target(&target))
866 .unwrap_or(target);
867 if let Err(err) = crate::runtime::validate_open_target(&resolved) {
871 tracing::warn!(error = %err, "refusing to open runtime target");
872 return;
873 }
874 crate::utils::open_file(resolved);
875 });
876 },
877 Cmd::ShowRuntimePorts => {
878 let tx = self.msg_tx.clone();
879 self.detached.spawn_blocking(move || {
880 let text = crate::runtime::RuntimeClient::auto()
881 .ports()
882 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
883 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
884 let _ = tx.blocking_send(Msg::RuntimeText(text));
885 });
886 },
887 Cmd::ListRuntimeApprovals => {
888 let tx = self.msg_tx.clone();
889 self.detached.spawn_blocking(move || {
890 let approvals = crate::runtime::RuntimeClient::auto()
891 .list_approvals()
892 .map(|read| read.value)
893 .unwrap_or_default();
894 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
895 });
896 },
897 Cmd::DecideRuntimeApproval { id, decision } => {
898 let tx = self.msg_tx.clone();
899 self.detached.spawn_blocking(move || {
900 let result = if decision == "approved" {
901 crate::runtime::RuntimeClient::auto().approve(&id)
902 } else {
903 crate::runtime::RuntimeClient::auto().deny(&id)
904 };
905 let msg = match result {
906 Ok(result) => Msg::TransientStatus {
907 text: if result.replayed {
908 format!("Approval {} {}: {}", id, decision, result.summary)
909 } else {
910 format!("Approval {} {}", id, decision)
911 },
912 kind: crate::domain::StatusKind::Info,
913 dismiss_ms: 3_000,
914 },
915 Err(err) => Msg::TransientStatus {
916 text: format!("Approval update failed: {}", err),
917 kind: crate::domain::StatusKind::Warn,
918 dismiss_ms: 5_000,
919 },
920 };
921 let _ = tx.blocking_send(msg);
922 });
923 },
924 Cmd::ListRuntimeCheckpoints { limit } => {
925 let tx = self.msg_tx.clone();
926 self.detached.spawn_blocking(move || {
927 let checkpoints = crate::runtime::RuntimeClient::auto()
928 .list_checkpoints(limit)
929 .map(|read| read.value)
930 .unwrap_or_default();
931 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
932 });
933 },
934 Cmd::ListRuntimePlugins => {
935 let tx = self.msg_tx.clone();
936 self.detached.spawn_blocking(move || {
937 let plugins = crate::runtime::RuntimeClient::auto()
938 .list_plugins()
939 .map(|read| read.value)
940 .unwrap_or_default();
941 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
942 });
943 },
944 Cmd::UpdateRuntimeTaskStatus {
945 id,
946 status,
947 final_report,
948 } => {
949 let tx = self.msg_tx.clone();
950 self.detached.spawn_blocking(move || {
951 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
952 store
953 .tasks()
954 .update_status(&id, status, final_report.as_deref())
955 }) {
956 Ok(()) => Msg::TransientStatus {
957 text: format!("Task {} -> {}", id, status),
958 kind: crate::domain::StatusKind::Info,
959 dismiss_ms: 3_000,
960 },
961 Err(err) => Msg::TransientStatus {
962 text: format!("Task update failed: {}", err),
963 kind: crate::domain::StatusKind::Warn,
964 dismiss_ms: 4_000,
965 },
966 };
967 let _ = tx.blocking_send(msg);
968 });
969 },
970 Cmd::CreateRuntimeCheckpoint { paths } => {
971 let tx = self.msg_tx.clone();
972 let workdir = self.workdir.clone();
973 self.detached.spawn_blocking(move || {
974 let pending_action = Some(serde_json::json!({
975 "source": "tui",
976 "command": "checkpoint",
977 }));
978 let msg =
979 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
980 Ok(manifest) => Msg::TransientStatus {
981 text: format!(
982 "Checkpoint {} created for {} path(s)",
983 manifest.id,
984 manifest.files.len()
985 ),
986 kind: crate::domain::StatusKind::Info,
987 dismiss_ms: 4_000,
988 },
989 Err(err) => Msg::TransientStatus {
990 text: format!("Checkpoint failed: {}", err),
991 kind: crate::domain::StatusKind::Warn,
992 dismiss_ms: 5_000,
993 },
994 };
995 let _ = tx.blocking_send(msg);
996 });
997 },
998 Cmd::RestoreRuntimeCheckpoint { id } => {
999 let tx = self.msg_tx.clone();
1000 self.detached.spawn_blocking(move || {
1001 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1002 Ok(result) => Msg::TransientStatus {
1003 text: format!(
1004 "Restored checkpoint {} ({} file(s)){}",
1005 result.checkpoint.id,
1006 result.checkpoint.files.len(),
1007 if result.checkpoint.pending_action.is_some() {
1008 "; pending action available in checkpoint manifest"
1009 } else {
1010 ""
1011 }
1012 ),
1013 kind: crate::domain::StatusKind::Info,
1014 dismiss_ms: 4_000,
1015 },
1016 Err(err) => Msg::TransientStatus {
1017 text: format!("Restore failed: {}", err),
1018 kind: crate::domain::StatusKind::Warn,
1019 dismiss_ms: 5_000,
1020 },
1021 };
1022 let _ = tx.blocking_send(msg);
1023 });
1024 },
1025 Cmd::ShowRuntimeModelInfo { model } => {
1026 let tx = self.msg_tx.clone();
1027 self.detached.spawn_blocking(move || {
1028 let text = runtime_model_info_text(&model);
1029 let _ = tx.blocking_send(Msg::RuntimeText(text));
1030 });
1031 },
1032 Cmd::InitMcpServers(configs) => {
1033 let tx = self.msg_tx.clone();
1034 self.detached.spawn(async move {
1035 if configs.is_empty() {
1036 return;
1037 }
1038 crate::mcp::manager_ref::mark_init_started();
1039 let manager =
1040 std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1041 for (name, _cfg) in configs.iter() {
1045 let server_tools: Vec<crate::domain::McpToolSpec> = manager
1046 .get_all_tools()
1047 .iter()
1048 .filter(|(server, _)| server == name)
1049 .map(|(_, def)| crate::domain::McpToolSpec {
1050 name: def.name.clone(),
1051 description: def.description.clone(),
1052 input_schema: def.input_schema.clone(),
1053 })
1054 .collect();
1055 let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1056 let _ = tx.send(msg).await;
1057 }
1058 crate::mcp::manager_ref::set_manager(manager);
1059 crate::mcp::manager_ref::mark_init_complete();
1060 });
1061 },
1062 Cmd::StopMcpServer { name } => {
1063 let tx = self.msg_tx.clone();
1064 self.detached.spawn(async move {
1065 if let Some(mgr) = crate::mcp::manager_ref::get() {
1068 mgr.stop_server(&name).await;
1069 }
1070 let _ = tx.send(Msg::McpServerStopped { name }).await;
1071 });
1072 },
1073 Cmd::PullOllamaModel { model } => {
1074 let tx = self.msg_tx.clone();
1075 self.detached.spawn(async move {
1076 dispatch_pull_ollama_model(tx, model).await;
1077 });
1078 },
1079 Cmd::OpenInSystem(path) => {
1080 self.detached.spawn(async move {
1081 let _ = tokio::task::spawn_blocking(move || {
1082 crate::utils::open_file(&path);
1083 })
1084 .await;
1085 });
1086 },
1087 Cmd::DismissStatusAfter { ms } => {
1088 let tx = self.msg_tx.clone();
1089 self.detached.spawn(async move {
1090 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1091 let _ = tx.send(Msg::StatusDismiss).await;
1092 });
1093 },
1094 Cmd::WriteImageToTemp {
1095 path,
1096 bytes,
1097 format: _,
1098 } => {
1099 self.detached.spawn(async move {
1100 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1101 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1102 }
1103 });
1104 },
1105 Cmd::ReadClipboard => {
1106 let tx = self.msg_tx.clone();
1107 self.detached.spawn(async move {
1108 dispatch_read_clipboard(tx).await;
1109 });
1110 },
1111 Cmd::CopyToClipboard(text) => {
1112 let tx = self.msg_tx.clone();
1113 self.detached.spawn(async move {
1114 dispatch_copy_to_clipboard(text, tx).await;
1115 });
1116 },
1117 Cmd::Exit => {
1118 },
1123 Cmd::SetTerminalTitle(title) => {
1124 if !self.terminal_title_enabled {
1125 return;
1126 }
1127 self.detached.spawn_blocking(move || {
1133 use std::io::Write;
1134 let seq = format!("\x1b]2;{}\x07", title);
1135 let mut stdout = std::io::stdout();
1136 let _ = stdout.write_all(seq.as_bytes());
1137 let _ = stdout.flush();
1138 });
1139 },
1140 }
1141 }
1142
1143 pub async fn shutdown(mut self) {
1147 for (id, scope) in self.scopes.iter() {
1148 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1149 scope.cancel();
1150 }
1151
1152 if let Some(handle) = self.config_watch.take() {
1155 handle.abort();
1156 }
1157
1158 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1160
1161 let drain = async {
1162 let _ = tokio::time::timeout(
1167 std::time::Duration::from_secs(2),
1168 crate::mcp::manager_ref::wait_ready(),
1169 )
1170 .await;
1171 if let Some(mgr) = crate::mcp::manager_ref::get() {
1177 mgr.shutdown().await;
1178 }
1179 for (_, mut scope) in self.scopes.drain() {
1180 scope.drain().await;
1181 }
1182 while let Some(result) = self.detached.join_next().await {
1183 if let Err(e) = result
1184 && !e.is_cancelled()
1185 {
1186 tracing::warn!(error = %e, "shutdown: detached task panic");
1187 }
1188 }
1189 };
1190
1191 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1192 }
1193
1194 #[doc(hidden)]
1197 pub fn msg_sender(&self) -> MsgSender {
1198 self.msg_tx.clone()
1199 }
1200}
1201
1202async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1211 if let Err(e) = handle.await
1212 && !e.is_cancelled()
1213 {
1214 tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1215 }
1216}
1217
1218async fn dispatch_call_model(
1219 msg_tx: MsgSender,
1220 providers: Option<Arc<ProviderFactory>>,
1221 turn: TurnId,
1222 mut request: crate::domain::ChatRequest,
1223 token: tokio_util::sync::CancellationToken,
1224) {
1225 use crate::models::UserFacingError;
1226
1227 let Some(factory) = providers else {
1228 let error = UserFacingError {
1229 summary: "not wired".to_string(),
1230 message: "EffectRunner has no ProviderFactory bound".to_string(),
1231 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1232 category: crate::models::ErrorCategory::Internal,
1233 recoverable: false,
1234 };
1235 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1236 return;
1237 };
1238
1239 let provider = match factory.resolve(&request.model_id).await {
1241 Ok(p) => p,
1242 Err(e) => {
1243 let error = classify_error_for_ui(&e);
1244 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1245 return;
1246 },
1247 };
1248 {
1249 let model_id = request.model_id.clone();
1253 let caps = provider.capabilities().clone();
1254 tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps));
1255 }
1256 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1257 let _ = msg_tx
1258 .send(Msg::TransientStatus {
1259 text: format!(
1260 "{} does not advertise tool support; Mermaid will send the turn without tools",
1261 request.model_id
1262 ),
1263 kind: crate::domain::StatusKind::Warn,
1264 dismiss_ms: 6_000,
1265 })
1266 .await;
1267 request.tools.clear();
1268 }
1269
1270 let sizing = provider.resolve_context_window(&request).await;
1276 let max_context_tokens = sizing.effective.or_else(|| {
1277 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1278 });
1279 let _ = msg_tx
1283 .send(Msg::ProviderContextResolved {
1284 model_max: sizing.model_max,
1285 effective: sizing.effective,
1286 source: sizing.source,
1287 })
1288 .await;
1289 let context_snapshot =
1290 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1291 let _ = msg_tx
1292 .send(Msg::ContextUsageEstimated {
1293 turn,
1294 snapshot: context_snapshot.clone(),
1295 })
1296 .await;
1297
1298 let policy = CompactionPolicy::default();
1299 let mut compacted_before_stream = false;
1300 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1301 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1302 match run_compaction(
1303 Arc::clone(&provider),
1304 turn,
1305 compaction,
1306 context_snapshot.clone(),
1307 max_context_tokens,
1308 token.clone(),
1309 )
1310 .await
1311 {
1312 Ok(result) => {
1313 request.messages = result.replacement_messages.clone();
1314 compacted_before_stream = true;
1315 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1316 },
1317 Err(err) => {
1318 if token.is_cancelled() {
1326 return;
1327 }
1328 tracing::warn!(
1329 turn = %turn,
1330 error = %err,
1331 "auto-compaction failed; proceeding with the un-compacted request",
1332 );
1333 let _ = msg_tx
1334 .send(Msg::CompactionFailed {
1335 turn,
1336 trigger: CompactionTrigger::AutoThreshold,
1337 message: err.to_string(),
1338 kind: crate::domain::StatusKind::Warn,
1339 })
1340 .await;
1341 },
1342 }
1343 }
1344
1345 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1348 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1349
1350 let relay_tx = msg_tx.clone();
1357 let relay_token = token.clone();
1358 let relay = tokio::spawn(async move {
1359 loop {
1360 let event = tokio::select! {
1361 biased;
1362 _ = relay_token.cancelled() => break,
1363 ev = stream_rx.recv() => match ev {
1364 Some(ev) => ev,
1365 None => break,
1366 },
1367 };
1368 let msg = match event {
1369 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1370 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1371 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1372 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1374 usage,
1375 thinking_signature,
1376 stop_reason,
1377 } => Msg::StreamDone {
1378 turn,
1379 usage,
1380 thinking_signature,
1381 stop_reason,
1382 },
1383 };
1384 if relay_tx.send(msg).await.is_err() {
1385 break;
1386 }
1387 }
1388 });
1389
1390 let mut completed_ok = false;
1399 match provider.chat(request.clone(), ctx).await {
1400 Ok(_final_response) => {
1401 completed_ok = true;
1403 },
1404 Err(crate::models::ModelError::Cancelled) => {
1405 },
1407 Err(e) => {
1408 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1409 if retry_context_limit {
1410 let latest_snapshot =
1411 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1412 let compaction =
1413 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1414 match run_compaction(
1415 Arc::clone(&provider),
1416 turn,
1417 compaction,
1418 latest_snapshot,
1419 max_context_tokens,
1420 token.clone(),
1421 )
1422 .await
1423 {
1424 Ok(result) => {
1425 let mut retry_request = request;
1426 retry_request.messages = result.replacement_messages.clone();
1427 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1428 join_logged(relay, "stream_relay").await;
1429 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1430 .await;
1431 return;
1432 },
1433 Err(compact_err) => {
1434 let _ = msg_tx
1435 .send(Msg::CompactionFailed {
1436 turn,
1437 trigger: CompactionTrigger::ContextLimitRetry,
1438 message: compact_err.to_string(),
1439 kind: crate::domain::StatusKind::Error,
1440 })
1441 .await;
1442 },
1443 }
1444 }
1445 let error = classify_error_for_ui(&e);
1446 run_provider_error_hook(&request.model_id, &error).await;
1447 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1448 },
1449 }
1450
1451 join_logged(relay, "stream_relay").await;
1452
1453 if completed_ok
1458 && request.ollama_allow_ram_offload != Some(true)
1459 && let Some(p) = provider.verify_placement(sizing.effective).await
1460 {
1461 tracing::debug!(
1462 size_vram_bytes = p.size_vram_bytes,
1463 total_bytes = p.total_bytes,
1464 offloaded = p.size_vram_bytes < p.total_bytes,
1465 suggested_num_ctx = ?p.suggested_num_ctx,
1466 "Ollama placement"
1467 );
1468 let _ = msg_tx
1469 .send(Msg::OllamaPlacementResolved {
1470 model_id: request.model_id.clone(),
1471 size_vram_bytes: p.size_vram_bytes,
1472 total_bytes: p.total_bytes,
1473 suggested_num_ctx: p.suggested_num_ctx,
1474 })
1475 .await;
1476 }
1477}
1478
1479async fn dispatch_provider_stream(
1480 msg_tx: MsgSender,
1481 provider: Arc<dyn ModelProvider>,
1482 turn: TurnId,
1483 request: crate::domain::ChatRequest,
1484 token: tokio_util::sync::CancellationToken,
1485) {
1486 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1487 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1488 let relay_tx = msg_tx.clone();
1489 let relay_token = token.clone();
1490 let relay = tokio::spawn(async move {
1491 loop {
1492 let event = tokio::select! {
1493 biased;
1494 _ = relay_token.cancelled() => break,
1495 ev = stream_rx.recv() => match ev {
1496 Some(ev) => ev,
1497 None => break,
1498 },
1499 };
1500 let msg = match event {
1501 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1502 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1503 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1504 StreamEvent::ThinkingSignature(_) => continue,
1505 StreamEvent::Done {
1506 usage,
1507 thinking_signature,
1508 stop_reason,
1509 } => Msg::StreamDone {
1510 turn,
1511 usage,
1512 thinking_signature,
1513 stop_reason,
1514 },
1515 };
1516 if relay_tx.send(msg).await.is_err() {
1517 break;
1518 }
1519 }
1520 });
1521
1522 let model_id = request.model_id.clone();
1523 match provider.chat(request, ctx).await {
1524 Ok(_) | Err(ModelError::Cancelled) => {},
1525 Err(e) => {
1526 let error = classify_error_for_ui(&e);
1527 run_provider_error_hook(&model_id, &error).await;
1528 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1529 },
1530 }
1531
1532 join_logged(relay, "stream_relay").await;
1533}
1534
1535async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1541 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1542 .await;
1543}
1544
1545async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1546 fire_plugin_hooks(
1547 "provider_error",
1548 serde_json::json!({
1549 "model_id": model_id,
1550 "summary": &error.summary,
1551 "message": &error.message,
1552 "category": format!("{:?}", error.category),
1553 "recoverable": error.recoverable,
1554 }),
1555 )
1556 .await;
1557}
1558
1559fn memory_title_from_text(text: &str) -> String {
1563 let first = text
1564 .lines()
1565 .find(|l| !l.trim().is_empty())
1566 .unwrap_or("memory")
1567 .trim();
1568 let title: String = first
1569 .split_whitespace()
1570 .take(8)
1571 .collect::<Vec<_>>()
1572 .join(" ")
1573 .chars()
1574 .take(60)
1575 .collect();
1576 if title.trim().is_empty() {
1577 "memory".to_string()
1578 } else {
1579 title
1580 }
1581}
1582
1583const 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.";
1584
1585#[derive(Debug)]
1586struct PrunePlan {
1587 prune: Vec<String>,
1588 reason: String,
1589}
1590
1591fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1594 let start = text.find('{')?;
1595 let end = text.rfind('}')?;
1596 if end < start {
1597 return None;
1598 }
1599 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1600 let prune = json
1601 .get("prune")?
1602 .as_array()?
1603 .iter()
1604 .filter_map(|v| v.as_str().map(str::to_string))
1605 .collect();
1606 let reason = json
1607 .get("reason")
1608 .and_then(|v| v.as_str())
1609 .unwrap_or("")
1610 .to_string();
1611 Some(PrunePlan { prune, reason })
1612}
1613
1614async fn consolidate_memory(
1618 tx: MsgSender,
1619 providers: Option<Arc<ProviderFactory>>,
1620 workdir: std::path::PathBuf,
1621 model_id: String,
1622) {
1623 let items = crate::app::memory::entries_with_bodies(&workdir);
1624 if items.len() < 2 {
1625 let _ = tx
1626 .send(Msg::RuntimeText(format!(
1627 "Nothing to consolidate — {} memor{} saved.",
1628 items.len(),
1629 if items.len() == 1 { "y" } else { "ies" }
1630 )))
1631 .await;
1632 return;
1633 }
1634 let Some(factory) = providers else {
1635 let _ = tx
1636 .send(Msg::RuntimeText(
1637 "Memory consolidation needs a model provider, which isn't bound in this session."
1638 .to_string(),
1639 ))
1640 .await;
1641 return;
1642 };
1643
1644 let mut listing = String::new();
1645 for (entry, body) in &items {
1646 let id = entry
1647 .path
1648 .file_stem()
1649 .and_then(|s| s.to_str())
1650 .unwrap_or(entry.name.as_str());
1651 listing.push_str(&format!(
1652 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
1653 entry.scope.as_str(),
1654 entry.description,
1655 body.replace('\n', " ").trim(),
1656 ));
1657 }
1658 let user = format!(
1659 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1660 items.len(),
1661 listing
1662 );
1663 let request = crate::domain::ChatRequest {
1664 model_id: model_id.clone(),
1665 messages: vec![crate::models::ChatMessage::user(user)],
1666 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1667 instructions: None,
1668 reasoning: crate::models::ReasoningLevel::None,
1669 temperature: 0.0,
1670 max_tokens: 1024,
1671 tools: Vec::new(),
1672 ollama_num_ctx: None,
1673 ollama_allow_ram_offload: None,
1674 };
1675
1676 let provider = match factory.resolve(&model_id).await {
1677 Ok(p) => p,
1678 Err(e) => {
1679 let _ = tx
1680 .send(Msg::RuntimeText(format!(
1681 "Memory consolidation failed: {e}"
1682 )))
1683 .await;
1684 return;
1685 },
1686 };
1687 let token = tokio_util::sync::CancellationToken::new();
1688 let text =
1689 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1690 Ok((t, _)) => t,
1691 Err(e) => {
1692 let _ = tx
1693 .send(Msg::RuntimeText(format!(
1694 "Memory consolidation failed: {e}"
1695 )))
1696 .await;
1697 return;
1698 },
1699 };
1700
1701 let Some(plan) = parse_prune_plan(&text) else {
1702 let _ = tx
1703 .send(Msg::RuntimeText(
1704 "Memory consolidation: couldn't parse the model's plan; nothing changed."
1705 .to_string(),
1706 ))
1707 .await;
1708 return;
1709 };
1710 if plan.prune.is_empty() {
1711 let reason = if plan.reason.is_empty() {
1712 String::new()
1713 } else {
1714 format!(" {}", plan.reason)
1715 };
1716 let _ = tx
1717 .send(Msg::RuntimeText(format!(
1718 "Memory consolidation: nothing to prune.{reason}"
1719 )))
1720 .await;
1721 return;
1722 }
1723
1724 let paths: Vec<std::path::PathBuf> = plan
1726 .prune
1727 .iter()
1728 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1729 .collect();
1730 if !paths.is_empty() {
1731 let _ = crate::runtime::create_checkpoint(
1732 &workdir,
1733 &paths,
1734 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1735 );
1736 }
1737
1738 let mut pruned = Vec::new();
1739 for id in &plan.prune {
1740 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1741 pruned.push(id.clone());
1742 }
1743 }
1744
1745 let cfg = crate::app::load_config().unwrap_or_default().memory;
1746 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1747 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1748
1749 let report = if pruned.is_empty() {
1750 "Memory consolidation: the model named facts to prune, but none matched existing memories."
1751 .to_string()
1752 } else {
1753 format!(
1754 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1755 pruned.len(),
1756 if pruned.len() == 1 { "" } else { "s" },
1757 pruned.join(", "),
1758 if plan.reason.is_empty() {
1759 String::new()
1760 } else {
1761 format!(" Reason: {}.", plan.reason)
1762 },
1763 )
1764 };
1765 let _ = tx.send(Msg::RuntimeText(report)).await;
1766}
1767
1768async fn dispatch_compact_conversation(
1769 msg_tx: MsgSender,
1770 providers: Option<Arc<ProviderFactory>>,
1771 turn: TurnId,
1772 request: CompactionRequest,
1773 token: tokio_util::sync::CancellationToken,
1774) {
1775 let Some(factory) = providers else {
1776 let _ = msg_tx
1777 .send(Msg::CompactionFailed {
1778 turn,
1779 trigger: request.trigger,
1780 message: "EffectRunner has no ProviderFactory bound".to_string(),
1781 kind: crate::domain::StatusKind::Error,
1782 })
1783 .await;
1784 return;
1785 };
1786
1787 let provider = match factory.resolve(&request.chat.model_id).await {
1788 Ok(provider) => provider,
1789 Err(err) => {
1790 let _ = msg_tx
1791 .send(Msg::CompactionFailed {
1792 turn,
1793 trigger: request.trigger,
1794 message: err.to_string(),
1795 kind: crate::domain::StatusKind::Error,
1796 })
1797 .await;
1798 return;
1799 },
1800 };
1801
1802 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1803 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1804 });
1805 let before_snapshot =
1806 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1807
1808 let trigger = request.trigger;
1809 match run_compaction(
1810 provider,
1811 turn,
1812 request,
1813 before_snapshot,
1814 max_context_tokens,
1815 token,
1816 )
1817 .await
1818 {
1819 Ok(result) => {
1820 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1821 },
1822 Err(err) => {
1823 let _ = msg_tx
1824 .send(Msg::CompactionFailed {
1825 turn,
1826 trigger,
1827 message: err.to_string(),
1828 kind: crate::domain::StatusKind::Error,
1829 })
1830 .await;
1831 },
1832 }
1833}
1834
1835async fn run_compaction(
1836 provider: Arc<dyn ModelProvider>,
1837 turn: TurnId,
1838 request: CompactionRequest,
1839 before_snapshot: crate::domain::ContextUsageSnapshot,
1840 max_context_tokens: Option<usize>,
1841 token: tokio_util::sync::CancellationToken,
1842) -> Result<CompactionResult, ModelError> {
1843 let started = Instant::now();
1844 let prepared = crate::domain::prepare_compaction(&request, max_context_tokens)
1845 .map_err(|skip| ModelError::InvalidRequest(skip.to_string()))?;
1846
1847 let summary_request = crate::domain::build_summary_request(
1848 &request.chat,
1849 &prepared,
1850 request.instructions.as_deref(),
1851 request.policy,
1852 );
1853 let (draft, draft_usage) =
1854 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1855 .await?;
1856 let draft_summary = crate::domain::normalize_summary(&draft);
1857 if draft_summary.trim().is_empty() {
1858 return Err(ModelError::InvalidRequest(
1859 "compaction produced an empty summary".to_string(),
1860 ));
1861 }
1862
1863 let verify_request = crate::domain::build_verification_request(
1864 &request.chat,
1865 &prepared,
1866 &draft_summary,
1867 request.instructions.as_deref(),
1868 request.policy,
1869 );
1870 let (final_summary, verify_usage, verified, verification_error) =
1871 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1872 Ok((verified_text, verify_usage)) => {
1873 let verified_summary = crate::domain::normalize_summary(&verified_text);
1874 if verified_summary.trim().is_empty() {
1875 (
1876 draft_summary,
1877 verify_usage,
1878 false,
1879 Some("verification returned an empty summary".to_string()),
1880 )
1881 } else {
1882 (verified_summary, verify_usage, true, None)
1883 }
1884 },
1885 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1886 Err(err) => (draft_summary, None, false, Some(err.to_string())),
1887 };
1888
1889 let id = format!(
1890 "compact_{}",
1891 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1892 );
1893 let mut record = crate::domain::CompactionRecord {
1894 id,
1895 trigger: request.trigger,
1896 created_at: chrono::Local::now(),
1897 before_tokens: before_snapshot.used_tokens,
1898 after_tokens: 0,
1899 archived_message_count: prepared.archived_messages.len(),
1900 preserved_message_count: prepared.preserved_messages.len(),
1901 summary_tokens: final_summary.len().div_ceil(4),
1902 duration_secs: started.elapsed().as_secs_f64(),
1903 verified,
1904 verification_error,
1905 focus: request.instructions.clone(),
1906 archive_path: None,
1907 };
1908
1909 let mut replacement =
1910 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1911 let mut compacted_request = request.chat.clone();
1912 compacted_request.messages = replacement.clone();
1913 let mut after_snapshot =
1914 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1915 record.after_tokens = after_snapshot.used_tokens;
1916 record.duration_secs = started.elapsed().as_secs_f64();
1917 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1918 compacted_request.messages = replacement.clone();
1919 after_snapshot =
1920 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1921 record.after_tokens = after_snapshot.used_tokens;
1922
1923 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1924 return Err(ModelError::InvalidRequest(format!(
1925 "compaction did not reduce context ({} -> {} tokens)",
1926 before_snapshot.used_tokens, after_snapshot.used_tokens
1927 )));
1928 }
1929
1930 if crate::domain::context_exceeds_hard_limit(
1931 &after_snapshot,
1932 &compacted_request,
1933 request.policy,
1934 ) {
1935 return Err(ModelError::InvalidRequest(format!(
1936 "compacted context still exceeds response reserve ({} tokens used)",
1937 after_snapshot.used_tokens
1938 )));
1939 }
1940
1941 Ok(CompactionResult {
1942 record,
1943 replacement_messages: replacement,
1944 archived_messages: prepared.archived_messages,
1945 before_snapshot,
1946 after_snapshot,
1947 usage: crate::domain::combine_usage(draft_usage, verify_usage),
1948 })
1949}
1950
1951async fn collect_compaction_text(
1952 provider: Arc<dyn ModelProvider>,
1953 turn: TurnId,
1954 request: crate::domain::ChatRequest,
1955 token: tokio_util::sync::CancellationToken,
1956) -> Result<(String, Option<TokenUsage>), ModelError> {
1957 crate::providers::model::collect_text(provider, turn, request, token).await
1960}
1961
1962fn record_provider_capabilities(
1963 model_id: &str,
1964 caps: &crate::providers::capabilities::Capabilities,
1965) {
1966 let (provider, model) = split_model_id(model_id);
1967 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1968 for (key, value) in [
1969 ("tools_support", caps.supports_tools.to_string()),
1970 ("vision_support", caps.supports_vision.to_string()),
1971 (
1972 "context_limit",
1973 caps.max_context_tokens
1974 .map(|v| v.to_string())
1975 .unwrap_or_else(|| "unknown".to_string()),
1976 ),
1977 (
1978 "reasoning_parameter_shape",
1979 format!("{:?}", caps.supports_reasoning),
1980 ),
1981 (
1982 "streaming_usage_available",
1983 "provider_dependent".to_string(),
1984 ),
1985 ("token_usage_field_shape", "normalized".to_string()),
1986 ] {
1987 let _ = store
1988 .provider_probes()
1989 .upsert(crate::runtime::NewProviderProbe {
1990 provider: provider.clone(),
1991 model_id: model.clone(),
1992 capability_key: key.to_string(),
1993 capability_value: value,
1994 confidence: "verified".to_string(),
1995 error: None,
1996 });
1997 }
1998 }
1999}
2000
2001fn split_model_id(model_id: &str) -> (String, String) {
2002 match model_id.split_once('/') {
2003 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2004 (provider.to_ascii_lowercase(), model.to_string())
2005 },
2006 _ => ("ollama".to_string(), model_id.to_string()),
2007 }
2008}
2009
2010fn is_context_limit_error(error: &ModelError) -> bool {
2011 let text = error.to_string().to_lowercase();
2012 text.contains("context")
2013 && (text.contains("too large")
2014 || text.contains("exceed")
2015 || text.contains("maximum")
2016 || text.contains("token"))
2017}
2018
2019#[allow(clippy::too_many_arguments)]
2021async fn dispatch_execute_tool(
2022 msg_tx: MsgSender,
2023 tools: Option<Arc<ToolRegistry>>,
2024 workdir: PathBuf,
2025 turn: TurnId,
2026 call_id: crate::domain::ToolCallId,
2027 source: crate::models::tool_call::ToolCall,
2028 token: tokio_util::sync::CancellationToken,
2029 background: tokio_util::sync::CancellationToken,
2030 config: Arc<crate::app::Config>,
2031 model_id: String,
2032 task_id: Option<String>,
2033 safety_mode: crate::runtime::SafetyMode,
2034 intent: Option<String>,
2035 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2036 approval: Option<crate::providers::ApprovalBroker>,
2037) {
2038 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2039
2040 let Some(registry) = tools else {
2041 let _ = msg_tx
2042 .send(Msg::ToolFinished {
2043 turn,
2044 call_id,
2045 outcome: crate::domain::ToolOutcome::error(
2046 "EffectRunner has no ToolRegistry bound",
2047 0.0,
2048 ),
2049 })
2050 .await;
2051 return;
2052 };
2053
2054 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2058 let rest = &source.function.name[5..];
2059 if let Some((server, tool)) = rest.split_once("__") {
2060 (
2061 "mcp_proxy",
2062 serde_json::json!({
2063 "server_name": server,
2064 "tool_name": tool,
2065 "arguments": source.function.arguments.clone(),
2066 }),
2067 )
2068 } else {
2069 let _ = msg_tx
2070 .send(Msg::ToolFinished {
2071 turn,
2072 call_id,
2073 outcome: crate::domain::ToolOutcome::error(
2074 format!("invalid MCP tool name: {}", source.function.name),
2075 0.0,
2076 ),
2077 })
2078 .await;
2079 return;
2080 }
2081 } else {
2082 (
2083 source.function.name.as_str(),
2084 source.function.arguments.clone(),
2085 )
2086 };
2087 let tool_run_id =
2088 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2089
2090 let Some(tool) = registry.get(tool_key) else {
2091 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2092 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2093 let _ = msg_tx
2094 .send(Msg::ToolFinished {
2095 turn,
2096 call_id,
2097 outcome,
2098 })
2099 .await;
2100 return;
2101 };
2102
2103 let (progress_tx, mut progress_rx) = mpsc::channel(16);
2109 let relay_tx = msg_tx.clone();
2110 let relay_token = token.clone();
2111 let progress_relay = tokio::spawn(async move {
2112 loop {
2113 let event = tokio::select! {
2114 biased;
2115 _ = relay_token.cancelled() => break,
2116 ev = progress_rx.recv() => match ev {
2117 Some(ev) => ev,
2118 None => break,
2119 },
2120 };
2121 if relay_tx
2122 .send(Msg::ToolProgress {
2123 turn,
2124 call_id,
2125 event,
2126 })
2127 .await
2128 .is_err()
2129 {
2130 break;
2131 }
2132 }
2133 });
2134
2135 let mut ctx = ExecContext::new(
2136 token,
2137 progress_tx,
2138 call_id,
2139 turn,
2140 workdir,
2141 config,
2142 model_id,
2143 task_id,
2144 safety_mode,
2145 intent,
2146 classifier,
2147 approval,
2148 );
2149 ctx.background = background;
2150 let before_payload = serde_json::json!({
2151 "turn_id": turn.0,
2152 "call_id": call_id.0,
2153 "tool": tool_key,
2154 });
2155 fire_plugin_hooks("before_tool_use", before_payload).await;
2156 let outcome = tool.execute(args, ctx).await;
2157 let after_payload = serde_json::json!({
2158 "turn_id": turn.0,
2159 "call_id": call_id.0,
2160 "tool": tool_key,
2161 "status": tool_status_label(outcome.status),
2162 "summary": &outcome.summary,
2163 });
2164 fire_plugin_hooks("after_tool_use", after_payload).await;
2165 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2166 join_logged(progress_relay, "tool_progress_relay").await;
2167 let _ = msg_tx
2168 .send(Msg::ToolFinished {
2169 turn,
2170 call_id,
2171 outcome,
2172 })
2173 .await;
2174}
2175
2176async fn start_runtime_tool_run(
2177 task_id: Option<&str>,
2178 turn: TurnId,
2179 call_id: crate::domain::ToolCallId,
2180 tool_name: &str,
2181 args: &serde_json::Value,
2182) -> Option<String> {
2183 let task_id = task_id.map(str::to_string);
2187 let tool_name = tool_name.to_string();
2188 let args_json = serde_json::to_string(args).ok();
2189 tokio::task::spawn_blocking(move || {
2190 crate::runtime::RuntimeStore::open_default()
2191 .and_then(|store| {
2192 store.tool_runs().start(crate::runtime::NewToolRun {
2193 id: None,
2194 task_id,
2195 turn_id: Some(turn.0.to_string()),
2196 call_id: Some(call_id.0.to_string()),
2197 tool_name,
2198 args_json,
2199 })
2200 })
2201 .map(|record| record.id)
2202 .ok()
2203 })
2204 .await
2205 .ok()
2206 .flatten()
2207}
2208
2209fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2210 let Some(tool_run_id) = tool_run_id else {
2211 return;
2212 };
2213 let tool_run_id = tool_run_id.to_string();
2214 let status = tool_status_label(outcome.status).to_string();
2215 let output_json = serde_json::to_string(&serde_json::json!({
2216 "status": tool_status_label(outcome.status),
2217 "summary": &outcome.summary,
2218 "model_content": &outcome.model_content,
2219 "error": &outcome.error,
2220 "metadata": &outcome.metadata,
2221 "artifacts": &outcome.artifacts,
2222 "duration_secs": outcome.duration_secs,
2223 }))
2224 .ok();
2225 tokio::task::spawn_blocking(move || {
2228 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2229 let _ = store
2230 .tool_runs()
2231 .finish(&tool_run_id, &status, output_json.as_deref());
2232 }
2233 });
2234}
2235
2236fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2237 match status {
2238 crate::domain::ToolStatus::Success => "success",
2239 crate::domain::ToolStatus::Error => "error",
2240 crate::domain::ToolStatus::Cancelled => "cancelled",
2241 }
2242}
2243
2244fn runtime_model_info_text(model: &str) -> String {
2245 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2246 let mut lines = vec![
2247 format!("Model info: {}", model),
2248 format!("- provider: {}", snapshot.provider),
2249 format!("- model: {}", snapshot.model),
2250 format!("- supports tools: {}", snapshot.supports_tools),
2251 format!("- supports vision: {}", snapshot.supports_vision),
2252 format!("- reasoning: {}", snapshot.reasoning),
2253 format!(
2254 "- context limit: {}",
2255 snapshot
2256 .max_context_tokens
2257 .map(|value: usize| value.to_string())
2258 .unwrap_or_else(|| "unknown".to_string())
2259 ),
2260 ];
2261 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2262 && let Ok(probes) = store
2263 .provider_probes()
2264 .list(Some(&snapshot.provider), Some(&snapshot.model))
2265 && !probes.is_empty()
2266 {
2267 lines.push(String::new());
2268 lines.push("Cached provider reality records:".to_string());
2269 for probe in probes {
2270 lines.push(format!(
2271 "- {} = {} ({})",
2272 probe.capability_key, probe.capability_value, probe.confidence
2273 ));
2274 }
2275 }
2276 lines.join("\n")
2277}
2278
2279async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2284 use tokio::io::{AsyncBufReadExt, BufReader};
2285 use tokio::process::Command;
2286
2287 let mut cmd = Command::new("ollama");
2288 cmd.arg("pull")
2289 .arg(&model)
2290 .stdin(std::process::Stdio::null())
2291 .stdout(std::process::Stdio::piped())
2292 .stderr(std::process::Stdio::piped())
2293 .kill_on_drop(true);
2294
2295 let mut child = match cmd.spawn() {
2296 Ok(c) => c,
2297 Err(e) => {
2298 let _ = tx
2299 .send(Msg::ModelPullProgress(format!(
2300 "ollama pull failed to start: {}",
2301 e
2302 )))
2303 .await;
2304 return;
2305 },
2306 };
2307
2308 let reader_handle = child.stdout.take().map(|stdout| {
2312 let tx_inner = tx.clone();
2313 tokio::spawn(async move {
2314 let mut reader = BufReader::new(stdout).lines();
2315 while let Ok(Some(line)) = reader.next_line().await {
2316 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2317 }
2318 })
2319 });
2320
2321 match child.wait().await {
2322 Ok(status) if status.success() => {
2323 let _ = tx.send(Msg::ModelPullFinished { model }).await;
2324 },
2325 Ok(status) => {
2326 let _ = tx
2327 .send(Msg::ModelPullProgress(format!(
2328 "ollama pull exited with status {}",
2329 status.code().unwrap_or(-1)
2330 )))
2331 .await;
2332 },
2333 Err(e) => {
2334 let _ = tx
2335 .send(Msg::ModelPullProgress(format!(
2336 "ollama pull wait error: {}",
2337 e
2338 )))
2339 .await;
2340 },
2341 }
2342
2343 if let Some(handle) = reader_handle {
2346 join_logged(handle, "ollama_pull_reader").await;
2347 }
2348}
2349
2350fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2351 if started {
2352 Msg::McpServerReady {
2353 name: name.to_string(),
2354 tools,
2355 }
2356 } else {
2357 Msg::McpServerErrored {
2358 name: name.to_string(),
2359 reason: "server failed to start or initialize".to_string(),
2360 }
2361 }
2362}
2363
2364async fn dispatch_read_clipboard(tx: MsgSender) {
2374 use crate::domain::{Paste, StatusKind};
2375
2376 enum Outcome {
2377 Image { bytes: Vec<u8>, format: String },
2378 Text(String),
2379 Empty,
2380 Error(String),
2381 }
2382
2383 let outcome = tokio::task::spawn_blocking(|| {
2384 if crate::clipboard::has_image() {
2385 match crate::clipboard::read_image_bytes() {
2386 Ok((bytes, format)) => Outcome::Image { bytes, format },
2387 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2388 }
2389 } else {
2390 match crate::clipboard::read_text() {
2391 Ok(t) if !t.is_empty() => Outcome::Text(t),
2392 Ok(_) => Outcome::Empty,
2393 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2394 }
2395 }
2396 })
2397 .await
2398 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2399
2400 let msg = match outcome {
2401 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2402 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2403 Outcome::Empty => Msg::TransientStatus {
2404 text: "Clipboard is empty".to_string(),
2405 kind: StatusKind::Info,
2406 dismiss_ms: 2_000,
2407 },
2408 Outcome::Error(text) => Msg::TransientStatus {
2409 text,
2410 kind: StatusKind::Warn,
2411 dismiss_ms: 4_000,
2412 },
2413 };
2414 let _ = tx.send(msg).await;
2415}
2416
2417async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2420 use crate::domain::StatusKind;
2421
2422 let char_count = text.chars().count();
2423 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2424 .await
2425 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2426
2427 let msg = match result {
2428 Ok(()) => Msg::TransientStatus {
2429 text: format!("Copied {char_count} chars to clipboard"),
2430 kind: StatusKind::Info,
2431 dismiss_ms: 2_000,
2432 },
2433 Err(e) => Msg::TransientStatus {
2434 text: format!("Copy failed: {e}"),
2435 kind: StatusKind::Warn,
2436 dismiss_ms: 4_000,
2437 },
2438 };
2439 let _ = tx.send(msg).await;
2440}
2441
2442fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2443 use crate::models::{ErrorCategory, ModelError, UserFacingError};
2444 match e {
2445 ModelError::Backend(b) => UserFacingError {
2446 summary: "Backend error".to_string(),
2447 message: b.to_string(),
2448 suggestion: "Check the provider endpoint / API key.".to_string(),
2449 category: ErrorCategory::Connection,
2450 recoverable: true,
2451 },
2452 ModelError::Authentication(msg) => UserFacingError {
2453 summary: "Auth error".to_string(),
2454 message: msg.clone(),
2455 suggestion: "Set the env var the provider expects.".to_string(),
2456 category: ErrorCategory::Auth,
2457 recoverable: false,
2458 },
2459 ModelError::RateLimit { retry_after } => UserFacingError {
2460 summary: "Rate limit".to_string(),
2461 message: format!("retry after {:?}", retry_after),
2462 suggestion: "Wait and try again.".to_string(),
2463 category: ErrorCategory::Temporary,
2464 recoverable: true,
2465 },
2466 ModelError::StreamError(msg) => UserFacingError {
2467 summary: "Stream error".to_string(),
2468 message: msg.clone(),
2469 suggestion: "Retry the request.".to_string(),
2470 category: ErrorCategory::Connection,
2471 recoverable: true,
2472 },
2473 other => UserFacingError {
2474 summary: "Model error".to_string(),
2475 message: other.to_string(),
2476 suggestion: String::new(),
2477 category: ErrorCategory::Internal,
2478 recoverable: false,
2479 },
2480 }
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485 use super::*;
2486 use crate::domain::ToolCallId;
2487 use std::time::Duration;
2488
2489 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2490 EffectRunner::pair(PathBuf::from("/tmp"))
2491 }
2492
2493 #[test]
2494 fn new_child_suppresses_terminal_title() {
2495 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2499 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2500 let tools = Arc::new(ToolRegistry::new());
2501 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2502 assert!(
2503 !child.terminal_title_enabled,
2504 "subagent child runner must suppress terminal-title escapes"
2505 );
2506 }
2507
2508 #[test]
2509 fn parse_prune_plan_extracts_json_amid_prose() {
2510 let plan = parse_prune_plan(
2511 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2512 )
2513 .expect("should parse");
2514 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2515 assert_eq!(plan.reason, "dupes");
2516 }
2517
2518 #[test]
2519 fn parse_prune_plan_handles_empty_and_garbage() {
2520 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2521 .expect("empty plan parses");
2522 assert!(empty.prune.is_empty());
2523 assert!(parse_prune_plan("no json here").is_none());
2524 }
2525
2526 #[test]
2527 fn memory_title_from_text_is_short_and_nonempty() {
2528 assert_eq!(
2529 memory_title_from_text("prefer ripgrep over grep"),
2530 "prefer ripgrep over grep"
2531 );
2532 assert_eq!(memory_title_from_text(" "), "memory");
2533 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2534 assert!(long.split_whitespace().count() <= 8);
2535 }
2536
2537 #[tokio::test]
2538 async fn dispatch_exit_is_noop_on_runner_state() {
2539 let (mut r, _rx) = runner();
2540 r.dispatch(Cmd::Exit);
2541 assert_eq!(r.scope_count(), 0);
2542 }
2543
2544 #[tokio::test]
2545 async fn dispatch_save_emits_session_saved() {
2546 let (mut r, mut rx) = runner();
2547 r.dispatch(Cmd::SaveConversation(
2548 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2549 ));
2550 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2551 .await
2552 .expect("sender emits")
2553 .expect("channel alive");
2554 assert!(matches!(msg, Msg::SessionSaved));
2555 }
2556
2557 #[tokio::test]
2558 async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2559 let (mut r, mut rx) = runner();
2560 let t0 = std::time::Instant::now();
2561 r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2562 let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2563 .await
2564 .expect("sender emits")
2565 .expect("channel alive");
2566 assert!(matches!(msg, Msg::StatusDismiss));
2567 assert!(t0.elapsed() >= Duration::from_millis(25));
2568 }
2569
2570 #[test]
2571 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2572 let msg = mcp_startup_msg("empty", true, Vec::new());
2573 assert!(matches!(
2574 msg,
2575 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2576 ));
2577 }
2578
2579 #[test]
2580 fn mcp_startup_msg_reports_unstarted_server_as_error() {
2581 let msg = mcp_startup_msg("bad", false, Vec::new());
2582 assert!(matches!(
2583 msg,
2584 Msg::McpServerErrored { name, reason }
2585 if name == "bad" && reason.contains("failed to start")
2586 ));
2587 }
2588
2589 #[tokio::test]
2590 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2591 let (mut r, mut rx) = runner();
2592 let turn = TurnId(77);
2593 {
2594 let scope = r.scope_mut(turn);
2595 scope.spawn(async {
2596 std::future::pending::<()>().await;
2597 });
2598 }
2599 assert_eq!(r.scope_count(), 1);
2600
2601 let start = std::time::Instant::now();
2602 r.dispatch(Cmd::CancelScope(turn));
2603 assert_eq!(r.scope_count(), 0);
2604 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2605 .await
2606 .expect("bounded cancel should emit terminal message")
2607 .expect("channel alive");
2608 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2609 assert!(
2610 start.elapsed() < Duration::from_millis(500),
2611 "cancel terminal message took {:?}",
2612 start.elapsed()
2613 );
2614 }
2615
2616 #[tokio::test]
2617 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2618 let (mut r, mut rx) = runner();
2624 let turn = TurnId(88);
2625 {
2626 let scope = r.scope_mut(turn);
2627 scope.spawn(async {}); }
2629 assert_eq!(r.scope_count(), 1);
2630
2631 tokio::time::sleep(Duration::from_millis(20)).await;
2633 r.dispatch(Cmd::Exit);
2634 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2635
2636 r.dispatch(Cmd::CancelScope(turn));
2639 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2640 .await
2641 .expect("cancel on a reaped scope must still emit a terminal message")
2642 .expect("channel alive");
2643 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2644 }
2645
2646 #[tokio::test]
2647 async fn dispatch_call_model_creates_scope() {
2648 let (mut r, _rx) = runner();
2649 let turn = TurnId(7);
2650 let request = crate::domain::ChatRequest {
2651 model_id: "test/m".to_string(),
2652 messages: vec![],
2653 system_prompt: String::new(),
2654 instructions: None,
2655 reasoning: crate::models::ReasoningLevel::Medium,
2656 temperature: 0.7,
2657 max_tokens: 4096,
2658 tools: vec![],
2659
2660 ollama_num_ctx: None,
2661 ollama_allow_ram_offload: None,
2662 };
2663 r.dispatch(Cmd::CallModel { turn, request });
2664 assert_eq!(r.scope_count(), 1);
2665 }
2666
2667 #[tokio::test]
2671 async fn empty_scopes_are_reaped_on_next_dispatch() {
2672 let (mut r, mut rx) = runner();
2673 let turn = TurnId(42);
2674 let request = crate::domain::ChatRequest {
2675 model_id: "test/m".to_string(),
2676 messages: vec![],
2677 system_prompt: String::new(),
2678 instructions: None,
2679 reasoning: crate::models::ReasoningLevel::Medium,
2680 temperature: 0.7,
2681 max_tokens: 4096,
2682 tools: vec![],
2683
2684 ollama_num_ctx: None,
2685 ollama_allow_ram_offload: None,
2686 };
2687 r.dispatch(Cmd::CallModel { turn, request });
2688 assert_eq!(r.scope_count(), 1);
2689
2690 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2695 .await
2696 .expect("upstream error arrived")
2697 .expect("channel alive");
2698 assert!(matches!(msg, Msg::UpstreamError { .. }));
2699
2700 tokio::task::yield_now().await;
2702
2703 r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2705 assert_eq!(
2706 r.scope_count(),
2707 0,
2708 "completed scope must be reaped on next dispatch"
2709 );
2710 }
2711
2712 #[tokio::test]
2713 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2714 let (mut r, mut rx) = runner();
2715 let turn = TurnId(7);
2716 let call_id = ToolCallId(1);
2717 let source = crate::models::tool_call::ToolCall {
2718 id: Some("c1".to_string()),
2719 function: crate::models::tool_call::FunctionCall {
2720 name: "read_file".to_string(),
2721 arguments: serde_json::json!({"path": "x"}),
2722 },
2723 };
2724 r.dispatch(Cmd::ExecuteTool {
2725 turn,
2726 call_id,
2727 source,
2728 model_id: "ollama/test".to_string(),
2729 safety_mode: crate::runtime::SafetyMode::Ask,
2730 intent: None,
2731 });
2732 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2733 .await
2734 .expect("some msg")
2735 .expect("channel alive");
2736 assert!(matches!(
2737 first,
2738 Msg::ToolStarted {
2739 turn: t,
2740 call_id: c,
2741 } if t == turn && c == call_id
2742 ));
2743 }
2744
2745 #[tokio::test]
2746 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2747 let (mut r, _rx) = runner();
2748 let turn = TurnId(9);
2749 r.dispatch(Cmd::CallModel {
2750 turn,
2751 request: crate::domain::ChatRequest {
2752 model_id: "m".to_string(),
2753 messages: vec![],
2754 system_prompt: String::new(),
2755 instructions: None,
2756 reasoning: crate::models::ReasoningLevel::Medium,
2757 temperature: 0.7,
2758 max_tokens: 4096,
2759 tools: vec![],
2760
2761 ollama_num_ctx: None,
2762 ollama_allow_ram_offload: None,
2763 },
2764 });
2765 assert_eq!(r.scope_count(), 1);
2766
2767 r.dispatch(Cmd::CancelScope(turn));
2768 assert_eq!(r.scope_count(), 0);
2769 }
2770
2771 #[tokio::test]
2772 async fn shutdown_drains_pending_saves() {
2773 let (mut r, _rx) = runner();
2774 for _ in 0..5 {
2775 r.dispatch(Cmd::SaveConversation(
2776 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2777 ));
2778 }
2779 let start = std::time::Instant::now();
2781 r.shutdown().await;
2782 assert!(start.elapsed() < Duration::from_secs(2));
2783 }
2784}