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 if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1305 match run_compaction(
1306 Arc::clone(&provider),
1307 turn,
1308 compaction,
1309 prepared,
1310 context_snapshot.clone(),
1311 max_context_tokens,
1312 token.clone(),
1313 )
1314 .await
1315 {
1316 Ok(result) => {
1317 request.messages = result.replacement_messages.clone();
1318 compacted_before_stream = true;
1319 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1320 },
1321 Err(err) => {
1322 if token.is_cancelled() {
1330 return;
1331 }
1332 tracing::warn!(
1333 turn = %turn,
1334 error = %err,
1335 "auto-compaction failed; proceeding with the un-compacted request",
1336 );
1337 let _ = msg_tx
1338 .send(Msg::CompactionFailed {
1339 turn,
1340 trigger: CompactionTrigger::AutoThreshold,
1341 message: err.to_string(),
1342 kind: crate::domain::StatusKind::Warn,
1343 })
1344 .await;
1345 },
1346 }
1347 }
1348 }
1349
1350 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1353 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1354
1355 let relay_tx = msg_tx.clone();
1362 let relay_token = token.clone();
1363 let relay = tokio::spawn(async move {
1364 loop {
1365 let event = tokio::select! {
1366 biased;
1367 _ = relay_token.cancelled() => break,
1368 ev = stream_rx.recv() => match ev {
1369 Some(ev) => ev,
1370 None => break,
1371 },
1372 };
1373 let msg = match event {
1374 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1375 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1376 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1377 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1379 usage,
1380 thinking_signature,
1381 stop_reason,
1382 } => Msg::StreamDone {
1383 turn,
1384 usage,
1385 thinking_signature,
1386 stop_reason,
1387 },
1388 };
1389 if relay_tx.send(msg).await.is_err() {
1390 break;
1391 }
1392 }
1393 });
1394
1395 let mut completed_ok = false;
1404 match provider.chat(request.clone(), ctx).await {
1405 Ok(_final_response) => {
1406 completed_ok = true;
1408 },
1409 Err(crate::models::ModelError::Cancelled) => {
1410 },
1412 Err(e) => {
1413 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1414 if retry_context_limit {
1415 let latest_snapshot =
1416 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1417 let compaction =
1418 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1419 if let Ok(prepared) =
1422 crate::domain::prepare_compaction(&compaction, max_context_tokens)
1423 {
1424 match run_compaction(
1425 Arc::clone(&provider),
1426 turn,
1427 compaction,
1428 prepared,
1429 latest_snapshot,
1430 max_context_tokens,
1431 token.clone(),
1432 )
1433 .await
1434 {
1435 Ok(result) => {
1436 let mut retry_request = request;
1437 retry_request.messages = result.replacement_messages.clone();
1438 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1439 join_logged(relay, "stream_relay").await;
1440 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1441 .await;
1442 return;
1443 },
1444 Err(compact_err) => {
1445 let _ = msg_tx
1446 .send(Msg::CompactionFailed {
1447 turn,
1448 trigger: CompactionTrigger::ContextLimitRetry,
1449 message: compact_err.to_string(),
1450 kind: crate::domain::StatusKind::Error,
1451 })
1452 .await;
1453 },
1454 }
1455 }
1456 }
1457 let error = classify_error_for_ui(&e);
1458 run_provider_error_hook(&request.model_id, &error).await;
1459 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1460 },
1461 }
1462
1463 join_logged(relay, "stream_relay").await;
1464
1465 if completed_ok
1470 && request.ollama_allow_ram_offload != Some(true)
1471 && let Some(p) = provider.verify_placement(sizing.effective).await
1472 {
1473 tracing::debug!(
1474 size_vram_bytes = p.size_vram_bytes,
1475 total_bytes = p.total_bytes,
1476 offloaded = p.size_vram_bytes < p.total_bytes,
1477 suggested_num_ctx = ?p.suggested_num_ctx,
1478 "Ollama placement"
1479 );
1480 let _ = msg_tx
1481 .send(Msg::OllamaPlacementResolved {
1482 model_id: request.model_id.clone(),
1483 size_vram_bytes: p.size_vram_bytes,
1484 total_bytes: p.total_bytes,
1485 suggested_num_ctx: p.suggested_num_ctx,
1486 })
1487 .await;
1488 }
1489}
1490
1491async fn dispatch_provider_stream(
1492 msg_tx: MsgSender,
1493 provider: Arc<dyn ModelProvider>,
1494 turn: TurnId,
1495 request: crate::domain::ChatRequest,
1496 token: tokio_util::sync::CancellationToken,
1497) {
1498 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1499 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1500 let relay_tx = msg_tx.clone();
1501 let relay_token = token.clone();
1502 let relay = tokio::spawn(async move {
1503 loop {
1504 let event = tokio::select! {
1505 biased;
1506 _ = relay_token.cancelled() => break,
1507 ev = stream_rx.recv() => match ev {
1508 Some(ev) => ev,
1509 None => break,
1510 },
1511 };
1512 let msg = match event {
1513 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1514 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1515 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1516 StreamEvent::ThinkingSignature(_) => continue,
1517 StreamEvent::Done {
1518 usage,
1519 thinking_signature,
1520 stop_reason,
1521 } => Msg::StreamDone {
1522 turn,
1523 usage,
1524 thinking_signature,
1525 stop_reason,
1526 },
1527 };
1528 if relay_tx.send(msg).await.is_err() {
1529 break;
1530 }
1531 }
1532 });
1533
1534 let model_id = request.model_id.clone();
1535 match provider.chat(request, ctx).await {
1536 Ok(_) | Err(ModelError::Cancelled) => {},
1537 Err(e) => {
1538 let error = classify_error_for_ui(&e);
1539 run_provider_error_hook(&model_id, &error).await;
1540 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1541 },
1542 }
1543
1544 join_logged(relay, "stream_relay").await;
1545}
1546
1547async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1553 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1554 .await;
1555}
1556
1557async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1558 fire_plugin_hooks(
1559 "provider_error",
1560 serde_json::json!({
1561 "model_id": model_id,
1562 "summary": &error.summary,
1563 "message": &error.message,
1564 "category": format!("{:?}", error.category),
1565 "recoverable": error.recoverable,
1566 }),
1567 )
1568 .await;
1569}
1570
1571fn memory_title_from_text(text: &str) -> String {
1575 let first = text
1576 .lines()
1577 .find(|l| !l.trim().is_empty())
1578 .unwrap_or("memory")
1579 .trim();
1580 let title: String = first
1581 .split_whitespace()
1582 .take(8)
1583 .collect::<Vec<_>>()
1584 .join(" ")
1585 .chars()
1586 .take(60)
1587 .collect();
1588 if title.trim().is_empty() {
1589 "memory".to_string()
1590 } else {
1591 title
1592 }
1593}
1594
1595const 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.";
1596
1597#[derive(Debug)]
1598struct PrunePlan {
1599 prune: Vec<String>,
1600 reason: String,
1601}
1602
1603fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1606 let start = text.find('{')?;
1607 let end = text.rfind('}')?;
1608 if end < start {
1609 return None;
1610 }
1611 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1612 let prune = json
1613 .get("prune")?
1614 .as_array()?
1615 .iter()
1616 .filter_map(|v| v.as_str().map(str::to_string))
1617 .collect();
1618 let reason = json
1619 .get("reason")
1620 .and_then(|v| v.as_str())
1621 .unwrap_or("")
1622 .to_string();
1623 Some(PrunePlan { prune, reason })
1624}
1625
1626async fn consolidate_memory(
1630 tx: MsgSender,
1631 providers: Option<Arc<ProviderFactory>>,
1632 workdir: std::path::PathBuf,
1633 model_id: String,
1634) {
1635 let items = crate::app::memory::entries_with_bodies(&workdir);
1636 if items.len() < 2 {
1637 let _ = tx
1638 .send(Msg::RuntimeText(format!(
1639 "Nothing to consolidate — {} memor{} saved.",
1640 items.len(),
1641 if items.len() == 1 { "y" } else { "ies" }
1642 )))
1643 .await;
1644 return;
1645 }
1646 let Some(factory) = providers else {
1647 let _ = tx
1648 .send(Msg::RuntimeText(
1649 "Memory consolidation needs a model provider, which isn't bound in this session."
1650 .to_string(),
1651 ))
1652 .await;
1653 return;
1654 };
1655
1656 let mut listing = String::new();
1657 for (entry, body) in &items {
1658 let id = entry
1659 .path
1660 .file_stem()
1661 .and_then(|s| s.to_str())
1662 .unwrap_or(entry.name.as_str());
1663 listing.push_str(&format!(
1664 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
1665 entry.scope.as_str(),
1666 entry.description,
1667 body.replace('\n', " ").trim(),
1668 ));
1669 }
1670 let user = format!(
1671 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1672 items.len(),
1673 listing
1674 );
1675 let request = crate::domain::ChatRequest {
1676 model_id: model_id.clone(),
1677 messages: vec![crate::models::ChatMessage::user(user)],
1678 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1679 instructions: None,
1680 reasoning: crate::models::ReasoningLevel::None,
1681 temperature: 0.0,
1682 max_tokens: 1024,
1683 tools: Vec::new(),
1684 ollama_num_ctx: None,
1685 ollama_allow_ram_offload: None,
1686 };
1687
1688 let provider = match factory.resolve(&model_id).await {
1689 Ok(p) => p,
1690 Err(e) => {
1691 let _ = tx
1692 .send(Msg::RuntimeText(format!(
1693 "Memory consolidation failed: {e}"
1694 )))
1695 .await;
1696 return;
1697 },
1698 };
1699 let token = tokio_util::sync::CancellationToken::new();
1700 let text =
1701 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1702 Ok((t, _)) => t,
1703 Err(e) => {
1704 let _ = tx
1705 .send(Msg::RuntimeText(format!(
1706 "Memory consolidation failed: {e}"
1707 )))
1708 .await;
1709 return;
1710 },
1711 };
1712
1713 let Some(plan) = parse_prune_plan(&text) else {
1714 let _ = tx
1715 .send(Msg::RuntimeText(
1716 "Memory consolidation: couldn't parse the model's plan; nothing changed."
1717 .to_string(),
1718 ))
1719 .await;
1720 return;
1721 };
1722 if plan.prune.is_empty() {
1723 let reason = if plan.reason.is_empty() {
1724 String::new()
1725 } else {
1726 format!(" {}", plan.reason)
1727 };
1728 let _ = tx
1729 .send(Msg::RuntimeText(format!(
1730 "Memory consolidation: nothing to prune.{reason}"
1731 )))
1732 .await;
1733 return;
1734 }
1735
1736 let paths: Vec<std::path::PathBuf> = plan
1738 .prune
1739 .iter()
1740 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1741 .collect();
1742 if !paths.is_empty() {
1743 let _ = crate::runtime::create_checkpoint(
1744 &workdir,
1745 &paths,
1746 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1747 );
1748 }
1749
1750 let mut pruned = Vec::new();
1751 for id in &plan.prune {
1752 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1753 pruned.push(id.clone());
1754 }
1755 }
1756
1757 let cfg = crate::app::load_config().unwrap_or_default().memory;
1758 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1759 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1760
1761 let report = if pruned.is_empty() {
1762 "Memory consolidation: the model named facts to prune, but none matched existing memories."
1763 .to_string()
1764 } else {
1765 format!(
1766 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1767 pruned.len(),
1768 if pruned.len() == 1 { "" } else { "s" },
1769 pruned.join(", "),
1770 if plan.reason.is_empty() {
1771 String::new()
1772 } else {
1773 format!(" Reason: {}.", plan.reason)
1774 },
1775 )
1776 };
1777 let _ = tx.send(Msg::RuntimeText(report)).await;
1778}
1779
1780async fn dispatch_compact_conversation(
1781 msg_tx: MsgSender,
1782 providers: Option<Arc<ProviderFactory>>,
1783 turn: TurnId,
1784 request: CompactionRequest,
1785 token: tokio_util::sync::CancellationToken,
1786) {
1787 let Some(factory) = providers else {
1788 let _ = msg_tx
1789 .send(Msg::CompactionFailed {
1790 turn,
1791 trigger: request.trigger,
1792 message: "EffectRunner has no ProviderFactory bound".to_string(),
1793 kind: crate::domain::StatusKind::Error,
1794 })
1795 .await;
1796 return;
1797 };
1798
1799 let provider = match factory.resolve(&request.chat.model_id).await {
1800 Ok(provider) => provider,
1801 Err(err) => {
1802 let _ = msg_tx
1803 .send(Msg::CompactionFailed {
1804 turn,
1805 trigger: request.trigger,
1806 message: err.to_string(),
1807 kind: crate::domain::StatusKind::Error,
1808 })
1809 .await;
1810 return;
1811 },
1812 };
1813
1814 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1815 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1816 });
1817 let before_snapshot =
1818 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1819
1820 let trigger = request.trigger;
1821 let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
1826 Ok(prepared) => prepared,
1827 Err(skip) => {
1828 let _ = msg_tx
1829 .send(Msg::CompactionFailed {
1830 turn,
1831 trigger,
1832 message: skip.to_string(),
1833 kind: crate::domain::StatusKind::Info,
1834 })
1835 .await;
1836 return;
1837 },
1838 };
1839 match run_compaction(
1840 provider,
1841 turn,
1842 request,
1843 prepared,
1844 before_snapshot,
1845 max_context_tokens,
1846 token,
1847 )
1848 .await
1849 {
1850 Ok(result) => {
1851 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1852 },
1853 Err(err) => {
1854 let _ = msg_tx
1855 .send(Msg::CompactionFailed {
1856 turn,
1857 trigger,
1858 message: err.to_string(),
1859 kind: crate::domain::StatusKind::Error,
1860 })
1861 .await;
1862 },
1863 }
1864}
1865
1866async fn run_compaction(
1867 provider: Arc<dyn ModelProvider>,
1868 turn: TurnId,
1869 request: CompactionRequest,
1870 prepared: crate::domain::PreparedCompaction,
1871 before_snapshot: crate::domain::ContextUsageSnapshot,
1872 max_context_tokens: Option<usize>,
1873 token: tokio_util::sync::CancellationToken,
1874) -> Result<CompactionResult, ModelError> {
1875 let started = Instant::now();
1876
1877 let summary_request = crate::domain::build_summary_request(
1878 &request.chat,
1879 &prepared,
1880 request.instructions.as_deref(),
1881 request.policy,
1882 );
1883 let (draft, draft_usage) =
1884 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1885 .await?;
1886 let draft_summary = crate::domain::normalize_summary(&draft);
1887 if draft_summary.trim().is_empty() {
1888 return Err(ModelError::InvalidRequest(
1889 "compaction produced an empty summary".to_string(),
1890 ));
1891 }
1892
1893 let verify_request = crate::domain::build_verification_request(
1894 &request.chat,
1895 &prepared,
1896 &draft_summary,
1897 request.instructions.as_deref(),
1898 request.policy,
1899 );
1900 let (final_summary, verify_usage, verified, verification_error) =
1901 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1902 Ok((verified_text, verify_usage)) => {
1903 let verified_summary = crate::domain::normalize_summary(&verified_text);
1904 if verified_summary.trim().is_empty() {
1905 (
1906 draft_summary,
1907 verify_usage,
1908 false,
1909 Some("verification returned an empty summary".to_string()),
1910 )
1911 } else {
1912 (verified_summary, verify_usage, true, None)
1913 }
1914 },
1915 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1916 Err(err) => (draft_summary, None, false, Some(err.to_string())),
1917 };
1918
1919 let id = format!(
1920 "compact_{}",
1921 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1922 );
1923 let mut record = crate::domain::CompactionRecord {
1924 id,
1925 trigger: request.trigger,
1926 created_at: chrono::Local::now(),
1927 before_tokens: before_snapshot.used_tokens,
1928 after_tokens: 0,
1929 archived_message_count: prepared.archived_messages.len(),
1930 preserved_message_count: prepared.preserved_messages.len(),
1931 summary_tokens: final_summary.len().div_ceil(4),
1932 duration_secs: started.elapsed().as_secs_f64(),
1933 verified,
1934 verification_error,
1935 focus: request.instructions.clone(),
1936 archive_path: None,
1937 };
1938
1939 let mut replacement =
1940 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1941 let mut compacted_request = request.chat.clone();
1942 compacted_request.messages = replacement.clone();
1943 let mut after_snapshot =
1944 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1945 record.after_tokens = after_snapshot.used_tokens;
1946 record.duration_secs = started.elapsed().as_secs_f64();
1947 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1948 compacted_request.messages = replacement.clone();
1949 after_snapshot =
1950 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1951 record.after_tokens = after_snapshot.used_tokens;
1952
1953 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1954 return Err(ModelError::InvalidRequest(format!(
1955 "compaction did not reduce context ({} -> {} tokens)",
1956 before_snapshot.used_tokens, after_snapshot.used_tokens
1957 )));
1958 }
1959
1960 if crate::domain::context_exceeds_hard_limit(
1961 &after_snapshot,
1962 &compacted_request,
1963 request.policy,
1964 ) {
1965 return Err(ModelError::InvalidRequest(format!(
1966 "compacted context still exceeds response reserve ({} tokens used)",
1967 after_snapshot.used_tokens
1968 )));
1969 }
1970
1971 Ok(CompactionResult {
1972 record,
1973 replacement_messages: replacement,
1974 archived_messages: prepared.archived_messages,
1975 before_snapshot,
1976 after_snapshot,
1977 usage: crate::domain::combine_usage(draft_usage, verify_usage),
1978 })
1979}
1980
1981async fn collect_compaction_text(
1982 provider: Arc<dyn ModelProvider>,
1983 turn: TurnId,
1984 request: crate::domain::ChatRequest,
1985 token: tokio_util::sync::CancellationToken,
1986) -> Result<(String, Option<TokenUsage>), ModelError> {
1987 crate::providers::model::collect_text(provider, turn, request, token).await
1990}
1991
1992fn record_provider_capabilities(
1993 model_id: &str,
1994 caps: &crate::providers::capabilities::Capabilities,
1995) {
1996 let (provider, model) = split_model_id(model_id);
1997 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1998 for (key, value) in [
1999 ("tools_support", caps.supports_tools.to_string()),
2000 ("vision_support", caps.supports_vision.to_string()),
2001 (
2002 "context_limit",
2003 caps.max_context_tokens
2004 .map(|v| v.to_string())
2005 .unwrap_or_else(|| "unknown".to_string()),
2006 ),
2007 (
2008 "reasoning_parameter_shape",
2009 format!("{:?}", caps.supports_reasoning),
2010 ),
2011 (
2012 "streaming_usage_available",
2013 "provider_dependent".to_string(),
2014 ),
2015 ("token_usage_field_shape", "normalized".to_string()),
2016 ] {
2017 let _ = store
2018 .provider_probes()
2019 .upsert(crate::runtime::NewProviderProbe {
2020 provider: provider.clone(),
2021 model_id: model.clone(),
2022 capability_key: key.to_string(),
2023 capability_value: value,
2024 confidence: "verified".to_string(),
2025 error: None,
2026 });
2027 }
2028 }
2029}
2030
2031fn split_model_id(model_id: &str) -> (String, String) {
2032 match model_id.split_once('/') {
2033 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2034 (provider.to_ascii_lowercase(), model.to_string())
2035 },
2036 _ => ("ollama".to_string(), model_id.to_string()),
2037 }
2038}
2039
2040fn is_context_limit_error(error: &ModelError) -> bool {
2041 let text = error.to_string().to_lowercase();
2042 text.contains("context")
2043 && (text.contains("too large")
2044 || text.contains("exceed")
2045 || text.contains("maximum")
2046 || text.contains("token"))
2047}
2048
2049#[allow(clippy::too_many_arguments)]
2051async fn dispatch_execute_tool(
2052 msg_tx: MsgSender,
2053 tools: Option<Arc<ToolRegistry>>,
2054 workdir: PathBuf,
2055 turn: TurnId,
2056 call_id: crate::domain::ToolCallId,
2057 source: crate::models::tool_call::ToolCall,
2058 token: tokio_util::sync::CancellationToken,
2059 background: tokio_util::sync::CancellationToken,
2060 config: Arc<crate::app::Config>,
2061 model_id: String,
2062 task_id: Option<String>,
2063 safety_mode: crate::runtime::SafetyMode,
2064 intent: Option<String>,
2065 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2066 approval: Option<crate::providers::ApprovalBroker>,
2067) {
2068 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2069
2070 let Some(registry) = tools else {
2071 let _ = msg_tx
2072 .send(Msg::ToolFinished {
2073 turn,
2074 call_id,
2075 outcome: crate::domain::ToolOutcome::error(
2076 "EffectRunner has no ToolRegistry bound",
2077 0.0,
2078 ),
2079 })
2080 .await;
2081 return;
2082 };
2083
2084 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2088 let rest = &source.function.name[5..];
2089 if let Some((server, tool)) = rest.split_once("__") {
2090 (
2091 "mcp_proxy",
2092 serde_json::json!({
2093 "server_name": server,
2094 "tool_name": tool,
2095 "arguments": source.function.arguments.clone(),
2096 }),
2097 )
2098 } else {
2099 let _ = msg_tx
2100 .send(Msg::ToolFinished {
2101 turn,
2102 call_id,
2103 outcome: crate::domain::ToolOutcome::error(
2104 format!("invalid MCP tool name: {}", source.function.name),
2105 0.0,
2106 ),
2107 })
2108 .await;
2109 return;
2110 }
2111 } else {
2112 (
2113 source.function.name.as_str(),
2114 source.function.arguments.clone(),
2115 )
2116 };
2117 let tool_run_id =
2118 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2119
2120 let Some(tool) = registry.get(tool_key) else {
2121 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2122 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2123 let _ = msg_tx
2124 .send(Msg::ToolFinished {
2125 turn,
2126 call_id,
2127 outcome,
2128 })
2129 .await;
2130 return;
2131 };
2132
2133 let (progress_tx, mut progress_rx) = mpsc::channel(16);
2139 let relay_tx = msg_tx.clone();
2140 let relay_token = token.clone();
2141 let progress_relay = tokio::spawn(async move {
2142 loop {
2143 let event = tokio::select! {
2144 biased;
2145 _ = relay_token.cancelled() => break,
2146 ev = progress_rx.recv() => match ev {
2147 Some(ev) => ev,
2148 None => break,
2149 },
2150 };
2151 if relay_tx
2152 .send(Msg::ToolProgress {
2153 turn,
2154 call_id,
2155 event,
2156 })
2157 .await
2158 .is_err()
2159 {
2160 break;
2161 }
2162 }
2163 });
2164
2165 let mut ctx = ExecContext::new(
2166 token,
2167 progress_tx,
2168 call_id,
2169 turn,
2170 workdir,
2171 config,
2172 model_id,
2173 task_id,
2174 safety_mode,
2175 intent,
2176 classifier,
2177 approval,
2178 );
2179 ctx.background = background;
2180 let before_payload = serde_json::json!({
2181 "turn_id": turn.0,
2182 "call_id": call_id.0,
2183 "tool": tool_key,
2184 });
2185 fire_plugin_hooks("before_tool_use", before_payload).await;
2186 let outcome = tool.execute(args, ctx).await;
2187 let after_payload = serde_json::json!({
2188 "turn_id": turn.0,
2189 "call_id": call_id.0,
2190 "tool": tool_key,
2191 "status": tool_status_label(outcome.status),
2192 "summary": &outcome.summary,
2193 });
2194 fire_plugin_hooks("after_tool_use", after_payload).await;
2195 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2196 join_logged(progress_relay, "tool_progress_relay").await;
2197 let _ = msg_tx
2198 .send(Msg::ToolFinished {
2199 turn,
2200 call_id,
2201 outcome,
2202 })
2203 .await;
2204}
2205
2206async fn start_runtime_tool_run(
2207 task_id: Option<&str>,
2208 turn: TurnId,
2209 call_id: crate::domain::ToolCallId,
2210 tool_name: &str,
2211 args: &serde_json::Value,
2212) -> Option<String> {
2213 let task_id = task_id.map(str::to_string);
2217 let tool_name = tool_name.to_string();
2218 let args_json = serde_json::to_string(args).ok();
2219 tokio::task::spawn_blocking(move || {
2220 crate::runtime::RuntimeStore::open_default()
2221 .and_then(|store| {
2222 store.tool_runs().start(crate::runtime::NewToolRun {
2223 id: None,
2224 task_id,
2225 turn_id: Some(turn.0.to_string()),
2226 call_id: Some(call_id.0.to_string()),
2227 tool_name,
2228 args_json,
2229 })
2230 })
2231 .map(|record| record.id)
2232 .ok()
2233 })
2234 .await
2235 .ok()
2236 .flatten()
2237}
2238
2239fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2240 let Some(tool_run_id) = tool_run_id else {
2241 return;
2242 };
2243 let tool_run_id = tool_run_id.to_string();
2244 let status = tool_status_label(outcome.status).to_string();
2245 let output_json = serde_json::to_string(&serde_json::json!({
2246 "status": tool_status_label(outcome.status),
2247 "summary": &outcome.summary,
2248 "model_content": &outcome.model_content,
2249 "error": &outcome.error,
2250 "metadata": &outcome.metadata,
2251 "artifacts": &outcome.artifacts,
2252 "duration_secs": outcome.duration_secs,
2253 }))
2254 .ok();
2255 tokio::task::spawn_blocking(move || {
2258 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2259 let _ = store
2260 .tool_runs()
2261 .finish(&tool_run_id, &status, output_json.as_deref());
2262 }
2263 });
2264}
2265
2266fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2267 match status {
2268 crate::domain::ToolStatus::Success => "success",
2269 crate::domain::ToolStatus::Error => "error",
2270 crate::domain::ToolStatus::Cancelled => "cancelled",
2271 }
2272}
2273
2274fn runtime_model_info_text(model: &str) -> String {
2275 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2276 let mut lines = vec![
2277 format!("Model info: {}", model),
2278 format!("- provider: {}", snapshot.provider),
2279 format!("- model: {}", snapshot.model),
2280 format!("- supports tools: {}", snapshot.supports_tools),
2281 format!("- supports vision: {}", snapshot.supports_vision),
2282 format!("- reasoning: {}", snapshot.reasoning),
2283 format!(
2284 "- context limit: {}",
2285 snapshot
2286 .max_context_tokens
2287 .map(|value: usize| value.to_string())
2288 .unwrap_or_else(|| "unknown".to_string())
2289 ),
2290 ];
2291 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2292 && let Ok(probes) = store
2293 .provider_probes()
2294 .list(Some(&snapshot.provider), Some(&snapshot.model))
2295 && !probes.is_empty()
2296 {
2297 lines.push(String::new());
2298 lines.push("Cached provider reality records:".to_string());
2299 for probe in probes {
2300 lines.push(format!(
2301 "- {} = {} ({})",
2302 probe.capability_key, probe.capability_value, probe.confidence
2303 ));
2304 }
2305 }
2306 lines.join("\n")
2307}
2308
2309async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2314 use tokio::io::{AsyncBufReadExt, BufReader};
2315 use tokio::process::Command;
2316
2317 let mut cmd = Command::new("ollama");
2318 cmd.arg("pull")
2319 .arg(&model)
2320 .stdin(std::process::Stdio::null())
2321 .stdout(std::process::Stdio::piped())
2322 .stderr(std::process::Stdio::piped())
2323 .kill_on_drop(true);
2324
2325 let mut child = match cmd.spawn() {
2326 Ok(c) => c,
2327 Err(e) => {
2328 let _ = tx
2329 .send(Msg::ModelPullProgress(format!(
2330 "ollama pull failed to start: {}",
2331 e
2332 )))
2333 .await;
2334 return;
2335 },
2336 };
2337
2338 let reader_handle = child.stdout.take().map(|stdout| {
2342 let tx_inner = tx.clone();
2343 tokio::spawn(async move {
2344 let mut reader = BufReader::new(stdout).lines();
2345 while let Ok(Some(line)) = reader.next_line().await {
2346 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2347 }
2348 })
2349 });
2350
2351 match child.wait().await {
2352 Ok(status) if status.success() => {
2353 let _ = tx.send(Msg::ModelPullFinished { model }).await;
2354 },
2355 Ok(status) => {
2356 let _ = tx
2357 .send(Msg::ModelPullProgress(format!(
2358 "ollama pull exited with status {}",
2359 status.code().unwrap_or(-1)
2360 )))
2361 .await;
2362 },
2363 Err(e) => {
2364 let _ = tx
2365 .send(Msg::ModelPullProgress(format!(
2366 "ollama pull wait error: {}",
2367 e
2368 )))
2369 .await;
2370 },
2371 }
2372
2373 if let Some(handle) = reader_handle {
2376 join_logged(handle, "ollama_pull_reader").await;
2377 }
2378}
2379
2380fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2381 if started {
2382 Msg::McpServerReady {
2383 name: name.to_string(),
2384 tools,
2385 }
2386 } else {
2387 Msg::McpServerErrored {
2388 name: name.to_string(),
2389 reason: "server failed to start or initialize".to_string(),
2390 }
2391 }
2392}
2393
2394async fn dispatch_read_clipboard(tx: MsgSender) {
2404 use crate::domain::{Paste, StatusKind};
2405
2406 enum Outcome {
2407 Image { bytes: Vec<u8>, format: String },
2408 Text(String),
2409 Empty,
2410 Error(String),
2411 }
2412
2413 let outcome = tokio::task::spawn_blocking(|| {
2414 if crate::clipboard::has_image() {
2415 match crate::clipboard::read_image_bytes() {
2416 Ok((bytes, format)) => Outcome::Image { bytes, format },
2417 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2418 }
2419 } else {
2420 match crate::clipboard::read_text() {
2421 Ok(t) if !t.is_empty() => Outcome::Text(t),
2422 Ok(_) => Outcome::Empty,
2423 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2424 }
2425 }
2426 })
2427 .await
2428 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2429
2430 let msg = match outcome {
2431 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2432 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2433 Outcome::Empty => Msg::TransientStatus {
2434 text: "Clipboard is empty".to_string(),
2435 kind: StatusKind::Info,
2436 dismiss_ms: 2_000,
2437 },
2438 Outcome::Error(text) => Msg::TransientStatus {
2439 text,
2440 kind: StatusKind::Warn,
2441 dismiss_ms: 4_000,
2442 },
2443 };
2444 let _ = tx.send(msg).await;
2445}
2446
2447async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2450 use crate::domain::StatusKind;
2451
2452 let char_count = text.chars().count();
2453 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2454 .await
2455 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2456
2457 let msg = match result {
2458 Ok(()) => Msg::TransientStatus {
2459 text: format!("Copied {char_count} chars to clipboard"),
2460 kind: StatusKind::Info,
2461 dismiss_ms: 2_000,
2462 },
2463 Err(e) => Msg::TransientStatus {
2464 text: format!("Copy failed: {e}"),
2465 kind: StatusKind::Warn,
2466 dismiss_ms: 4_000,
2467 },
2468 };
2469 let _ = tx.send(msg).await;
2470}
2471
2472fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2473 use crate::models::{ErrorCategory, ModelError, UserFacingError};
2474 match e {
2475 ModelError::Backend(b) => UserFacingError {
2476 summary: "Backend error".to_string(),
2477 message: b.to_string(),
2478 suggestion: "Check the provider endpoint / API key.".to_string(),
2479 category: ErrorCategory::Connection,
2480 recoverable: true,
2481 },
2482 ModelError::Authentication(msg) => UserFacingError {
2483 summary: "Auth error".to_string(),
2484 message: msg.clone(),
2485 suggestion: "Set the env var the provider expects.".to_string(),
2486 category: ErrorCategory::Auth,
2487 recoverable: false,
2488 },
2489 ModelError::RateLimit { retry_after } => UserFacingError {
2490 summary: "Rate limit".to_string(),
2491 message: format!("retry after {:?}", retry_after),
2492 suggestion: "Wait and try again.".to_string(),
2493 category: ErrorCategory::Temporary,
2494 recoverable: true,
2495 },
2496 ModelError::StreamError(msg) => UserFacingError {
2497 summary: "Stream error".to_string(),
2498 message: msg.clone(),
2499 suggestion: "Retry the request.".to_string(),
2500 category: ErrorCategory::Connection,
2501 recoverable: true,
2502 },
2503 other => UserFacingError {
2504 summary: "Model error".to_string(),
2505 message: other.to_string(),
2506 suggestion: String::new(),
2507 category: ErrorCategory::Internal,
2508 recoverable: false,
2509 },
2510 }
2511}
2512
2513#[cfg(test)]
2514mod tests {
2515 use super::*;
2516 use crate::domain::ToolCallId;
2517 use std::time::Duration;
2518
2519 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2520 EffectRunner::pair(PathBuf::from("/tmp"))
2521 }
2522
2523 #[test]
2524 fn new_child_suppresses_terminal_title() {
2525 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2529 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2530 let tools = Arc::new(ToolRegistry::new());
2531 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2532 assert!(
2533 !child.terminal_title_enabled,
2534 "subagent child runner must suppress terminal-title escapes"
2535 );
2536 }
2537
2538 #[test]
2539 fn parse_prune_plan_extracts_json_amid_prose() {
2540 let plan = parse_prune_plan(
2541 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2542 )
2543 .expect("should parse");
2544 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2545 assert_eq!(plan.reason, "dupes");
2546 }
2547
2548 #[test]
2549 fn parse_prune_plan_handles_empty_and_garbage() {
2550 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2551 .expect("empty plan parses");
2552 assert!(empty.prune.is_empty());
2553 assert!(parse_prune_plan("no json here").is_none());
2554 }
2555
2556 #[test]
2557 fn memory_title_from_text_is_short_and_nonempty() {
2558 assert_eq!(
2559 memory_title_from_text("prefer ripgrep over grep"),
2560 "prefer ripgrep over grep"
2561 );
2562 assert_eq!(memory_title_from_text(" "), "memory");
2563 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2564 assert!(long.split_whitespace().count() <= 8);
2565 }
2566
2567 #[tokio::test]
2568 async fn dispatch_exit_is_noop_on_runner_state() {
2569 let (mut r, _rx) = runner();
2570 r.dispatch(Cmd::Exit);
2571 assert_eq!(r.scope_count(), 0);
2572 }
2573
2574 #[tokio::test]
2575 async fn dispatch_save_emits_session_saved() {
2576 let (mut r, mut rx) = runner();
2577 r.dispatch(Cmd::SaveConversation(
2578 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2579 ));
2580 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2581 .await
2582 .expect("sender emits")
2583 .expect("channel alive");
2584 assert!(matches!(msg, Msg::SessionSaved));
2585 }
2586
2587 #[tokio::test]
2588 async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2589 let (mut r, mut rx) = runner();
2590 let t0 = std::time::Instant::now();
2591 r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2592 let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2593 .await
2594 .expect("sender emits")
2595 .expect("channel alive");
2596 assert!(matches!(msg, Msg::StatusDismiss));
2597 assert!(t0.elapsed() >= Duration::from_millis(25));
2598 }
2599
2600 #[test]
2601 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2602 let msg = mcp_startup_msg("empty", true, Vec::new());
2603 assert!(matches!(
2604 msg,
2605 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2606 ));
2607 }
2608
2609 #[test]
2610 fn mcp_startup_msg_reports_unstarted_server_as_error() {
2611 let msg = mcp_startup_msg("bad", false, Vec::new());
2612 assert!(matches!(
2613 msg,
2614 Msg::McpServerErrored { name, reason }
2615 if name == "bad" && reason.contains("failed to start")
2616 ));
2617 }
2618
2619 #[tokio::test]
2620 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2621 let (mut r, mut rx) = runner();
2622 let turn = TurnId(77);
2623 {
2624 let scope = r.scope_mut(turn);
2625 scope.spawn(async {
2626 std::future::pending::<()>().await;
2627 });
2628 }
2629 assert_eq!(r.scope_count(), 1);
2630
2631 let start = std::time::Instant::now();
2632 r.dispatch(Cmd::CancelScope(turn));
2633 assert_eq!(r.scope_count(), 0);
2634 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2635 .await
2636 .expect("bounded cancel should emit terminal message")
2637 .expect("channel alive");
2638 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2639 assert!(
2640 start.elapsed() < Duration::from_millis(500),
2641 "cancel terminal message took {:?}",
2642 start.elapsed()
2643 );
2644 }
2645
2646 #[tokio::test]
2647 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2648 let (mut r, mut rx) = runner();
2654 let turn = TurnId(88);
2655 {
2656 let scope = r.scope_mut(turn);
2657 scope.spawn(async {}); }
2659 assert_eq!(r.scope_count(), 1);
2660
2661 tokio::time::sleep(Duration::from_millis(20)).await;
2663 r.dispatch(Cmd::Exit);
2664 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2665
2666 r.dispatch(Cmd::CancelScope(turn));
2669 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2670 .await
2671 .expect("cancel on a reaped scope must still emit a terminal message")
2672 .expect("channel alive");
2673 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2674 }
2675
2676 #[tokio::test]
2677 async fn dispatch_call_model_creates_scope() {
2678 let (mut r, _rx) = runner();
2679 let turn = TurnId(7);
2680 let request = crate::domain::ChatRequest {
2681 model_id: "test/m".to_string(),
2682 messages: vec![],
2683 system_prompt: String::new(),
2684 instructions: None,
2685 reasoning: crate::models::ReasoningLevel::Medium,
2686 temperature: 0.7,
2687 max_tokens: 4096,
2688 tools: vec![],
2689
2690 ollama_num_ctx: None,
2691 ollama_allow_ram_offload: None,
2692 };
2693 r.dispatch(Cmd::CallModel { turn, request });
2694 assert_eq!(r.scope_count(), 1);
2695 }
2696
2697 #[tokio::test]
2701 async fn empty_scopes_are_reaped_on_next_dispatch() {
2702 let (mut r, mut rx) = runner();
2703 let turn = TurnId(42);
2704 let request = crate::domain::ChatRequest {
2705 model_id: "test/m".to_string(),
2706 messages: vec![],
2707 system_prompt: String::new(),
2708 instructions: None,
2709 reasoning: crate::models::ReasoningLevel::Medium,
2710 temperature: 0.7,
2711 max_tokens: 4096,
2712 tools: vec![],
2713
2714 ollama_num_ctx: None,
2715 ollama_allow_ram_offload: None,
2716 };
2717 r.dispatch(Cmd::CallModel { turn, request });
2718 assert_eq!(r.scope_count(), 1);
2719
2720 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2725 .await
2726 .expect("upstream error arrived")
2727 .expect("channel alive");
2728 assert!(matches!(msg, Msg::UpstreamError { .. }));
2729
2730 tokio::task::yield_now().await;
2732
2733 r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2735 assert_eq!(
2736 r.scope_count(),
2737 0,
2738 "completed scope must be reaped on next dispatch"
2739 );
2740 }
2741
2742 #[tokio::test]
2743 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2744 let (mut r, mut rx) = runner();
2745 let turn = TurnId(7);
2746 let call_id = ToolCallId(1);
2747 let source = crate::models::tool_call::ToolCall {
2748 id: Some("c1".to_string()),
2749 function: crate::models::tool_call::FunctionCall {
2750 name: "read_file".to_string(),
2751 arguments: serde_json::json!({"path": "x"}),
2752 },
2753 };
2754 r.dispatch(Cmd::ExecuteTool {
2755 turn,
2756 call_id,
2757 source,
2758 model_id: "ollama/test".to_string(),
2759 safety_mode: crate::runtime::SafetyMode::Ask,
2760 intent: None,
2761 });
2762 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2763 .await
2764 .expect("some msg")
2765 .expect("channel alive");
2766 assert!(matches!(
2767 first,
2768 Msg::ToolStarted {
2769 turn: t,
2770 call_id: c,
2771 } if t == turn && c == call_id
2772 ));
2773 }
2774
2775 #[tokio::test]
2776 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2777 let (mut r, _rx) = runner();
2778 let turn = TurnId(9);
2779 r.dispatch(Cmd::CallModel {
2780 turn,
2781 request: crate::domain::ChatRequest {
2782 model_id: "m".to_string(),
2783 messages: vec![],
2784 system_prompt: String::new(),
2785 instructions: None,
2786 reasoning: crate::models::ReasoningLevel::Medium,
2787 temperature: 0.7,
2788 max_tokens: 4096,
2789 tools: vec![],
2790
2791 ollama_num_ctx: None,
2792 ollama_allow_ram_offload: None,
2793 },
2794 });
2795 assert_eq!(r.scope_count(), 1);
2796
2797 r.dispatch(Cmd::CancelScope(turn));
2798 assert_eq!(r.scope_count(), 0);
2799 }
2800
2801 #[tokio::test]
2802 async fn shutdown_drains_pending_saves() {
2803 let (mut r, _rx) = runner();
2804 for _ in 0..5 {
2805 r.dispatch(Cmd::SaveConversation(
2806 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2807 ));
2808 }
2809 let start = std::time::Instant::now();
2811 r.shutdown().await;
2812 assert!(start.elapsed() < Duration::from_secs(2));
2813 }
2814}