1mod config_watch;
35mod middleware;
36mod turn_scope;
37
38use std::collections::HashMap;
39use std::collections::VecDeque;
40use std::path::PathBuf;
41use std::sync::Arc;
42use std::time::Instant;
43
44use tokio::sync::mpsc;
45
46use crate::app::{Config, MemoryConfig};
47use crate::domain::{
48 Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
49};
50use crate::models::{ModelError, TokenUsage};
51use crate::providers::ctx::{ExecContext, StreamContext};
52use crate::providers::model::ModelProvider;
53use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
54
55pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
56pub use turn_scope::TurnScope;
57
58#[cfg(not(test))]
59const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
60#[cfg(test)]
61const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
62
63const CANCELLED_TOMBSTONE_CAP: usize = 256;
69
70pub type MsgSender = mpsc::Sender<Msg>;
76
77pub const MSG_CHANNEL_CAPACITY: usize = 512;
83
84pub struct EffectRunner {
87 msg_tx: MsgSender,
88 scopes: HashMap<TurnId, TurnScope>,
94 cancelled_turns: VecDeque<TurnId>,
100 detached: tokio::task::JoinSet<()>,
104 workdir: PathBuf,
108 providers: Option<Arc<ProviderFactory>>,
113 tools: Option<Arc<ToolRegistry>>,
116 task_id: Option<String>,
118 terminal_title_enabled: bool,
122 approval: Option<crate::providers::ApprovalBroker>,
126 config_watch: Option<tokio::task::AbortHandle>,
130}
131
132impl EffectRunner {
133 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
135 Self {
136 msg_tx,
137 scopes: HashMap::new(),
138 cancelled_turns: VecDeque::new(),
139 detached: tokio::task::JoinSet::new(),
140 workdir,
141 providers: None,
142 tools: None,
143 task_id: None,
144 terminal_title_enabled: true,
145 approval: None,
146 config_watch: None,
147 }
148 }
149
150 pub fn with_interactive_approvals(mut self) -> Self {
154 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
155 self
156 }
157
158 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
164 let handle = self.detached.spawn(config_watch::config_watcher(
165 self.msg_tx.clone(),
166 cwd,
167 memory,
168 ));
169 self.config_watch = Some(handle);
170 }
171
172 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
175 self.task_id = task_id;
176 self
177 }
178
179 pub fn without_terminal_title(mut self) -> Self {
181 self.terminal_title_enabled = false;
182 self
183 }
184
185 pub fn with_bindings(
190 mut self,
191 providers: Arc<ProviderFactory>,
192 tools: Arc<ToolRegistry>,
193 ) -> Self {
194 self.providers = Some(providers);
195 self.tools = Some(tools);
196 self
197 }
198
199 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
203 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
204 (Self::new(tx, workdir), rx)
205 }
206
207 pub fn pair_with_bindings(
210 workdir: PathBuf,
211 config: Config,
212 tools: Arc<ToolRegistry>,
213 ) -> (Self, mpsc::Receiver<Msg>) {
214 let providers = Arc::new(ProviderFactory::new(config));
215 Self::pair_from(workdir, providers, tools)
216 }
217
218 pub fn pair_from(
223 workdir: PathBuf,
224 providers: Arc<ProviderFactory>,
225 tools: Arc<ToolRegistry>,
226 ) -> (Self, mpsc::Receiver<Msg>) {
227 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
228 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
229 }
230
231 pub fn pair_from_with_task(
232 workdir: PathBuf,
233 providers: Arc<ProviderFactory>,
234 tools: Arc<ToolRegistry>,
235 task_id: Option<String>,
236 ) -> (Self, mpsc::Receiver<Msg>) {
237 let (runner, rx) = Self::pair_from(workdir, providers, tools);
238 (runner.with_task_id(task_id), rx)
239 }
240
241 pub fn new_child(
246 msg_tx: MsgSender,
247 workdir: PathBuf,
248 providers: Arc<ProviderFactory>,
249 tools: Arc<ToolRegistry>,
250 ) -> Self {
251 Self::new(msg_tx, workdir)
256 .with_bindings(providers, tools)
257 .without_terminal_title()
258 }
259
260 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
264 self.scopes
265 .entry(turn)
266 .or_insert_with(|| TurnScope::new(turn))
267 }
268
269 fn tombstone_turn(&mut self, turn: TurnId) {
273 if self.cancelled_turns.contains(&turn) {
274 return;
275 }
276 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
277 self.cancelled_turns.pop_front();
278 }
279 self.cancelled_turns.push_back(turn);
280 }
281
282 fn is_tombstoned(&self, turn: TurnId) -> bool {
285 self.cancelled_turns.contains(&turn)
286 }
287
288 fn drop_scope(&mut self, turn: TurnId) {
298 self.tombstone_turn(turn);
303 if let Some(mut scope) = self.scopes.remove(&turn) {
304 scope.cancel();
305 let tx = self.msg_tx.clone();
306 self.detached.spawn(async move {
307 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
308 .await
309 .is_err()
310 {
311 tracing::warn!(
312 turn = %turn,
313 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
314 "cancel drain timed out; aborting remaining scoped tasks"
315 );
316 }
317 let _ = tx.send(Msg::TurnCancelled(turn)).await;
318 });
319 } else {
320 let tx = self.msg_tx.clone();
327 self.detached.spawn(async move {
328 let _ = tx.send(Msg::TurnCancelled(turn)).await;
329 });
330 }
331 }
332
333 pub fn scope_count(&self) -> usize {
336 self.scopes.len()
337 }
338
339 fn reap_empty_scopes(&mut self) {
348 self.reap_detached();
349 self.scopes.retain(|_, scope| {
350 scope.drain_completed();
351 !scope.is_empty()
352 });
353 }
354
355 fn reap_detached(&mut self) {
360 while let Some(result) = self.detached.try_join_next() {
361 if let Err(e) = result
362 && !e.is_cancelled()
363 {
364 tracing::warn!(error = %e, "effect: detached task panicked");
365 }
366 }
367 }
368
369 pub fn dispatch(&mut self, cmd: Cmd) {
373 self.reap_empty_scopes();
376 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
377
378 if let Some(turn) = cmd.scope_turn()
386 && self.is_tombstoned(turn)
387 {
388 tracing::debug!(
389 cmd = %cmd.summary(),
390 turn = %turn,
391 "effect: dropping turn-scoped cmd for an already-cancelled turn"
392 );
393 return;
394 }
395
396 match cmd {
397 Cmd::CallModel { turn, mut request } => {
398 let tx = self.msg_tx.clone();
399 let providers = self.providers.clone();
400 if let Some(tools) = &self.tools {
407 let mut enriched = tools.describe_all();
408 let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
412 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
418 tracing::debug!(
419 error = %e,
420 "effect: dropped builtin tool-schema token estimate (channel full); \
421 /context preview may be briefly stale"
422 );
423 }
424 enriched.append(&mut request.tools);
425 request.tools = enriched;
426 }
427 self.detached.spawn(fire_plugin_hooks(
430 "prompt_submit",
431 serde_json::json!({
432 "turn_id": turn.0,
433 "model_id": request.model_id.clone(),
434 "message_count": request.messages.len(),
435 "tool_count": request.tools.len(),
436 }),
437 ));
438 let scope = self.scope_mut(turn);
439 let token = scope.token();
440 scope.spawn(async move {
441 use futures::FutureExt;
442 let fallback_tx = tx.clone();
443 if std::panic::AssertUnwindSafe(dispatch_call_model(
444 tx, providers, turn, request, token,
445 ))
446 .catch_unwind()
447 .await
448 .is_err()
449 {
450 tracing::error!(turn = %turn, "dispatch_call_model panicked");
455 let _ = fallback_tx
456 .send(Msg::UpstreamError {
457 turn,
458 error: crate::models::UserFacingError {
459 summary: "Internal error".to_string(),
460 message: "The model dispatch task panicked unexpectedly."
461 .to_string(),
462 suggestion: "This is a bug. Please retry; if it persists, \
463 check the logs."
464 .to_string(),
465 category: crate::models::ErrorCategory::Internal,
466 recoverable: true,
467 },
468 })
469 .await;
470 }
471 });
472 },
473 Cmd::CompactConversation { turn, mut request } => {
474 let tx = self.msg_tx.clone();
475 let providers = self.providers.clone();
476 if let Some(tools) = &self.tools {
477 let mut enriched = tools.describe_all();
478 enriched.append(&mut request.chat.tools);
479 request.chat.tools = enriched;
480 }
481 let trigger = request.trigger;
484 let scope = self.scope_mut(turn);
485 let token = scope.token();
486 scope.spawn(async move {
487 use futures::FutureExt;
488 let fallback_tx = tx.clone();
489 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
490 tx, providers, turn, request, token,
491 ))
492 .catch_unwind()
493 .await
494 .is_err()
495 {
496 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
502 let _ = fallback_tx
503 .send(Msg::CompactionFailed {
504 turn,
505 trigger,
506 message: "the compaction task panicked unexpectedly".to_string(),
507 kind: crate::domain::StatusKind::Error,
508 })
509 .await;
510 }
511 });
512 },
513 Cmd::ExecuteTool {
514 turn,
515 call_id,
516 source,
517 model_id,
518 safety_mode,
519 intent,
520 } => {
521 let tx = self.msg_tx.clone();
522 let tools = self.tools.clone();
523 let workdir = self.workdir.clone();
524 let config = self
529 .providers
530 .as_ref()
531 .map(|p| Arc::new(p.config().clone()))
532 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
533 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
538 if safety_mode == crate::runtime::SafetyMode::Auto {
539 self.providers.as_ref().map(|p| {
540 let model = config
541 .safety
542 .auto_classifier_model
543 .clone()
544 .unwrap_or_else(|| model_id.clone());
545 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
546 as Arc<dyn crate::providers::AutoClassifier>
547 })
548 } else {
549 None
550 };
551 let task_id = self.task_id.clone();
552 let approval = self.approval.clone();
553 let scope = self.scope_mut(turn);
554 let token = scope.token();
555 let background = scope.background_token();
556 scope.spawn(async move {
557 use futures::FutureExt;
558 let fallback_tx = tx.clone();
559 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
560 tx,
561 tools,
562 workdir,
563 turn,
564 call_id,
565 source,
566 token,
567 background,
568 config,
569 model_id,
570 task_id,
571 safety_mode,
572 intent,
573 classifier,
574 approval,
575 ))
576 .catch_unwind()
577 .await
578 .is_err()
579 {
580 tracing::error!(
585 turn = %turn,
586 call_id = call_id.0,
587 "dispatch_execute_tool panicked"
588 );
589 let _ = fallback_tx
590 .send(Msg::ToolFinished {
591 turn,
592 call_id,
593 outcome: crate::domain::ToolOutcome::error(
594 "internal error: the tool execution task panicked".to_string(),
595 0.0,
596 ),
597 })
598 .await;
599 }
600 });
601 },
602 Cmd::ResolveApproval { call_id, decision } => {
603 if let Some(broker) = &self.approval {
606 broker.resolve(call_id, decision.into());
607 }
608 },
609 Cmd::CancelScope(turn) => {
610 self.drop_scope(turn);
611 },
612 Cmd::BackgroundScope(turn) => {
613 self.scope_mut(turn).background();
617 },
618 Cmd::SaveConversation(history) => {
619 let tx = self.msg_tx.clone();
620 let workdir = self.workdir.clone();
621 self.detached.spawn(async move {
622 if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
623 && manager.save_conversation(&history).is_ok()
624 {
625 let _ = tx.send(Msg::SessionSaved).await;
626 } else {
627 tracing::warn!("SaveConversation: failed to write to disk");
628 }
629 });
630 },
631 Cmd::SaveCompactionArchive {
632 archive,
633 record,
634 conversation,
635 } => {
636 let tx = self.msg_tx.clone();
637 let workdir = self.workdir.clone();
638 let task_id = self.task_id.clone();
639 self.detached.spawn(async move {
640 let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
641 return;
642 };
643 match manager.save_compaction_archive(&archive) {
648 Ok(path) => {
649 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
650 let compaction_id = record.id.clone();
651 let conversation_id = archive.conversation_id.clone();
652 let _ =
653 store.compactions().create(crate::runtime::NewCompaction {
654 id: Some(record.id),
655 task_id: task_id.clone(),
656 session_id: Some(archive.conversation_id),
657 source_token_estimate: Some(record.before_tokens as i64),
658 summary_token_count: Some(record.summary_tokens as i64),
659 preserved_turns: Some(record.preserved_message_count as i64),
660 archive_path: Some(path.display().to_string()),
661 verification_status: Some("verified".to_string()),
662 });
663 fire_plugin_hooks(
664 "compaction",
665 serde_json::json!({
666 "id": compaction_id,
667 "task_id": task_id.clone(),
668 "session_id": conversation_id,
669 "archive_path": path.display().to_string(),
670 }),
671 )
672 .await;
673 }
674 if manager.save_conversation(&conversation).is_ok() {
675 let _ = tx.send(Msg::SessionSaved).await;
676 } else {
677 tracing::warn!(
678 "SaveCompactionArchive: archive persisted but conversation save failed"
679 );
680 }
681 },
682 Err(err) => {
683 tracing::warn!(
684 error = %err,
685 "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
686 );
687 },
688 }
689 });
690 },
691 Cmd::SaveProcess(process) => {
692 let task_id = self.task_id.clone();
693 self.detached.spawn(async move {
694 let status = match process.status {
695 crate::domain::ManagedProcessStatus::Running => {
696 crate::runtime::ProcessStatus::Running
697 },
698 crate::domain::ManagedProcessStatus::Exited => {
699 crate::runtime::ProcessStatus::Exited
700 },
701 crate::domain::ManagedProcessStatus::Unknown => {
702 crate::runtime::ProcessStatus::Unknown
703 },
704 };
705 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
706 let _ = store.processes().upsert(crate::runtime::NewProcess {
707 id: Some(process.id),
708 task_id,
709 pid: process.pid,
710 command: process.command,
711 cwd: process.cwd,
712 log_path: Some(process.log_path),
713 detected_url: process.detected_url,
714 status,
715 health: None,
716 });
717 }
718 });
719 },
720 Cmd::PersistLastModel(model) => {
721 self.detached.spawn(async move {
722 let _ = crate::app::persist_last_model(&model);
723 });
724 },
725 Cmd::PersistReasoningFor { model_id, level } => {
726 self.detached.spawn(async move {
727 let _ = crate::app::persist_reasoning_for_model(&model_id, level);
728 });
729 },
730 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
731 self.detached.spawn(async move {
732 let _ = crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx);
733 });
734 },
735 Cmd::PersistOllamaOffload(enabled) => {
736 self.detached.spawn(async move {
737 let _ = crate::app::persist_ollama_allow_ram_offload(enabled);
738 });
739 },
740 Cmd::ListMemory => {
741 let tx = self.msg_tx.clone();
742 let workdir = self.workdir.clone();
743 self.detached.spawn(async move {
744 let cfg = crate::app::load_config().unwrap_or_default().memory;
745 let text = match crate::app::memory::load(&workdir, &cfg) {
746 Some(mem) => mem.index,
747 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(),
748 };
749 let _ = tx.send(Msg::RuntimeText(text)).await;
750 });
751 },
752 Cmd::RememberMemory { text } => {
753 let tx = self.msg_tx.clone();
754 let workdir = self.workdir.clone();
755 self.detached.spawn(async move {
756 let cfg = crate::app::load_config().unwrap_or_default().memory;
757 let name = memory_title_from_text(&text);
758 let status = match crate::app::memory::write_memory(
759 &workdir,
760 crate::app::memory::MemoryScope::ProjectPrivate,
761 &name,
762 &text,
763 &[],
764 &text,
765 ) {
766 Ok(_) => format!("Remembered: {name}"),
767 Err(e) => format!("Couldn't save memory: {e}"),
768 };
769 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
770 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
771 let _ = tx.send(Msg::TransientStatus { text: status }).await;
772 });
773 },
774 Cmd::ForgetMemory { id } => {
775 let tx = self.msg_tx.clone();
776 let workdir = self.workdir.clone();
777 self.detached.spawn(async move {
778 let cfg = crate::app::load_config().unwrap_or_default().memory;
779 let status = match crate::app::memory::delete_memory(&workdir, &id) {
780 Ok(Some(_)) => format!("Forgot: {id}"),
781 Ok(None) => format!("No memory named '{id}'"),
782 Err(e) => format!("Couldn't forget memory: {e}"),
783 };
784 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
785 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
786 let _ = tx.send(Msg::TransientStatus { text: status }).await;
787 });
788 },
789 Cmd::ConsolidateMemory { model_id } => {
790 let tx = self.msg_tx.clone();
791 let workdir = self.workdir.clone();
792 let providers = self.providers.clone();
793 self.detached.spawn(async move {
794 consolidate_memory(tx, providers, workdir, model_id).await;
795 });
796 },
797 Cmd::LoadConversation(id) => {
798 let tx = self.msg_tx.clone();
799 let workdir = self.workdir.clone();
800 self.detached.spawn(async move {
801 match crate::session::ConversationManager::new(&workdir) {
802 Ok(mgr) => match mgr.load_conversation(&id) {
803 Ok(history) => {
804 let _ = tx.send(Msg::ConversationLoaded(history)).await;
805 },
806 Err(e) => {
807 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
808 },
809 },
810 Err(e) => {
811 tracing::warn!(error = %e, "ConversationManager init failed");
812 },
813 }
814 });
815 },
816 Cmd::ListConversations => {
817 let tx = self.msg_tx.clone();
818 let workdir = self.workdir.clone();
819 self.detached.spawn(async move {
820 let summaries = match crate::session::ConversationManager::new(&workdir) {
821 Ok(mgr) => mgr
822 .list_conversations()
823 .unwrap_or_default()
824 .into_iter()
825 .map(|h| crate::domain::ConversationSummary {
826 id: h.id.clone(),
827 title: h.title.clone(),
828 message_count: h.messages.len(),
829 updated_at: h.updated_at.to_rfc3339(),
830 })
831 .collect(),
832 Err(_) => Vec::new(),
833 };
834 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
835 });
836 },
837 Cmd::ListRuntimeTasks { limit } => {
838 let tx = self.msg_tx.clone();
839 self.detached.spawn_blocking(move || {
842 let tasks = crate::runtime::RuntimeClient::auto()
843 .list_tasks(limit)
844 .map(|read| read.value)
845 .unwrap_or_default();
846 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
847 });
848 },
849 Cmd::LoadRuntimeTask { id } => {
850 let tx = self.msg_tx.clone();
851 self.detached.spawn_blocking(move || {
852 let (task, events) = crate::runtime::RuntimeClient::auto()
853 .task_detail(&id)
854 .map(|read| (Some(read.value.task), read.value.events))
855 .unwrap_or((None, Vec::new()));
856 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
857 });
858 },
859 Cmd::ListRuntimeProcesses { limit } => {
860 let tx = self.msg_tx.clone();
861 self.detached.spawn_blocking(move || {
862 let processes = crate::runtime::RuntimeClient::auto()
863 .list_processes(limit)
864 .map(|read| read.value)
865 .unwrap_or_default();
866 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
867 });
868 },
869 Cmd::ShowRuntimeProcessLogs { id } => {
870 let tx = self.msg_tx.clone();
871 self.detached.spawn_blocking(move || {
872 let text = crate::runtime::RuntimeClient::auto()
873 .process_log(&id, None)
874 .map(|log| format!("Process log {}\n\n{}", id, log.content))
875 .unwrap_or_else(|err| format!("Process log error: {}", err));
876 let _ = tx.blocking_send(Msg::RuntimeText(text));
877 });
878 },
879 Cmd::StopRuntimeProcess { id } => {
880 let tx = self.msg_tx.clone();
881 self.detached.spawn_blocking(move || {
882 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
883 Ok(response) => Msg::TransientStatus {
884 text: format!("Stopped process {} (pid {})", id, response.item.pid),
885 },
886 Err(err) => Msg::TransientStatus {
887 text: format!("Process stop failed: {}", err),
888 },
889 };
890 let _ = tx.blocking_send(msg);
891 });
892 },
893 Cmd::RestartRuntimeProcess { id } => {
894 let tx = self.msg_tx.clone();
895 self.detached.spawn_blocking(move || {
896 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
897 Ok(response) => Msg::TransientStatus {
898 text: format!("Restarted process {} (pid {})", id, response.item.pid),
899 },
900 Err(err) => Msg::TransientStatus {
901 text: format!("Process restart failed: {}", err),
902 },
903 };
904 let _ = tx.blocking_send(msg);
905 });
906 },
907 Cmd::OpenRuntimeTarget { target } => {
908 self.detached.spawn_blocking(move || {
909 let resolved = crate::runtime::RuntimeService::open_default()
910 .and_then(|service| service.resolve_open_target(&target))
911 .unwrap_or(target);
912 if let Err(err) = crate::runtime::validate_open_target(&resolved) {
916 tracing::warn!(error = %err, "refusing to open runtime target");
917 return;
918 }
919 crate::utils::open_file(resolved);
920 });
921 },
922 Cmd::ShowRuntimePorts => {
923 let tx = self.msg_tx.clone();
924 self.detached.spawn_blocking(move || {
925 let text = crate::runtime::RuntimeClient::auto()
926 .ports()
927 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
928 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
929 let _ = tx.blocking_send(Msg::RuntimeText(text));
930 });
931 },
932 Cmd::ListRuntimeApprovals => {
933 let tx = self.msg_tx.clone();
934 self.detached.spawn_blocking(move || {
935 let approvals = crate::runtime::RuntimeClient::auto()
936 .list_approvals()
937 .map(|read| read.value)
938 .unwrap_or_default();
939 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
940 });
941 },
942 Cmd::DecideRuntimeApproval { id, decision } => {
943 let tx = self.msg_tx.clone();
944 self.detached.spawn_blocking(move || {
945 let result = if decision == "approved" {
946 crate::runtime::RuntimeClient::auto().approve(&id)
947 } else {
948 crate::runtime::RuntimeClient::auto().deny(&id)
949 };
950 let msg = match result {
951 Ok(result) => Msg::TransientStatus {
952 text: if result.replayed {
953 format!("Approval {} {}: {}", id, decision, result.summary)
954 } else {
955 format!("Approval {} {}", id, decision)
956 },
957 },
958 Err(err) => Msg::TransientStatus {
959 text: format!("Approval update failed: {}", err),
960 },
961 };
962 let _ = tx.blocking_send(msg);
963 });
964 },
965 Cmd::ListRuntimeCheckpoints { limit } => {
966 let tx = self.msg_tx.clone();
967 self.detached.spawn_blocking(move || {
968 let checkpoints = crate::runtime::RuntimeClient::auto()
969 .list_checkpoints(limit)
970 .map(|read| read.value)
971 .unwrap_or_default();
972 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
973 });
974 },
975 Cmd::ListRuntimePlugins => {
976 let tx = self.msg_tx.clone();
977 self.detached.spawn_blocking(move || {
978 let plugins = crate::runtime::RuntimeClient::auto()
979 .list_plugins()
980 .map(|read| read.value)
981 .unwrap_or_default();
982 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
983 });
984 },
985 Cmd::UpdateRuntimeTaskStatus {
986 id,
987 status,
988 final_report,
989 } => {
990 let tx = self.msg_tx.clone();
991 self.detached.spawn_blocking(move || {
992 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
993 store
994 .tasks()
995 .update_status(&id, status, final_report.as_deref())
996 }) {
997 Ok(()) => Msg::TransientStatus {
998 text: format!("Task {} -> {}", id, status),
999 },
1000 Err(err) => Msg::TransientStatus {
1001 text: format!("Task update failed: {}", err),
1002 },
1003 };
1004 let _ = tx.blocking_send(msg);
1005 });
1006 },
1007 Cmd::CreateRuntimeCheckpoint { paths } => {
1008 let tx = self.msg_tx.clone();
1009 let workdir = self.workdir.clone();
1010 self.detached.spawn_blocking(move || {
1011 let pending_action = Some(serde_json::json!({
1012 "source": "tui",
1013 "command": "checkpoint",
1014 }));
1015 let msg =
1016 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1017 Ok(manifest) => Msg::TransientStatus {
1018 text: format!(
1019 "Checkpoint {} created for {} path(s)",
1020 manifest.id,
1021 manifest.files.len()
1022 ),
1023 },
1024 Err(err) => Msg::TransientStatus {
1025 text: format!("Checkpoint failed: {}", err),
1026 },
1027 };
1028 let _ = tx.blocking_send(msg);
1029 });
1030 },
1031 Cmd::RestoreRuntimeCheckpoint { id } => {
1032 let tx = self.msg_tx.clone();
1033 self.detached.spawn_blocking(move || {
1034 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1035 Ok(result) => Msg::TransientStatus {
1036 text: format!(
1037 "Restored checkpoint {} ({} file(s)){}",
1038 result.checkpoint.id,
1039 result.checkpoint.files.len(),
1040 if result.checkpoint.pending_action.is_some() {
1041 "; pending action available in checkpoint manifest"
1042 } else {
1043 ""
1044 }
1045 ),
1046 },
1047 Err(err) => Msg::TransientStatus {
1048 text: format!("Restore failed: {}", err),
1049 },
1050 };
1051 let _ = tx.blocking_send(msg);
1052 });
1053 },
1054 Cmd::ShowRuntimeModelInfo { model } => {
1055 let tx = self.msg_tx.clone();
1056 self.detached.spawn_blocking(move || {
1057 let text = runtime_model_info_text(&model);
1058 let _ = tx.blocking_send(Msg::RuntimeText(text));
1059 });
1060 },
1061 Cmd::InitMcpServers(configs) => {
1062 let tx = self.msg_tx.clone();
1063 self.detached.spawn(async move {
1064 if configs.is_empty() {
1065 return;
1066 }
1067 crate::mcp::manager_ref::mark_init_started();
1068 let manager =
1069 std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1070 for (name, _cfg) in configs.iter() {
1074 let server_tools: Vec<crate::domain::McpToolSpec> = manager
1075 .get_all_tools()
1076 .iter()
1077 .filter(|(server, _)| server == name)
1078 .map(|(_, def)| crate::domain::McpToolSpec {
1079 name: def.name.clone(),
1080 description: def.description.clone(),
1081 input_schema: def.input_schema.clone(),
1082 })
1083 .collect();
1084 let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1085 let _ = tx.send(msg).await;
1086 }
1087 crate::mcp::manager_ref::set_manager(manager);
1088 crate::mcp::manager_ref::mark_init_complete();
1089 });
1090 },
1091 Cmd::StopMcpServer { name } => {
1092 let tx = self.msg_tx.clone();
1093 self.detached.spawn(async move {
1094 if let Some(mgr) = crate::mcp::manager_ref::get() {
1097 mgr.stop_server(&name).await;
1098 }
1099 let _ = tx.send(Msg::McpServerStopped { name }).await;
1100 });
1101 },
1102 Cmd::PullOllamaModel { model } => {
1103 let tx = self.msg_tx.clone();
1104 self.detached.spawn(async move {
1105 dispatch_pull_ollama_model(tx, model).await;
1106 });
1107 },
1108 Cmd::OpenInSystem(path) => {
1109 self.detached.spawn(async move {
1110 let _ = tokio::task::spawn_blocking(move || {
1111 crate::utils::open_file(&path);
1112 })
1113 .await;
1114 });
1115 },
1116 Cmd::WriteImageToTemp {
1117 path,
1118 bytes,
1119 format: _,
1120 } => {
1121 self.detached.spawn(async move {
1122 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1123 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1124 }
1125 });
1126 },
1127 Cmd::ReadClipboard => {
1128 let tx = self.msg_tx.clone();
1129 self.detached.spawn(async move {
1130 dispatch_read_clipboard(tx).await;
1131 });
1132 },
1133 Cmd::CopyToClipboard(text) => {
1134 let tx = self.msg_tx.clone();
1135 self.detached.spawn(async move {
1136 dispatch_copy_to_clipboard(text, tx).await;
1137 });
1138 },
1139 Cmd::Exit => {
1140 },
1145 Cmd::SetTerminalTitle(title) => {
1146 if !self.terminal_title_enabled {
1147 return;
1148 }
1149 self.detached.spawn_blocking(move || {
1155 use std::io::Write;
1156 let seq = format!("\x1b]2;{}\x07", title);
1157 let mut stdout = std::io::stdout();
1158 let _ = stdout.write_all(seq.as_bytes());
1159 let _ = stdout.flush();
1160 });
1161 },
1162 }
1163 }
1164
1165 pub async fn shutdown(mut self) {
1169 for (id, scope) in self.scopes.iter() {
1170 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1171 scope.cancel();
1172 }
1173
1174 if let Some(handle) = self.config_watch.take() {
1177 handle.abort();
1178 }
1179
1180 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1182
1183 let drain = async {
1184 let _ = tokio::time::timeout(
1189 std::time::Duration::from_secs(2),
1190 crate::mcp::manager_ref::wait_ready(),
1191 )
1192 .await;
1193 if let Some(mgr) = crate::mcp::manager_ref::get() {
1199 mgr.shutdown().await;
1200 }
1201 for (id, mut scope) in self.scopes.drain() {
1207 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1208 .await
1209 .is_err()
1210 {
1211 tracing::warn!(
1212 turn = %id,
1213 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1214 "shutdown: scope drain timed out; aborting its remaining tasks"
1215 );
1216 }
1217 }
1218 while let Some(result) = self.detached.join_next().await {
1219 if let Err(e) = result
1220 && !e.is_cancelled()
1221 {
1222 tracing::warn!(error = %e, "shutdown: detached task panic");
1223 }
1224 }
1225 };
1226
1227 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1228 }
1229}
1230
1231async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1240 if let Err(e) = handle.await
1241 && !e.is_cancelled()
1242 {
1243 tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1244 }
1245}
1246
1247struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
1254
1255impl AbortOnDrop {
1256 fn take(mut self) -> tokio::task::JoinHandle<()> {
1257 self.0.take().expect("AbortOnDrop::take called once")
1258 }
1259}
1260
1261impl Drop for AbortOnDrop {
1262 fn drop(&mut self) {
1263 if let Some(handle) = &self.0 {
1264 handle.abort();
1265 }
1266 }
1267}
1268
1269fn spawn_guarded<F>(fut: F) -> AbortOnDrop
1271where
1272 F: std::future::Future<Output = ()> + Send + 'static,
1273{
1274 AbortOnDrop(Some(tokio::spawn(fut)))
1275}
1276
1277async fn dispatch_call_model(
1278 msg_tx: MsgSender,
1279 providers: Option<Arc<ProviderFactory>>,
1280 turn: TurnId,
1281 mut request: crate::domain::ChatRequest,
1282 token: tokio_util::sync::CancellationToken,
1283) {
1284 use crate::models::UserFacingError;
1285
1286 let Some(factory) = providers else {
1287 let error = UserFacingError {
1288 summary: "not wired".to_string(),
1289 message: "EffectRunner has no ProviderFactory bound".to_string(),
1290 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1291 category: crate::models::ErrorCategory::Internal,
1292 recoverable: false,
1293 };
1294 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1295 return;
1296 };
1297
1298 let provider = match factory.resolve(&request.model_id).await {
1300 Ok(p) => p,
1301 Err(e) => {
1302 let error = classify_error_for_ui(&e);
1303 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1304 return;
1305 },
1306 };
1307 {
1308 let model_id = request.model_id.clone();
1312 let caps = provider.capabilities().clone();
1313 if let Err(e) =
1320 tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1321 .await
1322 {
1323 tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1324 }
1325 }
1326 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1327 let _ = msg_tx
1328 .send(Msg::TransientStatus {
1329 text: format!(
1330 "{} does not advertise tool support; Mermaid will send the turn without tools",
1331 request.model_id
1332 ),
1333 })
1334 .await;
1335 request.tools.clear();
1336 }
1337
1338 let sizing = provider.resolve_context_window(&request).await;
1344 let max_context_tokens = sizing.effective.or_else(|| {
1345 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1346 });
1347 let _ = msg_tx
1351 .send(Msg::ProviderContextResolved {
1352 model_id: request.model_id.clone(),
1353 model_max: sizing.model_max,
1354 effective: sizing.effective,
1355 source: sizing.source,
1356 })
1357 .await;
1358 let context_snapshot =
1359 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1360 let _ = msg_tx
1361 .send(Msg::ContextUsageEstimated {
1362 turn,
1363 snapshot: context_snapshot.clone(),
1364 })
1365 .await;
1366
1367 let policy = CompactionPolicy::default();
1368 let mut compacted_before_stream = false;
1369 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1370 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1371 if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1374 match run_compaction(
1375 Arc::clone(&provider),
1376 turn,
1377 compaction,
1378 prepared,
1379 context_snapshot.clone(),
1380 max_context_tokens,
1381 token.clone(),
1382 )
1383 .await
1384 {
1385 Ok(result) => {
1386 request.messages = result.replacement_messages.clone();
1387 compacted_before_stream = true;
1388 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1389 },
1390 Err(err) => {
1391 if token.is_cancelled() {
1399 return;
1400 }
1401 tracing::warn!(
1402 turn = %turn,
1403 error = %err,
1404 "auto-compaction failed; proceeding with the un-compacted request",
1405 );
1406 let _ = msg_tx
1407 .send(Msg::CompactionFailed {
1408 turn,
1409 trigger: CompactionTrigger::AutoThreshold,
1410 message: err.to_string(),
1411 kind: crate::domain::StatusKind::Warn,
1412 })
1413 .await;
1414 },
1415 }
1416 }
1417 }
1418
1419 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1422 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1423
1424 let relay_tx = msg_tx.clone();
1431 let relay_token = token.clone();
1432 let relay = spawn_guarded(async move {
1433 loop {
1434 let event = tokio::select! {
1435 biased;
1436 _ = relay_token.cancelled() => {
1437 while let Ok(buffered) = stream_rx.try_recv() {
1444 if let StreamEvent::Done {
1445 usage,
1446 thinking_signature,
1447 stop_reason,
1448 } = buffered
1449 {
1450 let _ = relay_tx
1451 .send(Msg::StreamDone {
1452 turn,
1453 usage,
1454 thinking_signature,
1455 stop_reason,
1456 })
1457 .await;
1458 }
1459 }
1460 break;
1461 },
1462 ev = stream_rx.recv() => match ev {
1463 Some(ev) => ev,
1464 None => break,
1465 },
1466 };
1467 let msg = match event {
1468 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1469 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1470 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1471 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1473 usage,
1474 thinking_signature,
1475 stop_reason,
1476 } => Msg::StreamDone {
1477 turn,
1478 usage,
1479 thinking_signature,
1480 stop_reason,
1481 },
1482 };
1483 if relay_tx.send(msg).await.is_err() {
1484 break;
1485 }
1486 }
1487 });
1488
1489 let mut completed_ok = false;
1498 match provider.chat(request.clone(), ctx).await {
1499 Ok(_final_response) => {
1500 completed_ok = true;
1502 },
1503 Err(crate::models::ModelError::Cancelled) => {
1504 },
1506 Err(e) => {
1507 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1508 if retry_context_limit {
1509 let latest_snapshot =
1510 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1511 let compaction =
1512 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1513 if let Ok(prepared) =
1516 crate::domain::prepare_compaction(&compaction, max_context_tokens)
1517 {
1518 match run_compaction(
1519 Arc::clone(&provider),
1520 turn,
1521 compaction,
1522 prepared,
1523 latest_snapshot,
1524 max_context_tokens,
1525 token.clone(),
1526 )
1527 .await
1528 {
1529 Ok(result) => {
1530 let mut retry_request = request;
1531 retry_request.messages = result.replacement_messages.clone();
1532 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1533 join_logged(relay.take(), "stream_relay").await;
1534 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1535 .await;
1536 return;
1537 },
1538 Err(compact_err) => {
1539 let _ = msg_tx
1540 .send(Msg::CompactionFailed {
1541 turn,
1542 trigger: CompactionTrigger::ContextLimitRetry,
1543 message: compact_err.to_string(),
1544 kind: crate::domain::StatusKind::Error,
1545 })
1546 .await;
1547 },
1548 }
1549 }
1550 }
1551 let error = classify_error_for_ui(&e);
1552 run_provider_error_hook(&request.model_id, &error).await;
1553 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1554 },
1555 }
1556
1557 join_logged(relay.take(), "stream_relay").await;
1558
1559 if completed_ok
1564 && request.ollama_allow_ram_offload != Some(true)
1565 && let Some(p) = provider.verify_placement(sizing.effective).await
1566 {
1567 tracing::debug!(
1568 size_vram_bytes = p.size_vram_bytes,
1569 total_bytes = p.total_bytes,
1570 offloaded = p.size_vram_bytes < p.total_bytes,
1571 suggested_num_ctx = ?p.suggested_num_ctx,
1572 "Ollama placement"
1573 );
1574 let _ = msg_tx
1575 .send(Msg::OllamaPlacementResolved {
1576 model_id: request.model_id.clone(),
1577 size_vram_bytes: p.size_vram_bytes,
1578 total_bytes: p.total_bytes,
1579 suggested_num_ctx: p.suggested_num_ctx,
1580 })
1581 .await;
1582 }
1583}
1584
1585async fn dispatch_provider_stream(
1586 msg_tx: MsgSender,
1587 provider: Arc<dyn ModelProvider>,
1588 turn: TurnId,
1589 request: crate::domain::ChatRequest,
1590 token: tokio_util::sync::CancellationToken,
1591) {
1592 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1593 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1594 let relay_tx = msg_tx.clone();
1595 let relay_token = token.clone();
1596 let relay = spawn_guarded(async move {
1597 loop {
1598 let event = tokio::select! {
1599 biased;
1600 _ = relay_token.cancelled() => {
1601 while let Ok(buffered) = stream_rx.try_recv() {
1608 if let StreamEvent::Done {
1609 usage,
1610 thinking_signature,
1611 stop_reason,
1612 } = buffered
1613 {
1614 let _ = relay_tx
1615 .send(Msg::StreamDone {
1616 turn,
1617 usage,
1618 thinking_signature,
1619 stop_reason,
1620 })
1621 .await;
1622 }
1623 }
1624 break;
1625 },
1626 ev = stream_rx.recv() => match ev {
1627 Some(ev) => ev,
1628 None => break,
1629 },
1630 };
1631 let msg = match event {
1632 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1633 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1634 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1635 StreamEvent::ThinkingSignature(_) => continue,
1636 StreamEvent::Done {
1637 usage,
1638 thinking_signature,
1639 stop_reason,
1640 } => Msg::StreamDone {
1641 turn,
1642 usage,
1643 thinking_signature,
1644 stop_reason,
1645 },
1646 };
1647 if relay_tx.send(msg).await.is_err() {
1648 break;
1649 }
1650 }
1651 });
1652
1653 let model_id = request.model_id.clone();
1654 match provider.chat(request, ctx).await {
1655 Ok(_) | Err(ModelError::Cancelled) => {},
1656 Err(e) => {
1657 let error = classify_error_for_ui(&e);
1658 run_provider_error_hook(&model_id, &error).await;
1659 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1660 },
1661 }
1662
1663 join_logged(relay.take(), "stream_relay").await;
1664}
1665
1666async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1672 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1673 .await;
1674}
1675
1676async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1677 fire_plugin_hooks(
1678 "provider_error",
1679 serde_json::json!({
1680 "model_id": model_id,
1681 "summary": &error.summary,
1682 "message": &error.message,
1683 "category": format!("{:?}", error.category),
1684 "recoverable": error.recoverable,
1685 }),
1686 )
1687 .await;
1688}
1689
1690fn memory_title_from_text(text: &str) -> String {
1694 let first = text
1695 .lines()
1696 .find(|l| !l.trim().is_empty())
1697 .unwrap_or("memory")
1698 .trim();
1699 let title: String = first
1700 .split_whitespace()
1701 .take(8)
1702 .collect::<Vec<_>>()
1703 .join(" ")
1704 .chars()
1705 .take(60)
1706 .collect();
1707 if title.trim().is_empty() {
1708 "memory".to_string()
1709 } else {
1710 title
1711 }
1712}
1713
1714const 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.";
1715
1716#[derive(Debug)]
1717struct PrunePlan {
1718 prune: Vec<String>,
1719 reason: String,
1720}
1721
1722fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1725 let start = text.find('{')?;
1726 let end = text.rfind('}')?;
1727 if end < start {
1728 return None;
1729 }
1730 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1731 let prune = json
1732 .get("prune")?
1733 .as_array()?
1734 .iter()
1735 .filter_map(|v| v.as_str().map(str::to_string))
1736 .collect();
1737 let reason = json
1738 .get("reason")
1739 .and_then(|v| v.as_str())
1740 .unwrap_or("")
1741 .to_string();
1742 Some(PrunePlan { prune, reason })
1743}
1744
1745async fn consolidate_memory(
1749 tx: MsgSender,
1750 providers: Option<Arc<ProviderFactory>>,
1751 workdir: std::path::PathBuf,
1752 model_id: String,
1753) {
1754 let items = crate::app::memory::entries_with_bodies(&workdir);
1755 if items.len() < 2 {
1756 let _ = tx
1757 .send(Msg::RuntimeText(format!(
1758 "Nothing to consolidate — {} memor{} saved.",
1759 items.len(),
1760 if items.len() == 1 { "y" } else { "ies" }
1761 )))
1762 .await;
1763 return;
1764 }
1765 let Some(factory) = providers else {
1766 let _ = tx
1767 .send(Msg::RuntimeText(
1768 "Memory consolidation needs a model provider, which isn't bound in this session."
1769 .to_string(),
1770 ))
1771 .await;
1772 return;
1773 };
1774
1775 let mut listing = String::new();
1776 for (entry, body) in &items {
1777 let id = entry
1778 .path
1779 .file_stem()
1780 .and_then(|s| s.to_str())
1781 .unwrap_or(entry.name.as_str());
1782 listing.push_str(&format!(
1783 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
1784 entry.scope.as_str(),
1785 entry.description,
1786 body.replace('\n', " ").trim(),
1787 ));
1788 }
1789 let user = format!(
1790 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1791 items.len(),
1792 listing
1793 );
1794 let request = crate::domain::ChatRequest {
1795 model_id: model_id.clone(),
1796 messages: vec![crate::models::ChatMessage::user(user)],
1797 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1798 instructions: None,
1799 reasoning: crate::models::ReasoningLevel::None,
1800 temperature: 0.0,
1801 max_tokens: 1024,
1802 tools: Vec::new(),
1803 ollama_num_ctx: None,
1804 ollama_allow_ram_offload: None,
1805 };
1806
1807 let provider = match factory.resolve(&model_id).await {
1808 Ok(p) => p,
1809 Err(e) => {
1810 let _ = tx
1811 .send(Msg::RuntimeText(format!(
1812 "Memory consolidation failed: {e}"
1813 )))
1814 .await;
1815 return;
1816 },
1817 };
1818 let token = tokio_util::sync::CancellationToken::new();
1819 let text =
1820 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1821 Ok((t, _)) => t,
1822 Err(e) => {
1823 let _ = tx
1824 .send(Msg::RuntimeText(format!(
1825 "Memory consolidation failed: {e}"
1826 )))
1827 .await;
1828 return;
1829 },
1830 };
1831
1832 let Some(plan) = parse_prune_plan(&text) else {
1833 let _ = tx
1834 .send(Msg::RuntimeText(
1835 "Memory consolidation: couldn't parse the model's plan; nothing changed."
1836 .to_string(),
1837 ))
1838 .await;
1839 return;
1840 };
1841 if plan.prune.is_empty() {
1842 let reason = if plan.reason.is_empty() {
1843 String::new()
1844 } else {
1845 format!(" {}", plan.reason)
1846 };
1847 let _ = tx
1848 .send(Msg::RuntimeText(format!(
1849 "Memory consolidation: nothing to prune.{reason}"
1850 )))
1851 .await;
1852 return;
1853 }
1854
1855 let paths: Vec<std::path::PathBuf> = plan
1861 .prune
1862 .iter()
1863 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1864 .collect();
1865 if !paths.is_empty()
1866 && let Err(e) = crate::runtime::create_checkpoint(
1867 &workdir,
1868 &paths,
1869 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1870 )
1871 {
1872 let _ = tx
1873 .send(Msg::RuntimeText(format!(
1874 "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
1875 paths.len(),
1876 if paths.len() == 1 { "" } else { "s" },
1877 )))
1878 .await;
1879 return;
1880 }
1881
1882 let mut pruned = Vec::new();
1883 for id in &plan.prune {
1884 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1885 pruned.push(id.clone());
1886 }
1887 }
1888
1889 let cfg = crate::app::load_config().unwrap_or_default().memory;
1890 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1891 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1892
1893 let report = if pruned.is_empty() {
1894 "Memory consolidation: the model named facts to prune, but none matched existing memories."
1895 .to_string()
1896 } else {
1897 format!(
1898 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1899 pruned.len(),
1900 if pruned.len() == 1 { "" } else { "s" },
1901 pruned.join(", "),
1902 if plan.reason.is_empty() {
1903 String::new()
1904 } else {
1905 format!(" Reason: {}.", plan.reason)
1906 },
1907 )
1908 };
1909 let _ = tx.send(Msg::RuntimeText(report)).await;
1910}
1911
1912async fn dispatch_compact_conversation(
1913 msg_tx: MsgSender,
1914 providers: Option<Arc<ProviderFactory>>,
1915 turn: TurnId,
1916 request: CompactionRequest,
1917 token: tokio_util::sync::CancellationToken,
1918) {
1919 let Some(factory) = providers else {
1920 let _ = msg_tx
1921 .send(Msg::CompactionFailed {
1922 turn,
1923 trigger: request.trigger,
1924 message: "EffectRunner has no ProviderFactory bound".to_string(),
1925 kind: crate::domain::StatusKind::Error,
1926 })
1927 .await;
1928 return;
1929 };
1930
1931 let provider = match factory.resolve(&request.chat.model_id).await {
1932 Ok(provider) => provider,
1933 Err(err) => {
1934 let _ = msg_tx
1935 .send(Msg::CompactionFailed {
1936 turn,
1937 trigger: request.trigger,
1938 message: err.to_string(),
1939 kind: crate::domain::StatusKind::Error,
1940 })
1941 .await;
1942 return;
1943 },
1944 };
1945
1946 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1947 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1948 });
1949 let before_snapshot =
1950 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1951
1952 let trigger = request.trigger;
1953 let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
1958 Ok(prepared) => prepared,
1959 Err(skip) => {
1960 let _ = msg_tx
1961 .send(Msg::CompactionFailed {
1962 turn,
1963 trigger,
1964 message: skip.to_string(),
1965 kind: crate::domain::StatusKind::Info,
1966 })
1967 .await;
1968 return;
1969 },
1970 };
1971 match run_compaction(
1972 provider,
1973 turn,
1974 request,
1975 prepared,
1976 before_snapshot,
1977 max_context_tokens,
1978 token,
1979 )
1980 .await
1981 {
1982 Ok(result) => {
1983 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1984 },
1985 Err(err) => {
1986 let _ = msg_tx
1987 .send(Msg::CompactionFailed {
1988 turn,
1989 trigger,
1990 message: err.to_string(),
1991 kind: crate::domain::StatusKind::Error,
1992 })
1993 .await;
1994 },
1995 }
1996}
1997
1998async fn run_compaction(
1999 provider: Arc<dyn ModelProvider>,
2000 turn: TurnId,
2001 request: CompactionRequest,
2002 prepared: crate::domain::PreparedCompaction,
2003 before_snapshot: crate::domain::ContextUsageSnapshot,
2004 max_context_tokens: Option<usize>,
2005 token: tokio_util::sync::CancellationToken,
2006) -> Result<CompactionResult, ModelError> {
2007 let started = Instant::now();
2008
2009 let summary_request = crate::domain::build_summary_request(
2010 &request.chat,
2011 &prepared,
2012 request.instructions.as_deref(),
2013 request.policy,
2014 );
2015 let (draft, draft_usage) =
2016 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2017 .await?;
2018 let draft_summary = crate::domain::normalize_summary(&draft);
2019 if draft_summary.trim().is_empty() {
2020 return Err(ModelError::InvalidRequest(
2021 "compaction produced an empty summary".to_string(),
2022 ));
2023 }
2024
2025 let verify_request = crate::domain::build_verification_request(
2026 &request.chat,
2027 &prepared,
2028 &draft_summary,
2029 request.instructions.as_deref(),
2030 request.policy,
2031 );
2032 let (final_summary, verify_usage, verified, verification_error) =
2033 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2034 Ok((verified_text, verify_usage)) => {
2035 let verified_summary = crate::domain::normalize_summary(&verified_text);
2036 if verified_summary.trim().is_empty() {
2037 (
2038 draft_summary,
2039 verify_usage,
2040 false,
2041 Some("verification returned an empty summary".to_string()),
2042 )
2043 } else {
2044 (verified_summary, verify_usage, true, None)
2045 }
2046 },
2047 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2048 Err(err) => (draft_summary, None, false, Some(err.to_string())),
2049 };
2050
2051 let id = format!(
2052 "compact_{}",
2053 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2054 );
2055 let mut record = crate::domain::CompactionRecord {
2056 id,
2057 trigger: request.trigger,
2058 created_at: chrono::Local::now(),
2059 before_tokens: before_snapshot.used_tokens,
2060 after_tokens: 0,
2061 archived_message_count: prepared.archived_messages.len(),
2062 preserved_message_count: prepared.preserved_messages.len(),
2063 summary_tokens: final_summary.len().div_ceil(4),
2064 duration_secs: started.elapsed().as_secs_f64(),
2065 verified,
2066 verification_error,
2067 focus: request.instructions.clone(),
2068 archive_path: None,
2069 };
2070
2071 let mut replacement =
2072 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2073 let mut compacted_request = request.chat.clone();
2074 compacted_request.messages = replacement.clone();
2075 let mut after_snapshot =
2076 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2077 record.after_tokens = after_snapshot.used_tokens;
2078 record.duration_secs = started.elapsed().as_secs_f64();
2079 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2080 compacted_request.messages = replacement.clone();
2081 after_snapshot =
2082 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2083 record.after_tokens = after_snapshot.used_tokens;
2084
2085 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2086 return Err(ModelError::InvalidRequest(format!(
2087 "compaction did not reduce context ({} -> {} tokens)",
2088 before_snapshot.used_tokens, after_snapshot.used_tokens
2089 )));
2090 }
2091
2092 if crate::domain::context_exceeds_hard_limit(
2093 &after_snapshot,
2094 &compacted_request,
2095 request.policy,
2096 ) {
2097 return Err(ModelError::InvalidRequest(format!(
2098 "compacted context still exceeds response reserve ({} tokens used)",
2099 after_snapshot.used_tokens
2100 )));
2101 }
2102
2103 Ok(CompactionResult {
2104 record,
2105 replacement_messages: replacement,
2106 archived_messages: prepared.archived_messages,
2107 before_snapshot,
2108 after_snapshot,
2109 usage: crate::domain::combine_usage(draft_usage, verify_usage),
2110 })
2111}
2112
2113async fn collect_compaction_text(
2114 provider: Arc<dyn ModelProvider>,
2115 turn: TurnId,
2116 request: crate::domain::ChatRequest,
2117 token: tokio_util::sync::CancellationToken,
2118) -> Result<(String, Option<TokenUsage>), ModelError> {
2119 crate::providers::model::collect_text(provider, turn, request, token).await
2122}
2123
2124fn record_provider_capabilities(
2125 model_id: &str,
2126 caps: &crate::providers::capabilities::Capabilities,
2127) {
2128 let (provider, model) = split_model_id(model_id);
2129 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2130 for (key, value) in [
2131 ("tools_support", caps.supports_tools.to_string()),
2132 ("vision_support", caps.supports_vision.to_string()),
2133 (
2134 "context_limit",
2135 caps.max_context_tokens
2136 .map(|v| v.to_string())
2137 .unwrap_or_else(|| "unknown".to_string()),
2138 ),
2139 (
2140 "reasoning_parameter_shape",
2141 format!("{:?}", caps.supports_reasoning),
2142 ),
2143 (
2144 "streaming_usage_available",
2145 "provider_dependent".to_string(),
2146 ),
2147 ("token_usage_field_shape", "normalized".to_string()),
2148 ] {
2149 let _ = store
2150 .provider_probes()
2151 .upsert(crate::runtime::NewProviderProbe {
2152 provider: provider.clone(),
2153 model_id: model.clone(),
2154 capability_key: key.to_string(),
2155 capability_value: value,
2156 confidence: "verified".to_string(),
2157 error: None,
2158 });
2159 }
2160 }
2161}
2162
2163fn split_model_id(model_id: &str) -> (String, String) {
2164 match model_id.split_once('/') {
2165 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2166 (provider.to_ascii_lowercase(), model.to_string())
2167 },
2168 _ => ("ollama".to_string(), model_id.to_string()),
2169 }
2170}
2171
2172fn is_context_limit_error(error: &ModelError) -> bool {
2173 let text = error.to_string().to_lowercase();
2174 text.contains("context")
2175 && (text.contains("too large")
2176 || text.contains("exceed")
2177 || text.contains("maximum")
2178 || text.contains("token"))
2179}
2180
2181#[allow(clippy::too_many_arguments)]
2183async fn dispatch_execute_tool(
2184 msg_tx: MsgSender,
2185 tools: Option<Arc<ToolRegistry>>,
2186 workdir: PathBuf,
2187 turn: TurnId,
2188 call_id: crate::domain::ToolCallId,
2189 source: crate::models::tool_call::ToolCall,
2190 token: tokio_util::sync::CancellationToken,
2191 background: tokio_util::sync::CancellationToken,
2192 config: Arc<crate::app::Config>,
2193 model_id: String,
2194 task_id: Option<String>,
2195 safety_mode: crate::runtime::SafetyMode,
2196 intent: Option<String>,
2197 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2198 approval: Option<crate::providers::ApprovalBroker>,
2199) {
2200 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2201
2202 let Some(registry) = tools else {
2203 let _ = msg_tx
2204 .send(Msg::ToolFinished {
2205 turn,
2206 call_id,
2207 outcome: crate::domain::ToolOutcome::error(
2208 "EffectRunner has no ToolRegistry bound",
2209 0.0,
2210 ),
2211 })
2212 .await;
2213 return;
2214 };
2215
2216 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2220 let rest = &source.function.name[5..];
2221 if let Some((server, tool)) = rest.split_once("__") {
2222 (
2223 "mcp_proxy",
2224 serde_json::json!({
2225 "server_name": server,
2226 "tool_name": tool,
2227 "arguments": source.function.arguments.clone(),
2228 }),
2229 )
2230 } else {
2231 let _ = msg_tx
2232 .send(Msg::ToolFinished {
2233 turn,
2234 call_id,
2235 outcome: crate::domain::ToolOutcome::error(
2236 format!("invalid MCP tool name: {}", source.function.name),
2237 0.0,
2238 ),
2239 })
2240 .await;
2241 return;
2242 }
2243 } else {
2244 (
2245 source.function.name.as_str(),
2246 source.function.arguments.clone(),
2247 )
2248 };
2249 let tool_run_id =
2250 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2251
2252 let Some(tool) = registry.get(tool_key) else {
2253 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2254 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2255 let _ = msg_tx
2256 .send(Msg::ToolFinished {
2257 turn,
2258 call_id,
2259 outcome,
2260 })
2261 .await;
2262 return;
2263 };
2264
2265 let (progress_tx, mut progress_rx) = mpsc::channel(16);
2271 let relay_tx = msg_tx.clone();
2272 let relay_token = token.clone();
2273 let progress_relay = spawn_guarded(async move {
2274 loop {
2275 let event = tokio::select! {
2276 biased;
2277 _ = relay_token.cancelled() => break,
2278 ev = progress_rx.recv() => match ev {
2279 Some(ev) => ev,
2280 None => break,
2281 },
2282 };
2283 if relay_tx
2284 .send(Msg::ToolProgress {
2285 turn,
2286 call_id,
2287 event,
2288 })
2289 .await
2290 .is_err()
2291 {
2292 break;
2293 }
2294 }
2295 });
2296
2297 let mut ctx = ExecContext::new(
2298 token,
2299 progress_tx,
2300 call_id,
2301 turn,
2302 workdir,
2303 config,
2304 model_id,
2305 task_id,
2306 safety_mode,
2307 intent,
2308 classifier,
2309 approval,
2310 );
2311 ctx.background = background;
2312 let before_payload = serde_json::json!({
2313 "turn_id": turn.0,
2314 "call_id": call_id.0,
2315 "tool": tool_key,
2316 });
2317 fire_plugin_hooks("before_tool_use", before_payload).await;
2318 let outcome = tool.execute(args, ctx).await;
2319 let after_payload = serde_json::json!({
2320 "turn_id": turn.0,
2321 "call_id": call_id.0,
2322 "tool": tool_key,
2323 "status": tool_status_label(outcome.status),
2324 "summary": &outcome.summary,
2325 });
2326 fire_plugin_hooks("after_tool_use", after_payload).await;
2327 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2328 join_logged(progress_relay.take(), "tool_progress_relay").await;
2329 let _ = msg_tx
2330 .send(Msg::ToolFinished {
2331 turn,
2332 call_id,
2333 outcome,
2334 })
2335 .await;
2336}
2337
2338async fn start_runtime_tool_run(
2339 task_id: Option<&str>,
2340 turn: TurnId,
2341 call_id: crate::domain::ToolCallId,
2342 tool_name: &str,
2343 args: &serde_json::Value,
2344) -> Option<String> {
2345 let task_id = task_id.map(str::to_string);
2349 let tool_name = tool_name.to_string();
2350 let args_json = serde_json::to_string(args).ok();
2351 tokio::task::spawn_blocking(move || {
2352 crate::runtime::RuntimeStore::open_default()
2353 .and_then(|store| {
2354 store.tool_runs().start(crate::runtime::NewToolRun {
2355 id: None,
2356 task_id,
2357 turn_id: Some(turn.0.to_string()),
2358 call_id: Some(call_id.0.to_string()),
2359 tool_name,
2360 args_json,
2361 })
2362 })
2363 .map(|record| record.id)
2364 .ok()
2365 })
2366 .await
2367 .ok()
2368 .flatten()
2369}
2370
2371fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2372 let Some(tool_run_id) = tool_run_id else {
2373 return;
2374 };
2375 let tool_run_id = tool_run_id.to_string();
2376 let status = tool_status_label(outcome.status).to_string();
2377 let output_json = serde_json::to_string(&serde_json::json!({
2378 "status": tool_status_label(outcome.status),
2379 "summary": &outcome.summary,
2380 "model_content": &outcome.model_content,
2381 "error": &outcome.error,
2382 "metadata": &outcome.metadata,
2383 "artifacts": &outcome.artifacts,
2384 "duration_secs": outcome.duration_secs,
2385 }))
2386 .ok();
2387 tokio::task::spawn_blocking(move || {
2390 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2391 let _ = store
2392 .tool_runs()
2393 .finish(&tool_run_id, &status, output_json.as_deref());
2394 }
2395 });
2396}
2397
2398fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2399 match status {
2400 crate::domain::ToolStatus::Success => "success",
2401 crate::domain::ToolStatus::Error => "error",
2402 crate::domain::ToolStatus::Cancelled => "cancelled",
2403 }
2404}
2405
2406fn runtime_model_info_text(model: &str) -> String {
2407 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2408 let mut lines = vec![
2409 format!("Model info: {}", model),
2410 format!("- provider: {}", snapshot.provider),
2411 format!("- model: {}", snapshot.model),
2412 format!("- supports tools: {}", snapshot.supports_tools),
2413 format!("- supports vision: {}", snapshot.supports_vision),
2414 format!("- reasoning: {}", snapshot.reasoning),
2415 format!(
2416 "- context limit: {}",
2417 snapshot
2418 .max_context_tokens
2419 .map(|value: usize| value.to_string())
2420 .unwrap_or_else(|| "unknown".to_string())
2421 ),
2422 ];
2423 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2424 && let Ok(probes) = store
2425 .provider_probes()
2426 .list(Some(&snapshot.provider), Some(&snapshot.model))
2427 && !probes.is_empty()
2428 {
2429 lines.push(String::new());
2430 lines.push("Cached provider reality records:".to_string());
2431 for probe in probes {
2432 lines.push(format!(
2433 "- {} = {} ({})",
2434 probe.capability_key, probe.capability_value, probe.confidence
2435 ));
2436 }
2437 }
2438 lines.join("\n")
2439}
2440
2441async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2446 use tokio::io::{AsyncBufReadExt, BufReader};
2447 use tokio::process::Command;
2448
2449 let mut cmd = Command::new("ollama");
2450 cmd.arg("pull")
2451 .arg(&model)
2452 .stdin(std::process::Stdio::null())
2453 .stdout(std::process::Stdio::piped())
2454 .stderr(std::process::Stdio::piped())
2455 .kill_on_drop(true);
2456
2457 let mut child = match cmd.spawn() {
2458 Ok(c) => c,
2459 Err(e) => {
2460 let _ = tx
2461 .send(Msg::ModelPullProgress(format!(
2462 "ollama pull failed to start: {}",
2463 e
2464 )))
2465 .await;
2466 return;
2467 },
2468 };
2469
2470 let reader_handle = child.stdout.take().map(|stdout| {
2474 let tx_inner = tx.clone();
2475 tokio::spawn(async move {
2476 let mut reader = BufReader::new(stdout).lines();
2477 while let Ok(Some(line)) = reader.next_line().await {
2478 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2479 }
2480 })
2481 });
2482
2483 match child.wait().await {
2484 Ok(status) if status.success() => {
2485 let _ = tx.send(Msg::ModelPullFinished { model }).await;
2486 },
2487 Ok(status) => {
2488 let _ = tx
2489 .send(Msg::ModelPullProgress(format!(
2490 "ollama pull exited with status {}",
2491 status.code().unwrap_or(-1)
2492 )))
2493 .await;
2494 },
2495 Err(e) => {
2496 let _ = tx
2497 .send(Msg::ModelPullProgress(format!(
2498 "ollama pull wait error: {}",
2499 e
2500 )))
2501 .await;
2502 },
2503 }
2504
2505 if let Some(handle) = reader_handle {
2508 join_logged(handle, "ollama_pull_reader").await;
2509 }
2510}
2511
2512fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2513 if started {
2514 Msg::McpServerReady {
2515 name: name.to_string(),
2516 tools,
2517 }
2518 } else {
2519 Msg::McpServerErrored {
2520 name: name.to_string(),
2521 reason: "server failed to start or initialize".to_string(),
2522 }
2523 }
2524}
2525
2526async fn dispatch_read_clipboard(tx: MsgSender) {
2539 use crate::domain::Paste;
2540
2541 enum Outcome {
2542 Image { bytes: Vec<u8>, format: String },
2543 Text(String),
2544 Empty,
2545 Error(String),
2546 }
2547
2548 let outcome = tokio::task::spawn_blocking(|| {
2549 if crate::clipboard::has_image() {
2550 match crate::clipboard::read_image_bytes() {
2551 Ok((bytes, format)) => Outcome::Image { bytes, format },
2552 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2553 }
2554 } else {
2555 match crate::clipboard::read_text() {
2556 Ok(t) if !t.is_empty() => Outcome::Text(t),
2557 Ok(_) => Outcome::Empty,
2558 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2559 }
2560 }
2561 })
2562 .await
2563 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2564
2565 let msg = match outcome {
2566 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2567 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2568 Outcome::Empty => Msg::TransientStatus {
2569 text: "Clipboard is empty".to_string(),
2570 },
2571 Outcome::Error(text) => Msg::TransientStatus { text },
2572 };
2573 let _ = tx.send(msg).await;
2574}
2575
2576async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2579 let char_count = text.chars().count();
2580 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2581 .await
2582 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2583
2584 let msg = match result {
2585 Ok(()) => Msg::TransientStatus {
2586 text: format!("Copied {char_count} chars to clipboard"),
2587 },
2588 Err(e) => Msg::TransientStatus {
2589 text: format!("Copy failed: {e}"),
2590 },
2591 };
2592 let _ = tx.send(msg).await;
2593}
2594
2595fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2596 use crate::models::{ErrorCategory, ModelError, UserFacingError};
2597 match e {
2598 ModelError::Backend(b) => UserFacingError {
2599 summary: "Backend error".to_string(),
2600 message: b.to_string(),
2601 suggestion: "Check the provider endpoint / API key.".to_string(),
2602 category: ErrorCategory::Connection,
2603 recoverable: true,
2604 },
2605 ModelError::Authentication(msg) => UserFacingError {
2606 summary: "Auth error".to_string(),
2607 message: msg.clone(),
2608 suggestion: "Set the env var the provider expects.".to_string(),
2609 category: ErrorCategory::Auth,
2610 recoverable: false,
2611 },
2612 ModelError::RateLimit { retry_after } => UserFacingError {
2613 summary: "Rate limit".to_string(),
2614 message: format!("retry after {:?}", retry_after),
2615 suggestion: "Wait and try again.".to_string(),
2616 category: ErrorCategory::Temporary,
2617 recoverable: true,
2618 },
2619 ModelError::StreamError(msg) => UserFacingError {
2620 summary: "Stream error".to_string(),
2621 message: msg.clone(),
2622 suggestion: "Retry the request.".to_string(),
2623 category: ErrorCategory::Connection,
2624 recoverable: true,
2625 },
2626 other => UserFacingError {
2627 summary: "Model error".to_string(),
2628 message: other.to_string(),
2629 suggestion: String::new(),
2630 category: ErrorCategory::Internal,
2631 recoverable: false,
2632 },
2633 }
2634}
2635
2636#[cfg(test)]
2637mod tests {
2638 use super::*;
2639 use crate::domain::ToolCallId;
2640 use std::time::Duration;
2641
2642 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2643 EffectRunner::pair(PathBuf::from("/tmp"))
2644 }
2645
2646 #[test]
2647 fn new_child_suppresses_terminal_title() {
2648 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2652 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2653 let tools = Arc::new(ToolRegistry::new());
2654 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2655 assert!(
2656 !child.terminal_title_enabled,
2657 "subagent child runner must suppress terminal-title escapes"
2658 );
2659 }
2660
2661 #[test]
2662 fn parse_prune_plan_extracts_json_amid_prose() {
2663 let plan = parse_prune_plan(
2664 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2665 )
2666 .expect("should parse");
2667 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2668 assert_eq!(plan.reason, "dupes");
2669 }
2670
2671 #[test]
2672 fn parse_prune_plan_handles_empty_and_garbage() {
2673 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2674 .expect("empty plan parses");
2675 assert!(empty.prune.is_empty());
2676 assert!(parse_prune_plan("no json here").is_none());
2677 }
2678
2679 #[test]
2680 fn memory_title_from_text_is_short_and_nonempty() {
2681 assert_eq!(
2682 memory_title_from_text("prefer ripgrep over grep"),
2683 "prefer ripgrep over grep"
2684 );
2685 assert_eq!(memory_title_from_text(" "), "memory");
2686 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2687 assert!(long.split_whitespace().count() <= 8);
2688 }
2689
2690 #[tokio::test]
2691 async fn dispatch_exit_is_noop_on_runner_state() {
2692 let (mut r, _rx) = runner();
2693 r.dispatch(Cmd::Exit);
2694 assert_eq!(r.scope_count(), 0);
2695 }
2696
2697 #[tokio::test]
2698 async fn dispatch_save_emits_session_saved() {
2699 let (mut r, mut rx) = runner();
2700 r.dispatch(Cmd::SaveConversation(
2701 crate::session::ConversationHistory::new(
2702 "/p".to_string(),
2703 "m".to_string(),
2704 chrono::Local::now(),
2705 ),
2706 ));
2707 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2708 .await
2709 .expect("sender emits")
2710 .expect("channel alive");
2711 assert!(matches!(msg, Msg::SessionSaved));
2712 }
2713
2714 #[test]
2715 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2716 let msg = mcp_startup_msg("empty", true, Vec::new());
2717 assert!(matches!(
2718 msg,
2719 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2720 ));
2721 }
2722
2723 #[test]
2724 fn mcp_startup_msg_reports_unstarted_server_as_error() {
2725 let msg = mcp_startup_msg("bad", false, Vec::new());
2726 assert!(matches!(
2727 msg,
2728 Msg::McpServerErrored { name, reason }
2729 if name == "bad" && reason.contains("failed to start")
2730 ));
2731 }
2732
2733 #[tokio::test]
2734 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2735 let (mut r, mut rx) = runner();
2736 let turn = TurnId(77);
2737 {
2738 let scope = r.scope_mut(turn);
2739 scope.spawn(async {
2740 std::future::pending::<()>().await;
2741 });
2742 }
2743 assert_eq!(r.scope_count(), 1);
2744
2745 let start = std::time::Instant::now();
2746 r.dispatch(Cmd::CancelScope(turn));
2747 assert_eq!(r.scope_count(), 0);
2748 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2749 .await
2750 .expect("bounded cancel should emit terminal message")
2751 .expect("channel alive");
2752 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2753 assert!(
2754 start.elapsed() < Duration::from_millis(500),
2755 "cancel terminal message took {:?}",
2756 start.elapsed()
2757 );
2758 }
2759
2760 #[tokio::test]
2761 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2762 let (mut r, mut rx) = runner();
2768 let turn = TurnId(88);
2769 {
2770 let scope = r.scope_mut(turn);
2771 scope.spawn(async {}); }
2773 assert_eq!(r.scope_count(), 1);
2774
2775 tokio::time::sleep(Duration::from_millis(20)).await;
2777 r.dispatch(Cmd::Exit);
2778 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2779
2780 r.dispatch(Cmd::CancelScope(turn));
2783 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2784 .await
2785 .expect("cancel on a reaped scope must still emit a terminal message")
2786 .expect("channel alive");
2787 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2788 }
2789
2790 #[tokio::test]
2791 async fn dispatch_call_model_creates_scope() {
2792 let (mut r, _rx) = runner();
2793 let turn = TurnId(7);
2794 let request = crate::domain::ChatRequest {
2795 model_id: "test/m".to_string(),
2796 messages: vec![],
2797 system_prompt: String::new(),
2798 instructions: None,
2799 reasoning: crate::models::ReasoningLevel::Medium,
2800 temperature: 0.7,
2801 max_tokens: 4096,
2802 tools: vec![],
2803
2804 ollama_num_ctx: None,
2805 ollama_allow_ram_offload: None,
2806 };
2807 r.dispatch(Cmd::CallModel { turn, request });
2808 assert_eq!(r.scope_count(), 1);
2809 }
2810
2811 #[tokio::test]
2815 async fn empty_scopes_are_reaped_on_next_dispatch() {
2816 let (mut r, mut rx) = runner();
2817 let turn = TurnId(42);
2818 let request = crate::domain::ChatRequest {
2819 model_id: "test/m".to_string(),
2820 messages: vec![],
2821 system_prompt: String::new(),
2822 instructions: None,
2823 reasoning: crate::models::ReasoningLevel::Medium,
2824 temperature: 0.7,
2825 max_tokens: 4096,
2826 tools: vec![],
2827
2828 ollama_num_ctx: None,
2829 ollama_allow_ram_offload: None,
2830 };
2831 r.dispatch(Cmd::CallModel { turn, request });
2832 assert_eq!(r.scope_count(), 1);
2833
2834 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2839 .await
2840 .expect("upstream error arrived")
2841 .expect("channel alive");
2842 assert!(matches!(msg, Msg::UpstreamError { .. }));
2843
2844 tokio::task::yield_now().await;
2846
2847 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2849 assert_eq!(
2850 r.scope_count(),
2851 0,
2852 "completed scope must be reaped on next dispatch"
2853 );
2854 }
2855
2856 #[tokio::test]
2857 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2858 let (mut r, mut rx) = runner();
2859 let turn = TurnId(7);
2860 let call_id = ToolCallId(1);
2861 let source = crate::models::tool_call::ToolCall {
2862 id: Some("c1".to_string()),
2863 function: crate::models::tool_call::FunctionCall {
2864 name: "read_file".to_string(),
2865 arguments: serde_json::json!({"path": "x"}),
2866 },
2867 };
2868 r.dispatch(Cmd::ExecuteTool {
2869 turn,
2870 call_id,
2871 source,
2872 model_id: "ollama/test".to_string(),
2873 safety_mode: crate::runtime::SafetyMode::Ask,
2874 intent: None,
2875 });
2876 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2877 .await
2878 .expect("some msg")
2879 .expect("channel alive");
2880 assert!(matches!(
2881 first,
2882 Msg::ToolStarted {
2883 turn: t,
2884 call_id: c,
2885 } if t == turn && c == call_id
2886 ));
2887 }
2888
2889 #[tokio::test]
2890 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2891 let (mut r, _rx) = runner();
2892 let turn = TurnId(9);
2893 r.dispatch(Cmd::CallModel {
2894 turn,
2895 request: crate::domain::ChatRequest {
2896 model_id: "m".to_string(),
2897 messages: vec![],
2898 system_prompt: String::new(),
2899 instructions: None,
2900 reasoning: crate::models::ReasoningLevel::Medium,
2901 temperature: 0.7,
2902 max_tokens: 4096,
2903 tools: vec![],
2904
2905 ollama_num_ctx: None,
2906 ollama_allow_ram_offload: None,
2907 },
2908 });
2909 assert_eq!(r.scope_count(), 1);
2910
2911 r.dispatch(Cmd::CancelScope(turn));
2912 assert_eq!(r.scope_count(), 0);
2913 }
2914
2915 #[tokio::test]
2916 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2917 let (mut r, _rx) = runner();
2923 let req = || crate::domain::ChatRequest {
2924 model_id: "test/m".to_string(),
2925 messages: vec![],
2926 system_prompt: String::new(),
2927 instructions: None,
2928 reasoning: crate::models::ReasoningLevel::Medium,
2929 temperature: 0.7,
2930 max_tokens: 4096,
2931 tools: vec![],
2932 ollama_num_ctx: None,
2933 ollama_allow_ram_offload: None,
2934 };
2935 let turn = TurnId(123);
2936
2937 r.dispatch(Cmd::CallModel {
2938 turn,
2939 request: req(),
2940 });
2941 assert_eq!(r.scope_count(), 1);
2942
2943 r.dispatch(Cmd::CancelScope(turn));
2945 assert_eq!(r.scope_count(), 0);
2946
2947 r.dispatch(Cmd::CallModel {
2949 turn,
2950 request: req(),
2951 });
2952 assert_eq!(
2953 r.scope_count(),
2954 0,
2955 "a cancelled turn must not be resurrected by a late scoped Cmd"
2956 );
2957
2958 r.dispatch(Cmd::CallModel {
2960 turn: TurnId(124),
2961 request: req(),
2962 });
2963 assert_eq!(
2964 r.scope_count(),
2965 1,
2966 "a fresh turn must still create its scope normally"
2967 );
2968 }
2969
2970 #[tokio::test]
2971 async fn shutdown_drains_pending_saves() {
2972 let (mut r, _rx) = runner();
2973 for _ in 0..5 {
2974 r.dispatch(Cmd::SaveConversation(
2975 crate::session::ConversationHistory::new(
2976 "/p".to_string(),
2977 "m".to_string(),
2978 chrono::Local::now(),
2979 ),
2980 ));
2981 }
2982 let start = std::time::Instant::now();
2984 r.shutdown().await;
2985 assert!(start.elapsed() < Duration::from_secs(2));
2986 }
2987}