1mod config_watch;
35mod turn_scope;
36
37use std::collections::HashMap;
38use std::collections::VecDeque;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::sync::Mutex;
42use std::time::Instant;
43
44use tokio::sync::mpsc;
45
46use crate::app::{Config, MemoryConfig};
47use crate::domain::{Cmd, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId};
48use crate::models::{ModelError, TokenUsage};
49use crate::providers::ctx::{ExecContext, StreamContext};
50use crate::providers::model::ModelProvider;
51use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
52use crate::utils::{join_logged, spawn_guarded};
53
54pub use crate::models::retry::{
58 DEFAULT_MAX_ATTEMPTS, retry_transient_http, retry_transient_http_no_connect_retry,
59};
60pub use turn_scope::TurnScope;
61
62#[cfg(not(test))]
63const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
64#[cfg(test)]
65const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
66
67const CANCELLED_TOMBSTONE_CAP: usize = 256;
73
74pub type MsgSender = mpsc::Sender<Msg>;
80
81pub const MSG_CHANNEL_CAPACITY: usize = 512;
87
88#[derive(Clone)]
89enum PersistenceJob {
90 Conversation(Box<crate::session::ConversationHistory>),
91 Compaction(Box<PendingCompactionSave>),
92}
93
94#[derive(Clone)]
95struct PendingCompactionSave {
96 archive: crate::domain::CompactionArchive,
97 record: crate::domain::CompactionRecord,
98 conversation: crate::session::ConversationHistory,
99 task_id: Option<String>,
100}
101
102struct PersistedCompaction {
103 id: String,
104 task_id: Option<String>,
105 session_id: String,
106 archive_path: PathBuf,
107}
108
109struct PersistenceState {
110 workdir: PathBuf,
111 manager: Option<crate::session::ConversationManager>,
112 blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
113}
114
115impl PersistenceState {
116 fn new(workdir: PathBuf) -> Self {
117 Self {
118 workdir,
119 manager: None,
120 blocked: HashMap::new(),
121 }
122 }
123
124 fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
125 if self.manager.is_none() {
126 self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
127 }
128 Ok(self.manager.as_ref().expect("manager initialized"))
129 }
130
131 fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
136 match job {
137 PersistenceJob::Conversation(history) => {
138 let (persisted, retried) = self.retry_blocked(&history.id);
141 if retried.is_err() {
142 return (persisted, retried);
143 }
144 let saved = self
145 .manager()
146 .and_then(|manager| manager.save_conversation(&history).map(|_| ()));
147 (persisted, saved)
148 },
149 PersistenceJob::Compaction(save) => {
150 let conversation_id = save.archive.conversation_id.clone();
155 self.blocked
156 .entry(conversation_id.clone())
157 .or_default()
158 .push_back(*save);
159 self.retry_blocked(&conversation_id)
160 },
161 }
162 }
163
164 fn retry_blocked(
165 &mut self,
166 conversation_id: &str,
167 ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
168 let mut persisted = Vec::new();
169 if !self.blocked.contains_key(conversation_id) {
170 return (persisted, Ok(()));
171 }
172 if let Err(error) = self.manager() {
173 return (persisted, Err(error));
174 }
175 let manager = self.manager.as_ref().expect("manager initialized");
179 let queue = self
180 .blocked
181 .get_mut(conversation_id)
182 .expect("checked above");
183 while let Some(save) = queue.front() {
184 match Self::persist_compaction(manager, save) {
185 Ok(event) => {
189 persisted.push(event);
190 queue.pop_front();
191 },
192 Err(error) => return (persisted, Err(error)),
193 }
194 }
195 self.blocked.remove(conversation_id);
196 (persisted, Ok(()))
197 }
198
199 fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
200 let ids: Vec<String> = self.blocked.keys().cloned().collect();
201 let mut persisted = Vec::new();
202 let mut first_error = None;
203 for id in ids {
204 let (events, result) = self.retry_blocked(&id);
207 persisted.extend(events);
208 if let Err(error) = result {
209 first_error.get_or_insert(error);
210 }
211 }
212 match first_error {
213 None => (persisted, Ok(())),
214 Some(error) => (persisted, Err(error)),
215 }
216 }
217
218 fn persist_compaction(
219 manager: &crate::session::ConversationManager,
220 save: &PendingCompactionSave,
221 ) -> anyhow::Result<PersistedCompaction> {
222 let path = manager.save_compaction_archive(&save.archive)?;
223 manager.save_conversation(&save.conversation)?;
224
225 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
226 let _ = store.compactions().create(crate::runtime::NewCompaction {
227 id: Some(save.record.id.clone()),
228 task_id: save.task_id.clone(),
229 session_id: Some(save.archive.conversation_id.clone()),
230 source_token_estimate: Some(save.record.before_tokens as i64),
231 summary_token_count: Some(save.record.summary_tokens as i64),
232 preserved_turns: Some(save.record.preserved_turn_count as i64),
233 archive_path: Some(path.display().to_string()),
234 verification_status: Some(save.record.review_status.as_str().to_string()),
235 });
236 }
237
238 Ok(PersistedCompaction {
239 id: save.record.id.clone(),
240 task_id: save.task_id.clone(),
241 session_id: save.archive.conversation_id.clone(),
242 archive_path: path,
243 })
244 }
245}
246
247async fn fire_compaction_hook(event: &PersistedCompaction) {
249 fire_plugin_hooks(
250 "compaction",
251 serde_json::json!({
252 "id": event.id,
253 "task_id": event.task_id,
254 "session_id": event.session_id,
255 "archive_path": event.archive_path.display().to_string(),
256 }),
257 )
258 .await;
259}
260
261pub struct EffectRunner {
264 msg_tx: MsgSender,
265 scopes: HashMap<TurnId, TurnScope>,
271 cancelled_turns: VecDeque<TurnId>,
277 detached: tokio::task::JoinSet<()>,
281 persistence_state: Arc<Mutex<PersistenceState>>,
285 persistence_tail: Option<tokio::task::JoinHandle<()>>,
286 workdir: PathBuf,
290 providers: Option<Arc<ProviderFactory>>,
295 tools: Option<Arc<ToolRegistry>>,
298 task_id: Option<String>,
300 terminal_title_enabled: bool,
304 owns_global_mcp: bool,
310 approval: Option<crate::providers::ApprovalBroker>,
314 questions: Option<crate::providers::QuestionBroker>,
318 tasks: crate::providers::TaskBroker,
323 config_watch: Option<tokio::task::AbortHandle>,
327}
328
329impl EffectRunner {
330 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
332 let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
333 Self {
334 tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
335 msg_tx,
336 scopes: HashMap::new(),
337 cancelled_turns: VecDeque::new(),
338 detached: tokio::task::JoinSet::new(),
339 persistence_state,
340 persistence_tail: None,
341 workdir,
342 providers: None,
343 tools: None,
344 task_id: None,
345 terminal_title_enabled: true,
346 owns_global_mcp: true,
347 approval: None,
348 questions: None,
349 config_watch: None,
350 }
351 }
352
353 pub fn with_interactive_approvals(mut self) -> Self {
357 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
358 self
359 }
360
361 pub fn with_interactive_questions(mut self) -> Self {
365 self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
366 self
367 }
368
369 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
375 let handle = self.detached.spawn(config_watch::config_watcher(
376 self.msg_tx.clone(),
377 cwd,
378 memory,
379 ));
380 self.config_watch = Some(handle);
381 }
382
383 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
386 self.task_id = task_id;
387 self
388 }
389
390 pub fn without_terminal_title(mut self) -> Self {
392 self.terminal_title_enabled = false;
393 self
394 }
395
396 pub fn without_global_mcp_shutdown(mut self) -> Self {
399 self.owns_global_mcp = false;
400 self
401 }
402
403 pub fn with_bindings(
408 mut self,
409 providers: Arc<ProviderFactory>,
410 tools: Arc<ToolRegistry>,
411 ) -> Self {
412 self.providers = Some(providers);
413 self.tools = Some(tools);
414 self
415 }
416
417 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
421 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
422 (Self::new(tx, workdir), rx)
423 }
424
425 pub fn pair_with_bindings(
428 workdir: PathBuf,
429 config: Config,
430 tools: Arc<ToolRegistry>,
431 ) -> (Self, mpsc::Receiver<Msg>) {
432 let providers = Arc::new(ProviderFactory::new(config));
433 Self::pair_from(workdir, providers, tools)
434 }
435
436 pub fn pair_from(
441 workdir: PathBuf,
442 providers: Arc<ProviderFactory>,
443 tools: Arc<ToolRegistry>,
444 ) -> (Self, mpsc::Receiver<Msg>) {
445 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
446 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
447 }
448
449 pub fn pair_from_with_task(
450 workdir: PathBuf,
451 providers: Arc<ProviderFactory>,
452 tools: Arc<ToolRegistry>,
453 task_id: Option<String>,
454 ) -> (Self, mpsc::Receiver<Msg>) {
455 let (runner, rx) = Self::pair_from(workdir, providers, tools);
456 (runner.with_task_id(task_id), rx)
457 }
458
459 pub fn new_child(
464 msg_tx: MsgSender,
465 workdir: PathBuf,
466 providers: Arc<ProviderFactory>,
467 tools: Arc<ToolRegistry>,
468 ) -> Self {
469 Self::new(msg_tx, workdir)
477 .with_bindings(providers, tools)
478 .without_terminal_title()
479 .without_global_mcp_shutdown()
480 }
481
482 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
486 self.scopes
487 .entry(turn)
488 .or_insert_with(|| TurnScope::new(turn))
489 }
490
491 fn tombstone_turn(&mut self, turn: TurnId) {
495 if self.cancelled_turns.contains(&turn) {
496 return;
497 }
498 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
499 self.cancelled_turns.pop_front();
500 }
501 self.cancelled_turns.push_back(turn);
502 }
503
504 fn is_tombstoned(&self, turn: TurnId) -> bool {
507 self.cancelled_turns.contains(&turn)
508 }
509
510 fn drop_scope(&mut self, turn: TurnId) {
520 self.tombstone_turn(turn);
525 if let Some(mut scope) = self.scopes.remove(&turn) {
526 scope.cancel();
527 let tx = self.msg_tx.clone();
528 self.detached.spawn(async move {
529 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
530 .await
531 .is_err()
532 {
533 tracing::warn!(
534 turn = %turn,
535 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
536 "cancel drain timed out; aborting remaining scoped tasks"
537 );
538 }
539 let _ = tx.send(Msg::TurnCancelled(turn)).await;
540 });
541 } else {
542 let tx = self.msg_tx.clone();
549 self.detached.spawn(async move {
550 let _ = tx.send(Msg::TurnCancelled(turn)).await;
551 });
552 }
553 }
554
555 pub fn scope_count(&self) -> usize {
558 self.scopes.len()
559 }
560
561 fn reap_empty_scopes(&mut self) {
570 self.reap_detached();
571 self.scopes.retain(|_, scope| {
572 scope.drain_completed();
573 !scope.is_empty()
574 });
575 }
576
577 fn reap_detached(&mut self) {
582 while let Some(result) = self.detached.try_join_next() {
583 if let Err(e) = result
584 && !e.is_cancelled()
585 {
586 tracing::warn!(error = %e, "effect: detached task panicked");
587 }
588 }
589 }
590
591 pub fn dispatch(&mut self, cmd: Cmd) {
595 self.reap_empty_scopes();
598 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
599
600 if let Some(turn) = cmd.scope_turn()
608 && self.is_tombstoned(turn)
609 {
610 tracing::debug!(
611 cmd = %cmd.summary(),
612 turn = %turn,
613 "effect: dropping turn-scoped cmd for an already-cancelled turn"
614 );
615 return;
616 }
617
618 match cmd {
619 Cmd::CallModel { turn, mut request } => {
620 let tx = self.msg_tx.clone();
621 let providers = self.providers.clone();
622 if let Some(tools) = &self.tools
631 && request.output_schema.is_none()
632 {
633 let mut enriched =
634 filter_suppressed(tools.describe_all(), &request.suppressed_builtin_tools);
635 let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
640 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
646 tracing::debug!(
647 error = %e,
648 "effect: dropped builtin tool-schema token estimate (channel full); \
649 /context preview may be briefly stale"
650 );
651 }
652 enriched.append(&mut request.tools);
653 request.tools = enriched;
654 }
655 self.detached.spawn(fire_plugin_hooks(
658 "prompt_submit",
659 serde_json::json!({
660 "turn_id": turn.0,
661 "model_id": request.model_id.clone(),
662 "message_count": request.messages.len(),
663 "tool_count": request.tools.len(),
664 }),
665 ));
666 let task_usage = self.tasks.clone();
669 let scope = self.scope_mut(turn);
670 let token = scope.token();
671 scope.spawn(async move {
672 use futures::FutureExt;
673 let fallback_tx = tx.clone();
674 if std::panic::AssertUnwindSafe(dispatch_call_model(
675 tx, providers, turn, request, token, task_usage,
676 ))
677 .catch_unwind()
678 .await
679 .is_err()
680 {
681 tracing::error!(turn = %turn, "dispatch_call_model panicked");
686 let _ = fallback_tx
687 .send(Msg::UpstreamError {
688 turn,
689 error: crate::models::UserFacingError {
690 summary: "Internal error".to_string(),
691 message: "The model dispatch task panicked unexpectedly."
692 .to_string(),
693 suggestion: "This is a bug. Please retry; if it persists, \
694 check the logs."
695 .to_string(),
696 category: crate::models::ErrorCategory::Internal,
697 recoverable: true,
698 },
699 })
700 .await;
701 }
702 });
703 },
704 Cmd::CompactConversation { turn, mut request } => {
705 let tx = self.msg_tx.clone();
706 let providers = self.providers.clone();
707 if let Some(tools) = &self.tools {
708 let mut enriched = tools.describe_all();
709 enriched.append(&mut request.chat.tools);
710 request.chat.tools = enriched;
711 }
712 let trigger = request.trigger;
715 let scope = self.scope_mut(turn);
716 let token = scope.token();
717 scope.spawn(async move {
718 use futures::FutureExt;
719 let fallback_tx = tx.clone();
720 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
721 tx, providers, turn, request, token,
722 ))
723 .catch_unwind()
724 .await
725 .is_err()
726 {
727 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
733 let _ = fallback_tx
734 .send(Msg::CompactionFailed {
735 turn,
736 trigger,
737 message: "the compaction task panicked unexpectedly".to_string(),
738 kind: crate::domain::StatusKind::Error,
739 })
740 .await;
741 }
742 });
743 },
744 Cmd::ExecuteTool {
745 turn,
746 call_id,
747 source,
748 model_id,
749 safety_mode,
750 plan_file,
751 plan_permissions,
752 context_percent,
753 intent,
754 session_id,
755 message_index,
756 scratchpad,
757 } => {
758 let tx = self.msg_tx.clone();
759 let tools = self.tools.clone();
760 let workdir = self.workdir.clone();
761 let config = self
766 .providers
767 .as_ref()
768 .map(|p| Arc::new(p.config().clone()))
769 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
770 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
778 if safety_mode == crate::runtime::SafetyMode::Auto || plan_file.is_some() {
779 self.providers.as_ref().map(|p| {
780 let model = config
781 .safety
782 .auto_classifier_model
783 .clone()
784 .unwrap_or_else(|| model_id.clone());
785 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
786 as Arc<dyn crate::providers::AutoClassifier>
787 })
788 } else {
789 None
790 };
791 let task_id = self.task_id.clone();
792 let approval = self.approval.clone();
793 let questions = self.questions.clone();
794 let task_broker = self.tasks.clone();
795 let scope = self.scope_mut(turn);
796 let token = scope.token();
797 let background = scope.background_token();
798 let web_bytes = scope.web_bytes();
799 scope.spawn(async move {
800 use futures::FutureExt;
801 let fallback_tx = tx.clone();
802 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
803 tx,
804 tools,
805 workdir,
806 turn,
807 call_id,
808 source,
809 token,
810 background,
811 web_bytes,
812 config,
813 model_id,
814 task_id,
815 session_id,
816 message_index,
817 scratchpad,
818 safety_mode,
819 plan_file,
820 plan_permissions,
821 context_percent,
822 intent,
823 classifier,
824 approval,
825 questions,
826 task_broker,
827 ))
828 .catch_unwind()
829 .await
830 .is_err()
831 {
832 tracing::error!(
837 turn = %turn,
838 call_id = call_id.0,
839 "dispatch_execute_tool panicked"
840 );
841 let _ = fallback_tx
842 .send(Msg::ToolFinished {
843 turn,
844 call_id,
845 outcome: crate::domain::ToolOutcome::error(
846 "internal error: the tool execution task panicked".to_string(),
847 0.0,
848 ),
849 })
850 .await;
851 }
852 });
853 },
854 Cmd::ResolveApproval { call_id, decision } => {
855 if let Some(broker) = &self.approval {
858 broker.resolve(call_id, decision.into());
859 }
860 },
861 Cmd::ResolveQuestion {
862 call_id,
863 resolution,
864 } => {
865 if let Some(broker) = &self.questions {
868 broker.resolve(call_id, resolution);
869 }
870 },
871 Cmd::SyncTaskStore(store) => {
872 self.tasks.seed(store);
876 },
877 Cmd::EnsureScratchpad { session_id } => {
878 let tx = self.msg_tx.clone();
879 let workdir = self.workdir.clone();
880 self.detached.spawn(async move {
881 match crate::session::scratchpad::ensure(&workdir, &session_id) {
882 Ok(path) => {
883 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
884 },
885 Err(err) => {
886 tracing::warn!(error = %err, "failed to create session scratchpad");
889 },
890 }
891 if let Err(err) = crate::session::scratchpad::sweep_stale(
894 crate::session::scratchpad::RETENTION_DAYS,
895 ) {
896 tracing::warn!(error = %err, "scratchpad sweep failed");
897 }
898 });
899 },
900 Cmd::ListScratchpad { path } => {
901 let tx = self.msg_tx.clone();
904 self.detached.spawn(async move {
905 let text = tokio::task::spawn_blocking(move || {
906 crate::session::scratchpad::list_text(&path)
907 })
908 .await
909 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
910 let _ = tx.send(Msg::RuntimeText(text)).await;
911 });
912 },
913 Cmd::UserTaskEdit(edit) => {
914 let broker = self.tasks.clone();
919 let tx = self.msg_tx.clone();
920 self.detached.spawn(async move {
921 let (line, _snapshot) = broker.user_edit(edit).await;
922 let _ = tx
927 .send(Msg::TaskNotice {
928 text: format!(
929 "The user edited the task checklist: {line}. Acknowledge and \
930 incorporate this into your plan."
931 ),
932 })
933 .await;
934 let _ = tx.send(Msg::TransientStatus { text: line }).await;
935 });
936 },
937 Cmd::NotifyTaskCompleted {
938 task,
939 completed,
940 total,
941 } => {
942 let payload = serde_json::json!({
950 "task_id": task.id,
951 "subject": task.subject,
952 "description": task.description,
953 "evidence": task.evidence,
954 "completed": completed,
955 "total": total,
956 });
957 let broker = self.tasks.clone();
958 let tx = self.msg_tx.clone();
959 self.detached.spawn(async move {
960 let gate = run_plugin_hooks_gated("task_completed", payload).await;
961 let Some((plugin, reason)) = gate.deny else {
962 return;
963 };
964 let reason = crate::utils::redact_secrets(&reason);
965 let _ = broker
966 .update(vec![crate::domain::TaskEdit {
967 id: task.id,
968 status: Some(crate::domain::TaskStatus::InProgress),
969 ..crate::domain::TaskEdit::default()
970 }])
971 .await;
972 let _ = tx
973 .send(Msg::TaskNotice {
974 text: format!(
975 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
976 {reason}. The task is back in_progress; address the reason \
977 before completing it again.",
978 task.id, task.subject
979 ),
980 })
981 .await;
982 let _ = tx
983 .send(Msg::TransientStatus {
984 text: format!(
985 "task #{} completion vetoed by {plugin}: {reason}",
986 task.id
987 ),
988 })
989 .await;
990 });
991 },
992 Cmd::CancelScope(turn) => {
993 self.drop_scope(turn);
994 },
995 Cmd::BackgroundScope(turn) => {
996 self.scope_mut(turn).background();
1000 },
1001 Cmd::SaveConversation(history) => {
1002 self.queue_persistence(PersistenceJob::Conversation(Box::new(history)));
1003 },
1004 Cmd::SaveCompactionArchive {
1005 archive,
1006 record,
1007 conversation,
1008 } => {
1009 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1010 PendingCompactionSave {
1011 archive,
1012 record,
1013 conversation,
1014 task_id: self.task_id.clone(),
1015 },
1016 )));
1017 },
1018 Cmd::SaveProcess(process) => {
1019 let task_id = self.task_id.clone();
1020 self.detached.spawn(async move {
1021 let status = match process.status {
1022 crate::domain::ManagedProcessStatus::Running => {
1023 crate::runtime::ProcessStatus::Running
1024 },
1025 crate::domain::ManagedProcessStatus::Exited => {
1026 crate::runtime::ProcessStatus::Exited
1027 },
1028 crate::domain::ManagedProcessStatus::Unknown => {
1029 crate::runtime::ProcessStatus::Unknown
1030 },
1031 };
1032 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1033 let _ = store.processes().upsert(crate::runtime::NewProcess {
1034 id: Some(process.id),
1035 task_id,
1036 pid: process.pid,
1037 command: process.command,
1038 cwd: process.cwd,
1039 log_path: Some(process.log_path),
1040 detected_url: process.detected_url,
1041 status,
1042 health: None,
1043 });
1044 }
1045 });
1046 },
1047 Cmd::PersistPlanConfig(plan) => {
1048 self.detached.spawn(async move {
1049 if let Err(err) = crate::app::persist_plan_config(&plan) {
1050 tracing::warn!(error = %err, "failed to persist [plan] config");
1051 }
1052 });
1053 },
1054 Cmd::PersistLastModel(model) => {
1055 self.detached.spawn(async move {
1056 if let Err(err) = crate::app::persist_last_model(&model) {
1057 tracing::warn!(error = %err, "failed to persist last-used model");
1058 }
1059 });
1060 },
1061 Cmd::PersistReasoningFor { model_id, level } => {
1062 self.detached.spawn(async move {
1063 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1064 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1065 }
1066 });
1067 },
1068 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1069 self.detached.spawn(async move {
1070 if let Err(err) =
1071 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1072 {
1073 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1074 }
1075 });
1076 },
1077 Cmd::PersistOllamaOffload(enabled) => {
1078 self.detached.spawn(async move {
1079 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1080 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1081 }
1082 });
1083 },
1084 Cmd::PersistUiTheme(theme) => {
1085 self.detached.spawn(async move {
1086 if let Err(err) = crate::app::persist_ui_theme(theme) {
1087 tracing::warn!(error = %err, "failed to persist theme");
1088 }
1089 });
1090 },
1091 Cmd::ComposeInEditor { .. } => {
1092 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1096 },
1097 Cmd::ListMemory => {
1098 let tx = self.msg_tx.clone();
1099 let workdir = self.workdir.clone();
1100 self.detached.spawn(async move {
1101 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1102 let text = match crate::app::memory::load(&workdir, &cfg) {
1103 Some(mem) => mem.index,
1104 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(),
1105 };
1106 let _ = tx.send(Msg::RuntimeText(text)).await;
1107 });
1108 },
1109 Cmd::RememberMemory { text } => {
1110 let tx = self.msg_tx.clone();
1111 let workdir = self.workdir.clone();
1112 self.detached.spawn(async move {
1113 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1114 let name = memory_title_from_text(&text);
1115 let status = match crate::app::memory::write_memory(
1116 &workdir,
1117 crate::app::memory::MemoryScope::ProjectPrivate,
1118 &name,
1119 &text,
1120 &[],
1121 &text,
1122 ) {
1123 Ok(_) => format!("Remembered: {name}"),
1124 Err(e) => format!("Couldn't save memory: {e}"),
1125 };
1126 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1127 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1128 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1129 });
1130 },
1131 Cmd::ForgetMemory { id } => {
1132 let tx = self.msg_tx.clone();
1133 let workdir = self.workdir.clone();
1134 self.detached.spawn(async move {
1135 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1136 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1137 Ok(Some(_)) => format!("Forgot: {id}"),
1138 Ok(None) => format!("No memory named '{id}'"),
1139 Err(e) => format!("Couldn't forget memory: {e}"),
1140 };
1141 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1142 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1143 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1144 });
1145 },
1146 Cmd::ConsolidateMemory { model_id } => {
1147 let tx = self.msg_tx.clone();
1148 let workdir = self.workdir.clone();
1149 let providers = self.providers.clone();
1150 self.detached.spawn(async move {
1151 consolidate_memory(tx, providers, workdir, model_id).await;
1152 });
1153 },
1154 Cmd::LoadConversation(id) => {
1155 let tx = self.msg_tx.clone();
1156 let workdir = self.workdir.clone();
1157 self.detached.spawn(async move {
1158 match crate::session::ConversationManager::new(&workdir) {
1159 Ok(mgr) => match mgr.load_conversation(&id) {
1160 Ok(history) => {
1161 let _ = tx.send(Msg::ConversationLoaded(history)).await;
1162 },
1163 Err(e) => {
1164 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
1165 },
1166 },
1167 Err(e) => {
1168 tracing::warn!(error = %e, "ConversationManager init failed");
1169 },
1170 }
1171 });
1172 },
1173 Cmd::ListConversations => {
1174 let tx = self.msg_tx.clone();
1175 let workdir = self.workdir.clone();
1176 self.detached.spawn(async move {
1177 let summaries = match crate::session::ConversationManager::new(&workdir) {
1178 Ok(mgr) => mgr
1179 .list_conversation_metas()
1180 .unwrap_or_default()
1181 .into_iter()
1182 .map(|m| crate::domain::ConversationSummary {
1183 id: m.id,
1184 title: m.title,
1185 message_count: m.message_count,
1186 updated_at: m.updated_at.to_rfc3339(),
1187 })
1188 .collect(),
1189 Err(_) => Vec::new(),
1190 };
1191 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
1192 });
1193 },
1194 Cmd::ListAvailableModels => {
1195 let tx = self.msg_tx.clone();
1196 let providers = self.providers.clone();
1197 self.detached.spawn(async move {
1198 let choices = discover_available_models(providers).await;
1199 let _ = tx.send(Msg::AvailableModelsListed(choices)).await;
1200 });
1201 },
1202 Cmd::ListProjectFiles => {
1203 let tx = self.msg_tx.clone();
1204 let workdir = self.workdir.clone();
1205 self.detached.spawn_blocking(move || {
1207 let files = walk_project_files(&workdir);
1208 let _ = tx.blocking_send(Msg::ProjectFilesListed(files));
1209 });
1210 },
1211 Cmd::ListRuntimeTasks { limit } => {
1212 let tx = self.msg_tx.clone();
1213 self.detached.spawn_blocking(move || {
1216 let tasks = crate::runtime::RuntimeClient::auto()
1217 .list_tasks(limit)
1218 .map(|read| read.value)
1219 .unwrap_or_default();
1220 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
1221 });
1222 },
1223 Cmd::LoadRuntimeTask { id } => {
1224 let tx = self.msg_tx.clone();
1225 self.detached.spawn_blocking(move || {
1226 let (task, events) = crate::runtime::RuntimeClient::auto()
1227 .task_detail(&id)
1228 .map(|read| (Some(read.value.task), read.value.events))
1229 .unwrap_or((None, Vec::new()));
1230 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
1231 });
1232 },
1233 Cmd::ListRuntimeProcesses { limit } => {
1234 let tx = self.msg_tx.clone();
1235 self.detached.spawn_blocking(move || {
1236 let processes = crate::runtime::RuntimeClient::auto()
1237 .list_processes(limit)
1238 .map(|read| read.value)
1239 .unwrap_or_default();
1240 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
1241 });
1242 },
1243 Cmd::ShowRuntimeProcessLogs { id } => {
1244 let tx = self.msg_tx.clone();
1245 self.detached.spawn_blocking(move || {
1246 let text = crate::runtime::RuntimeClient::auto()
1247 .process_log(&id, None)
1248 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1249 .unwrap_or_else(|err| format!("Process log error: {}", err));
1250 let _ = tx.blocking_send(Msg::RuntimeText(text));
1251 });
1252 },
1253 Cmd::StopRuntimeProcess { id } => {
1254 let tx = self.msg_tx.clone();
1255 self.detached.spawn_blocking(move || {
1256 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
1257 Ok(response) => Msg::TransientStatus {
1258 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1259 },
1260 Err(err) => Msg::TransientStatus {
1261 text: format!("Process stop failed: {}", err),
1262 },
1263 };
1264 let _ = tx.blocking_send(msg);
1265 });
1266 },
1267 Cmd::KillBackgroundAgent { agent_id } => {
1268 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1272 if let Some(spawner) = spawner {
1273 match agent_id {
1274 Some(id) => {
1275 if let crate::providers::tool::subagent::KillResult::Evicted(
1280 workspace,
1281 ) = spawner.kill_detached(&id)
1282 {
1283 tokio::spawn(workspace.discard());
1284 }
1285 },
1286 None => {
1287 spawner.kill_all_detached();
1288 },
1289 }
1290 }
1291 },
1292 Cmd::RestartRuntimeProcess { id } => {
1293 let tx = self.msg_tx.clone();
1294 self.detached.spawn_blocking(move || {
1295 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
1296 Ok(response) => Msg::TransientStatus {
1297 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1298 },
1299 Err(err) => Msg::TransientStatus {
1300 text: format!("Process restart failed: {}", err),
1301 },
1302 };
1303 let _ = tx.blocking_send(msg);
1304 });
1305 },
1306 Cmd::OpenRuntimeTarget { target } => {
1307 self.detached.spawn_blocking(move || {
1308 let resolved = crate::runtime::RuntimeService::open_default()
1309 .and_then(|service| service.resolve_open_target(&target))
1310 .unwrap_or(target);
1311 if let Err(err) = crate::runtime::validate_open_target(&resolved) {
1315 tracing::warn!(error = %err, "refusing to open runtime target");
1316 return;
1317 }
1318 crate::utils::open_file(resolved);
1319 });
1320 },
1321 Cmd::ShowRuntimePorts => {
1322 let tx = self.msg_tx.clone();
1323 self.detached.spawn_blocking(move || {
1324 let text = crate::runtime::RuntimeClient::auto()
1325 .ports()
1326 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1327 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
1328 let _ = tx.blocking_send(Msg::RuntimeText(text));
1329 });
1330 },
1331 Cmd::ListRuntimeApprovals => {
1332 let tx = self.msg_tx.clone();
1333 self.detached.spawn_blocking(move || {
1334 let approvals = crate::runtime::RuntimeClient::auto()
1335 .list_approvals()
1336 .map(|read| read.value)
1337 .unwrap_or_default();
1338 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
1339 });
1340 },
1341 Cmd::DecideRuntimeApproval { id, decision } => {
1342 let tx = self.msg_tx.clone();
1343 self.detached.spawn_blocking(move || {
1344 let result = if decision == "approved" {
1345 crate::runtime::RuntimeClient::auto().approve(&id)
1346 } else {
1347 crate::runtime::RuntimeClient::auto().deny(&id)
1348 };
1349 let msg = match result {
1350 Ok(result) => Msg::TransientStatus {
1351 text: if result.replayed {
1352 format!("Approval {} {}: {}", id, decision, result.summary)
1353 } else {
1354 format!("Approval {} {}", id, decision)
1355 },
1356 },
1357 Err(err) => Msg::TransientStatus {
1358 text: format!("Approval update failed: {}", err),
1359 },
1360 };
1361 let _ = tx.blocking_send(msg);
1362 });
1363 },
1364 Cmd::ListRuntimeCheckpoints { limit } => {
1365 let tx = self.msg_tx.clone();
1366 self.detached.spawn_blocking(move || {
1367 let checkpoints = crate::runtime::RuntimeClient::auto()
1368 .list_checkpoints(limit)
1369 .map(|read| read.value)
1370 .unwrap_or_default();
1371 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
1372 });
1373 },
1374 Cmd::ListForkCheckpoints {
1375 session_id,
1376 message_index,
1377 } => {
1378 let tx = self.msg_tx.clone();
1379 self.detached.spawn_blocking(move || {
1380 let checkpoints = crate::runtime::RuntimeStore::open_default()
1381 .and_then(|store| {
1382 store
1383 .checkpoints()
1384 .list_for_session(&session_id, message_index as i64)
1385 })
1386 .unwrap_or_default();
1387 let _ = tx.blocking_send(Msg::ForkCheckpointsFound(checkpoints));
1388 });
1389 },
1390 Cmd::ListRuntimePlugins => {
1391 let tx = self.msg_tx.clone();
1392 self.detached.spawn_blocking(move || {
1393 let plugins = crate::runtime::RuntimeClient::auto()
1394 .list_plugins()
1395 .map(|read| read.value)
1396 .unwrap_or_default();
1397 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1398 });
1399 },
1400 Cmd::UpdateRuntimeTaskStatus {
1401 id,
1402 status,
1403 final_report,
1404 } => {
1405 let tx = self.msg_tx.clone();
1406 self.detached.spawn_blocking(move || {
1407 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
1408 store
1409 .tasks()
1410 .update_status(&id, status, final_report.as_deref())
1411 }) {
1412 Ok(()) => Msg::TransientStatus {
1413 text: format!("Task {} -> {}", id, status),
1414 },
1415 Err(err) => Msg::TransientStatus {
1416 text: format!("Task update failed: {}", err),
1417 },
1418 };
1419 let _ = tx.blocking_send(msg);
1420 });
1421 },
1422 Cmd::CreateRuntimeCheckpoint { paths } => {
1423 let tx = self.msg_tx.clone();
1424 let workdir = self.workdir.clone();
1425 self.detached.spawn_blocking(move || {
1426 let pending_action = Some(serde_json::json!({
1427 "source": "tui",
1428 "command": "checkpoint",
1429 }));
1430 let msg =
1431 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1432 Ok(manifest) => Msg::TransientStatus {
1433 text: format!(
1434 "Checkpoint {} created for {} path(s)",
1435 manifest.id,
1436 manifest.files.len()
1437 ),
1438 },
1439 Err(err) => Msg::TransientStatus {
1440 text: format!("Checkpoint failed: {}", err),
1441 },
1442 };
1443 let _ = tx.blocking_send(msg);
1444 });
1445 },
1446 Cmd::RestoreRuntimeCheckpoint { id } => {
1447 let tx = self.msg_tx.clone();
1448 self.detached.spawn_blocking(move || {
1449 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1450 Ok(result) => Msg::TransientStatus {
1451 text: format!(
1452 "Restored checkpoint {} ({} file(s)){}",
1453 result.checkpoint.id,
1454 result.checkpoint.files.len(),
1455 if result.checkpoint.pending_action.is_some() {
1456 "; pending action available in checkpoint manifest"
1457 } else {
1458 ""
1459 }
1460 ),
1461 },
1462 Err(err) => Msg::TransientStatus {
1463 text: format!("Restore failed: {}", err),
1464 },
1465 };
1466 let _ = tx.blocking_send(msg);
1467 });
1468 },
1469 Cmd::ShowRuntimeModelInfo { model } => {
1470 let tx = self.msg_tx.clone();
1471 self.detached.spawn_blocking(move || {
1472 let text = runtime_model_info_text(&model);
1473 let _ = tx.blocking_send(Msg::RuntimeText(text));
1474 });
1475 },
1476 Cmd::InitMcpServers(configs) => {
1477 let tx = self.msg_tx.clone();
1478 self.detached
1479 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1480 },
1481 Cmd::StopMcpServer { name } => {
1482 let tx = self.msg_tx.clone();
1483 self.detached.spawn(async move {
1484 if let Some(mgr) = crate::mcp::manager_ref::get() {
1487 mgr.stop_server(&name).await;
1488 }
1489 let _ = tx.send(Msg::McpServerStopped { name }).await;
1490 });
1491 },
1492 Cmd::PullOllamaModel { model } => {
1493 let tx = self.msg_tx.clone();
1494 self.detached.spawn(async move {
1495 dispatch_pull_ollama_model(tx, model).await;
1496 });
1497 },
1498 Cmd::OpenInSystem(path) => {
1499 self.detached.spawn(async move {
1500 let _ = tokio::task::spawn_blocking(move || {
1501 crate::utils::open_file(&path);
1502 })
1503 .await;
1504 });
1505 },
1506 Cmd::WriteImageToTemp {
1507 path,
1508 bytes,
1509 format: _,
1510 } => {
1511 self.detached.spawn(async move {
1512 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1513 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1514 }
1515 });
1516 },
1517 Cmd::ReadClipboard => {
1518 let tx = self.msg_tx.clone();
1519 self.detached.spawn(async move {
1520 dispatch_read_clipboard(tx).await;
1521 });
1522 },
1523 Cmd::ProbeVision { model_id, warn } => {
1524 let tx = self.msg_tx.clone();
1525 let providers = self.providers.clone();
1526 self.detached.spawn(async move {
1527 dispatch_probe_vision(model_id, warn, providers, tx).await;
1528 });
1529 },
1530 Cmd::CopyToClipboard(text) => {
1531 let tx = self.msg_tx.clone();
1532 self.detached.spawn(async move {
1533 dispatch_copy_to_clipboard(text, tx).await;
1534 });
1535 },
1536 Cmd::Exit => {
1537 },
1542 Cmd::SetTerminalTitle(title) => {
1543 if !self.terminal_title_enabled {
1544 return;
1545 }
1546 self.detached.spawn_blocking(move || {
1552 use std::io::Write;
1553 let seq = format!("\x1b]2;{}\x07", title);
1554 let mut stdout = std::io::stdout();
1555 let _ = stdout.write_all(seq.as_bytes());
1556 let _ = stdout.flush();
1557 });
1558 },
1559 Cmd::AlertUser => {
1560 if !self.terminal_title_enabled {
1561 return;
1562 }
1563 self.detached.spawn_blocking(|| {
1566 use std::io::Write;
1567 let mut stdout = std::io::stdout();
1568 let _ = stdout.write_all(b"\x07");
1569 let _ = stdout.flush();
1570 });
1571 },
1572 }
1573 }
1574
1575 fn queue_persistence(&mut self, job: PersistenceJob) {
1576 let previous = self.persistence_tail.take();
1577 let state = Arc::clone(&self.persistence_state);
1578 let tx = self.msg_tx.clone();
1579 self.persistence_tail = Some(tokio::spawn(async move {
1580 if let Some(previous) = previous
1581 && let Err(error) = previous.await
1582 {
1583 tracing::warn!(error = %error, "previous persistence job panicked");
1584 }
1585
1586 let result = tokio::task::spawn_blocking(move || {
1587 state
1588 .lock()
1589 .unwrap_or_else(|error| error.into_inner())
1590 .process(job)
1591 })
1592 .await;
1593
1594 match result {
1595 Ok((events, outcome)) => {
1596 if outcome.is_ok() || !events.is_empty() {
1600 let _ = tx.send(Msg::SessionSaved).await;
1601 }
1602 for event in events {
1603 fire_compaction_hook(&event).await;
1604 }
1605 if let Err(error) = outcome {
1606 tracing::warn!(
1607 error = %error,
1608 "persistence job failed; compaction barriers remain queued"
1609 );
1610 }
1611 },
1612 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1613 }
1614 }));
1615 }
1616
1617 pub async fn shutdown(mut self) {
1621 for (id, scope) in self.scopes.iter() {
1622 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1623 scope.cancel();
1624 }
1625
1626 if let Some(handle) = self.config_watch.take() {
1629 handle.abort();
1630 }
1631
1632 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1634
1635 let owns_global_mcp = self.owns_global_mcp;
1636 let persistence_tail = self.persistence_tail.take();
1637 let persistence_state = Arc::clone(&self.persistence_state);
1638 let drain = async {
1639 if let Some(tail) = persistence_tail
1640 && let Err(error) = tail.await
1641 {
1642 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1643 }
1644 match tokio::task::spawn_blocking(move || {
1645 persistence_state
1646 .lock()
1647 .unwrap_or_else(|error| error.into_inner())
1648 .retry_all_blocked()
1649 })
1650 .await
1651 {
1652 Ok((events, outcome)) => {
1653 for event in events {
1656 fire_compaction_hook(&event).await;
1657 }
1658 if let Err(error) = outcome {
1659 tracing::warn!(
1660 error = %error,
1661 "shutdown: compaction persistence barrier retry failed"
1662 );
1663 }
1664 },
1665 Err(error) => tracing::warn!(
1666 error = %error,
1667 "shutdown: compaction persistence barrier panicked"
1668 ),
1669 }
1670 if owns_global_mcp {
1674 let _ = tokio::time::timeout(
1680 std::time::Duration::from_secs(2),
1681 crate::mcp::manager_ref::wait_ready(),
1682 )
1683 .await;
1684 if let Some(mgr) = crate::mcp::manager_ref::get() {
1691 mgr.shutdown().await;
1692 }
1693 crate::searxng::shutdown().await;
1697 }
1698 for (id, mut scope) in self.scopes.drain() {
1704 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1705 .await
1706 .is_err()
1707 {
1708 tracing::warn!(
1709 turn = %id,
1710 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1711 "shutdown: scope drain timed out; aborting its remaining tasks"
1712 );
1713 }
1714 }
1715 while let Some(result) = self.detached.join_next().await {
1716 if let Err(e) = result
1717 && !e.is_cancelled()
1718 {
1719 tracing::warn!(error = %e, "shutdown: detached task panic");
1720 }
1721 }
1722 };
1723
1724 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1725 }
1726}
1727
1728fn note_stream_usage(
1736 tasks: &crate::providers::TaskBroker,
1737 usage: &Option<crate::models::TokenUsage>,
1738) {
1739 if let Some(usage) = usage {
1740 tasks.add_tokens(usage.completion_tokens as u64);
1741 }
1742}
1743
1744fn filter_suppressed(
1748 tools: Vec<crate::domain::ToolDefinition>,
1749 suppressed: &[&'static str],
1750) -> Vec<crate::domain::ToolDefinition> {
1751 if suppressed.is_empty() {
1752 return tools;
1753 }
1754 tools
1755 .into_iter()
1756 .filter(|t| !suppressed.contains(&t.name.as_str()))
1757 .collect()
1758}
1759
1760async fn dispatch_call_model(
1761 msg_tx: MsgSender,
1762 providers: Option<Arc<ProviderFactory>>,
1763 turn: TurnId,
1764 mut request: crate::domain::ChatRequest,
1765 token: tokio_util::sync::CancellationToken,
1766 tasks: crate::providers::TaskBroker,
1767) {
1768 use crate::models::UserFacingError;
1769
1770 let Some(factory) = providers else {
1771 let error = UserFacingError {
1772 summary: "not wired".to_string(),
1773 message: "EffectRunner has no ProviderFactory bound".to_string(),
1774 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1775 category: crate::models::ErrorCategory::Internal,
1776 recoverable: false,
1777 };
1778 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1779 return;
1780 };
1781
1782 let provider = match factory.resolve(&request.model_id).await {
1784 Ok(p) => p,
1785 Err(e) => {
1786 let error = classify_error_for_ui(&e);
1787 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1788 return;
1789 },
1790 };
1791 {
1792 let model_id = request.model_id.clone();
1796 let caps = provider.capabilities().clone();
1797 if let Err(e) =
1804 tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1805 .await
1806 {
1807 tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1808 }
1809 }
1810 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1811 let _ = msg_tx
1812 .send(Msg::TransientStatus {
1813 text: format!(
1814 "{} does not advertise tool support; Mermaid will send the turn without tools",
1815 request.model_id
1816 ),
1817 })
1818 .await;
1819 request.tools.clear();
1820 }
1821
1822 let sizing = provider.resolve_context_window(&request).await;
1828 let max_context_tokens = sizing.effective.or_else(|| {
1829 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1830 });
1831 request.resolved_context_window = sizing.effective.or(sizing.model_max);
1837 request.resolved_max_output = sizing.max_output;
1838 let _ = msg_tx
1842 .send(Msg::ProviderContextResolved {
1843 model_id: request.model_id.clone(),
1844 model_max: sizing.model_max,
1845 effective: sizing.effective,
1846 source: sizing.source,
1847 max_output: sizing.max_output,
1848 })
1849 .await;
1850 if request
1856 .messages
1857 .iter()
1858 .any(|m| m.images.as_ref().is_some_and(|v| !v.is_empty()))
1859 {
1860 let supports_vision = provider.supports_vision().await;
1861 let _ = msg_tx
1862 .send(Msg::ProviderVisionResolved {
1863 model_id: request.model_id.clone(),
1864 supports_vision,
1865 warn: true,
1866 })
1867 .await;
1868 }
1869 let context_snapshot =
1870 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1871 let _ = msg_tx
1872 .send(Msg::ContextUsageEstimated {
1873 turn,
1874 snapshot: context_snapshot.clone(),
1875 })
1876 .await;
1877
1878 let policy = factory.config().compaction.policy();
1882 let mut compacted_before_stream = false;
1883 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1884 let compaction =
1885 CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold, policy);
1886 if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1889 match run_compaction(
1890 Arc::clone(&provider),
1891 turn,
1892 compaction,
1893 prepared,
1894 context_snapshot.clone(),
1895 max_context_tokens,
1896 token.clone(),
1897 )
1898 .await
1899 {
1900 Ok(result) => {
1901 request.messages = result.replacement_messages.clone();
1902 compacted_before_stream = true;
1903 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1904 },
1905 Err(err) => {
1906 if token.is_cancelled() {
1914 return;
1915 }
1916 tracing::warn!(
1917 turn = %turn,
1918 error = %err,
1919 "auto-compaction failed; proceeding with the un-compacted request",
1920 );
1921 let _ = msg_tx
1922 .send(Msg::CompactionFailed {
1923 turn,
1924 trigger: CompactionTrigger::AutoThreshold,
1925 message: err.to_string(),
1926 kind: crate::domain::StatusKind::Warn,
1927 })
1928 .await;
1929 },
1930 }
1931 }
1932 }
1933
1934 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1937 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1938
1939 let relay_tx = msg_tx.clone();
1946 let relay_token = token.clone();
1947 let relay_tasks = tasks.clone();
1948 let relay = spawn_guarded(async move {
1949 loop {
1950 let event = tokio::select! {
1951 biased;
1952 _ = relay_token.cancelled() => {
1953 while let Ok(buffered) = stream_rx.try_recv() {
1960 if let StreamEvent::Done {
1961 usage,
1962 provider_continuation,
1963 stop_reason,
1964 } = buffered
1965 {
1966 note_stream_usage(&relay_tasks, &usage);
1967 let _ = relay_tx
1968 .send(Msg::StreamDone {
1969 turn,
1970 usage,
1971 provider_continuation,
1972 stop_reason,
1973 })
1974 .await;
1975 }
1976 }
1977 break;
1978 },
1979 ev = stream_rx.recv() => match ev {
1980 Some(ev) => ev,
1981 None => break,
1982 },
1983 };
1984 let msg = match event {
1985 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1986 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1987 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1988 StreamEvent::Status(text) => Msg::TransientStatus { text },
1991 StreamEvent::Done {
1992 usage,
1993 provider_continuation,
1994 stop_reason,
1995 } => {
1996 note_stream_usage(&relay_tasks, &usage);
1997 Msg::StreamDone {
1998 turn,
1999 usage,
2000 provider_continuation,
2001 stop_reason,
2002 }
2003 },
2004 };
2005 if relay_tx.send(msg).await.is_err() {
2006 break;
2007 }
2008 }
2009 });
2010
2011 let mut completed_ok = false;
2020 match provider.chat(request.clone(), ctx).await {
2021 Ok(_final_response) => {
2022 completed_ok = true;
2024 },
2025 Err(crate::models::ModelError::Cancelled) => {
2026 },
2028 Err(e) => {
2029 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
2030 if retry_context_limit {
2031 let latest_snapshot =
2032 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
2033 let compaction = CompactionRequest::auto(
2034 request.clone(),
2035 CompactionTrigger::ContextLimitRetry,
2036 policy,
2037 );
2038 if let Ok(prepared) =
2041 crate::domain::prepare_compaction(&compaction, max_context_tokens)
2042 {
2043 match run_compaction(
2044 Arc::clone(&provider),
2045 turn,
2046 compaction,
2047 prepared,
2048 latest_snapshot,
2049 max_context_tokens,
2050 token.clone(),
2051 )
2052 .await
2053 {
2054 Ok(result) => {
2055 let mut retry_request = request;
2056 retry_request.messages = result.replacement_messages.clone();
2057 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2058 join_logged(relay.take(), "stream_relay").await;
2059 dispatch_provider_stream(
2060 msg_tx,
2061 provider,
2062 turn,
2063 retry_request,
2064 token,
2065 tasks,
2066 )
2067 .await;
2068 return;
2069 },
2070 Err(compact_err) => {
2071 let _ = msg_tx
2072 .send(Msg::CompactionFailed {
2073 turn,
2074 trigger: CompactionTrigger::ContextLimitRetry,
2075 message: compact_err.to_string(),
2076 kind: crate::domain::StatusKind::Error,
2077 })
2078 .await;
2079 },
2080 }
2081 }
2082 }
2083 let error = classify_error_for_ui(&e);
2084 run_provider_error_hook(&request.model_id, &error).await;
2085 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
2086 },
2087 }
2088
2089 join_logged(relay.take(), "stream_relay").await;
2090
2091 if completed_ok
2096 && request.ollama_allow_ram_offload != Some(true)
2097 && let Some(p) = provider.verify_placement(sizing.effective).await
2098 {
2099 tracing::debug!(
2100 size_vram_bytes = p.size_vram_bytes,
2101 total_bytes = p.total_bytes,
2102 offloaded = p.size_vram_bytes < p.total_bytes,
2103 suggested_num_ctx = ?p.suggested_num_ctx,
2104 "Ollama placement"
2105 );
2106 let _ = msg_tx
2107 .send(Msg::OllamaPlacementResolved {
2108 model_id: request.model_id.clone(),
2109 size_vram_bytes: p.size_vram_bytes,
2110 total_bytes: p.total_bytes,
2111 suggested_num_ctx: p.suggested_num_ctx,
2112 })
2113 .await;
2114 }
2115}
2116
2117struct TurnTimer {
2121 turn: TurnId,
2122 model_id: String,
2123 started: std::time::Instant,
2124}
2125
2126impl Drop for TurnTimer {
2127 fn drop(&mut self) {
2128 tracing::debug!(
2129 turn = %self.turn,
2130 model = %self.model_id,
2131 elapsed_ms = self.started.elapsed().as_millis() as u64,
2132 "model turn complete"
2133 );
2134 }
2135}
2136
2137async fn dispatch_provider_stream(
2138 msg_tx: MsgSender,
2139 provider: Arc<dyn ModelProvider>,
2140 turn: TurnId,
2141 request: crate::domain::ChatRequest,
2142 token: tokio_util::sync::CancellationToken,
2143 tasks: crate::providers::TaskBroker,
2144) {
2145 let _turn_timer = TurnTimer {
2146 turn,
2147 model_id: request.model_id.clone(),
2148 started: std::time::Instant::now(),
2149 };
2150 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
2151 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
2152 let relay_tx = msg_tx.clone();
2153 let relay_token = token.clone();
2154 let relay_tasks = tasks.clone();
2155 let relay = spawn_guarded(async move {
2156 loop {
2157 let event = tokio::select! {
2158 biased;
2159 _ = relay_token.cancelled() => {
2160 while let Ok(buffered) = stream_rx.try_recv() {
2167 if let StreamEvent::Done {
2168 usage,
2169 provider_continuation,
2170 stop_reason,
2171 } = buffered
2172 {
2173 note_stream_usage(&relay_tasks, &usage);
2174 let _ = relay_tx
2175 .send(Msg::StreamDone {
2176 turn,
2177 usage,
2178 provider_continuation,
2179 stop_reason,
2180 })
2181 .await;
2182 }
2183 }
2184 break;
2185 },
2186 ev = stream_rx.recv() => match ev {
2187 Some(ev) => ev,
2188 None => break,
2189 },
2190 };
2191 let msg = match event {
2192 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
2193 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
2194 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
2195 StreamEvent::Status(text) => Msg::TransientStatus { text },
2197 StreamEvent::Done {
2198 usage,
2199 provider_continuation,
2200 stop_reason,
2201 } => {
2202 note_stream_usage(&relay_tasks, &usage);
2203 Msg::StreamDone {
2204 turn,
2205 usage,
2206 provider_continuation,
2207 stop_reason,
2208 }
2209 },
2210 };
2211 if relay_tx.send(msg).await.is_err() {
2212 break;
2213 }
2214 }
2215 });
2216
2217 let model_id = request.model_id.clone();
2218 match provider.chat(request, ctx).await {
2219 Ok(_) | Err(ModelError::Cancelled) => {},
2220 Err(e) => {
2221 let error = classify_error_for_ui(&e);
2222 run_provider_error_hook(&model_id, &error).await;
2223 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
2224 },
2225 }
2226
2227 join_logged(relay.take(), "stream_relay").await;
2228}
2229
2230async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
2236 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
2237 .await;
2238}
2239
2240async fn run_plugin_hooks_gated(
2245 event: &'static str,
2246 payload: serde_json::Value,
2247) -> crate::runtime::HookGate {
2248 tokio::task::spawn_blocking(move || {
2249 crate::runtime::run_plugin_hooks(event, &payload)
2250 .map(crate::runtime::aggregate_hook_responses)
2251 .unwrap_or_default()
2252 })
2253 .await
2254 .unwrap_or_default()
2255}
2256
2257async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
2258 fire_plugin_hooks(
2259 "provider_error",
2260 serde_json::json!({
2261 "model_id": model_id,
2262 "summary": &error.summary,
2263 "message": &error.message,
2264 "category": format!("{:?}", error.category),
2265 "recoverable": error.recoverable,
2266 }),
2267 )
2268 .await;
2269}
2270
2271fn memory_title_from_text(text: &str) -> String {
2275 let first = text
2276 .lines()
2277 .find(|l| !l.trim().is_empty())
2278 .unwrap_or("memory")
2279 .trim();
2280 let title: String = first
2281 .split_whitespace()
2282 .take(8)
2283 .collect::<Vec<_>>()
2284 .join(" ")
2285 .chars()
2286 .take(60)
2287 .collect();
2288 if title.trim().is_empty() {
2289 "memory".to_string()
2290 } else {
2291 title
2292 }
2293}
2294
2295const 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.";
2296
2297#[derive(Debug)]
2298struct PrunePlan {
2299 prune: Vec<String>,
2300 reason: String,
2301}
2302
2303fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
2306 let start = text.find('{')?;
2307 let end = text.rfind('}')?;
2308 if end < start {
2309 return None;
2310 }
2311 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
2312 let prune = json
2313 .get("prune")?
2314 .as_array()?
2315 .iter()
2316 .filter_map(|v| v.as_str().map(str::to_string))
2317 .collect();
2318 let reason = json
2319 .get("reason")
2320 .and_then(|v| v.as_str())
2321 .unwrap_or("")
2322 .to_string();
2323 Some(PrunePlan { prune, reason })
2324}
2325
2326async fn consolidate_memory(
2330 tx: MsgSender,
2331 providers: Option<Arc<ProviderFactory>>,
2332 workdir: std::path::PathBuf,
2333 model_id: String,
2334) {
2335 let items = crate::app::memory::entries_with_bodies(&workdir);
2336 if items.len() < 2 {
2337 let _ = tx
2338 .send(Msg::RuntimeText(format!(
2339 "Nothing to consolidate — {} memor{} saved.",
2340 items.len(),
2341 if items.len() == 1 { "y" } else { "ies" }
2342 )))
2343 .await;
2344 return;
2345 }
2346 let Some(factory) = providers else {
2347 let _ = tx
2348 .send(Msg::RuntimeText(
2349 "Memory consolidation needs a model provider, which isn't bound in this session."
2350 .to_string(),
2351 ))
2352 .await;
2353 return;
2354 };
2355
2356 let mut listing = String::new();
2357 for (entry, body) in &items {
2358 let id = entry
2359 .path
2360 .file_stem()
2361 .and_then(|s| s.to_str())
2362 .unwrap_or(entry.name.as_str());
2363 listing.push_str(&format!(
2364 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
2365 entry.scope.as_str(),
2366 entry.description,
2367 body.replace('\n', " ").trim(),
2368 ));
2369 }
2370 let user = format!(
2371 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
2372 items.len(),
2373 listing
2374 );
2375 let request = crate::domain::ChatRequest {
2376 model_id: model_id.clone(),
2377 messages: vec![crate::models::ChatMessage::user(user)],
2378 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
2379 instructions: None,
2380 reasoning: crate::models::ReasoningLevel::None,
2381 temperature: 0.0,
2382 max_tokens: 1024,
2383 tools: Vec::new(),
2384 ollama_num_ctx: None,
2385 ollama_allow_ram_offload: None,
2386 resolved_context_window: None,
2387 resolved_max_output: None,
2388 output_schema: None,
2389 suppress_auto_compact: false,
2390 suppressed_builtin_tools: Vec::new(),
2391 };
2392
2393 let provider = match factory.resolve(&model_id).await {
2394 Ok(p) => p,
2395 Err(e) => {
2396 let _ = tx
2397 .send(Msg::RuntimeText(format!(
2398 "Memory consolidation failed: {e}"
2399 )))
2400 .await;
2401 return;
2402 },
2403 };
2404 let token = tokio_util::sync::CancellationToken::new();
2405 let text =
2406 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
2407 Ok((t, _)) => t,
2408 Err(e) => {
2409 let _ = tx
2410 .send(Msg::RuntimeText(format!(
2411 "Memory consolidation failed: {e}"
2412 )))
2413 .await;
2414 return;
2415 },
2416 };
2417
2418 let Some(plan) = parse_prune_plan(&text) else {
2419 let _ = tx
2420 .send(Msg::RuntimeText(
2421 "Memory consolidation: couldn't parse the model's plan; nothing changed."
2422 .to_string(),
2423 ))
2424 .await;
2425 return;
2426 };
2427 if plan.prune.is_empty() {
2428 let reason = if plan.reason.is_empty() {
2429 String::new()
2430 } else {
2431 format!(" {}", plan.reason)
2432 };
2433 let _ = tx
2434 .send(Msg::RuntimeText(format!(
2435 "Memory consolidation: nothing to prune.{reason}"
2436 )))
2437 .await;
2438 return;
2439 }
2440
2441 let paths: Vec<std::path::PathBuf> = plan
2447 .prune
2448 .iter()
2449 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
2450 .collect();
2451 if !paths.is_empty()
2452 && let Err(e) = crate::runtime::create_checkpoint(
2453 &workdir,
2454 &paths,
2455 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
2456 )
2457 {
2458 let _ = tx
2459 .send(Msg::RuntimeText(format!(
2460 "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
2461 paths.len(),
2462 if paths.len() == 1 { "" } else { "s" },
2463 )))
2464 .await;
2465 return;
2466 }
2467
2468 let mut pruned = Vec::new();
2469 for id in &plan.prune {
2470 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
2471 pruned.push(id.clone());
2472 }
2473 }
2474
2475 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
2476 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
2477 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
2478
2479 let report = if pruned.is_empty() {
2480 "Memory consolidation: the model named facts to prune, but none matched existing memories."
2481 .to_string()
2482 } else {
2483 format!(
2484 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
2485 pruned.len(),
2486 if pruned.len() == 1 { "" } else { "s" },
2487 pruned.join(", "),
2488 if plan.reason.is_empty() {
2489 String::new()
2490 } else {
2491 format!(" Reason: {}.", plan.reason)
2492 },
2493 )
2494 };
2495 let _ = tx.send(Msg::RuntimeText(report)).await;
2496}
2497
2498async fn dispatch_compact_conversation(
2499 msg_tx: MsgSender,
2500 providers: Option<Arc<ProviderFactory>>,
2501 turn: TurnId,
2502 mut request: CompactionRequest,
2503 token: tokio_util::sync::CancellationToken,
2504) {
2505 let Some(factory) = providers else {
2506 let _ = msg_tx
2507 .send(Msg::CompactionFailed {
2508 turn,
2509 trigger: request.trigger,
2510 message: "EffectRunner has no ProviderFactory bound".to_string(),
2511 kind: crate::domain::StatusKind::Error,
2512 })
2513 .await;
2514 return;
2515 };
2516
2517 let provider = match factory.resolve(&request.chat.model_id).await {
2518 Ok(provider) => provider,
2519 Err(err) => {
2520 let _ = msg_tx
2521 .send(Msg::CompactionFailed {
2522 turn,
2523 trigger: request.trigger,
2524 message: err.to_string(),
2525 kind: crate::domain::StatusKind::Error,
2526 })
2527 .await;
2528 return;
2529 },
2530 };
2531
2532 let sizing = provider.resolve_context_window(&request.chat).await;
2537 request.chat.resolved_context_window = sizing.effective.or(sizing.model_max);
2538 request.chat.resolved_max_output = sizing.max_output;
2539 let max_context_tokens = request.chat.resolved_context_window.or_else(|| {
2540 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
2541 });
2542 let before_snapshot =
2543 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
2544
2545 let trigger = request.trigger;
2546 let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
2551 Ok(prepared) => prepared,
2552 Err(skip) => {
2553 let _ = msg_tx
2554 .send(Msg::CompactionFailed {
2555 turn,
2556 trigger,
2557 message: skip.to_string(),
2558 kind: crate::domain::StatusKind::Info,
2559 })
2560 .await;
2561 return;
2562 },
2563 };
2564 match run_compaction(
2565 provider,
2566 turn,
2567 request,
2568 prepared,
2569 before_snapshot,
2570 max_context_tokens,
2571 token,
2572 )
2573 .await
2574 {
2575 Ok(result) => {
2576 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2577 },
2578 Err(err) => {
2579 let _ = msg_tx
2580 .send(Msg::CompactionFailed {
2581 turn,
2582 trigger,
2583 message: err.to_string(),
2584 kind: crate::domain::StatusKind::Error,
2585 })
2586 .await;
2587 },
2588 }
2589}
2590
2591async fn run_compaction(
2592 provider: Arc<dyn ModelProvider>,
2593 turn: TurnId,
2594 request: CompactionRequest,
2595 prepared: crate::domain::PreparedCompaction,
2596 before_snapshot: crate::domain::ContextUsageSnapshot,
2597 max_context_tokens: Option<usize>,
2598 token: tokio_util::sync::CancellationToken,
2599) -> Result<CompactionResult, ModelError> {
2600 let started = Instant::now();
2601
2602 let summary_request = crate::domain::build_summary_request(
2603 &request.chat,
2604 &prepared,
2605 request.instructions.as_deref(),
2606 request.policy,
2607 max_context_tokens,
2608 );
2609 ensure_compaction_request_fits(&summary_request, max_context_tokens)?;
2610 let (draft, draft_usage) =
2611 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2612 .await?;
2613 let draft_summary = crate::domain::normalize_summary(&draft);
2614 let draft_validation = crate::domain::validate_summary_structure(&draft_summary);
2615
2616 let verify_request = crate::domain::build_verification_request(
2617 &request.chat,
2618 &prepared,
2619 &draft_summary,
2620 request.instructions.as_deref(),
2621 request.policy,
2622 max_context_tokens,
2623 );
2624 let review_fits = compaction_request_fits(&verify_request, max_context_tokens);
2625 let (final_summary, verify_usage, review_status, review_error) = if review_fits {
2626 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2627 Ok((verified_text, verify_usage)) => {
2628 let verified_summary = crate::domain::normalize_summary(&verified_text);
2629 match crate::domain::validate_summary_structure(&verified_summary) {
2630 Ok(()) => (
2631 verified_summary,
2632 verify_usage,
2633 crate::domain::CompactionReviewStatus::Reviewed,
2634 None,
2635 ),
2636 Err(error) => match draft_validation {
2637 Ok(()) => (
2638 draft_summary,
2639 verify_usage,
2640 crate::domain::CompactionReviewStatus::DraftValidated,
2641 Some(format!("review returned an invalid checkpoint: {error}")),
2642 ),
2643 Err(draft_error) => {
2644 return Err(ModelError::InvalidRequest(format!(
2645 "compaction produced no structurally valid checkpoint (draft: {draft_error}; review: {error})"
2646 )));
2647 },
2648 },
2649 }
2650 },
2651 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2652 Err(err) => match draft_validation {
2653 Ok(()) => (
2654 draft_summary,
2655 None,
2656 crate::domain::CompactionReviewStatus::DraftValidated,
2657 Some(format!("review failed: {err}")),
2658 ),
2659 Err(draft_error) => {
2660 return Err(ModelError::InvalidRequest(format!(
2661 "compaction draft was invalid and review failed (draft: {draft_error}; review: {err})"
2662 )));
2663 },
2664 },
2665 }
2666 } else {
2667 match draft_validation {
2668 Ok(()) => (
2669 draft_summary,
2670 None,
2671 crate::domain::CompactionReviewStatus::DraftValidated,
2672 Some(
2673 "review skipped because the complete request would exceed the context window"
2674 .to_string(),
2675 ),
2676 ),
2677 Err(error) => {
2678 return Err(ModelError::InvalidRequest(format!(
2679 "compaction draft was invalid and the review request did not fit: {error}"
2680 )));
2681 },
2682 }
2683 };
2684
2685 let id = format!(
2686 "compact_{}",
2687 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2688 );
2689 let mut record = crate::domain::CompactionRecord {
2690 id,
2691 trigger: request.trigger,
2692 created_at: chrono::Local::now(),
2693 before_tokens: before_snapshot.used_tokens,
2694 after_tokens: 0,
2695 archived_message_count: prepared.archived_messages.len(),
2696 preserved_message_count: prepared.preserved_messages.len(),
2697 preserved_turn_count: prepared
2698 .preserved_messages
2699 .iter()
2700 .filter(|message| message.role == crate::models::MessageRole::User)
2701 .count(),
2702 summary_tokens: final_summary.len().div_ceil(4),
2703 duration_secs: started.elapsed().as_secs_f64(),
2704 review_status,
2705 review_error,
2706 focus: request.instructions.clone(),
2707 archive_path: None,
2708 };
2709
2710 record.duration_secs = started.elapsed().as_secs_f64();
2711 const AFTER_TOKENS_PASSES: usize = 4;
2721 let mut compacted_request = request.chat.clone();
2722 let mut replacement =
2723 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2724 for _ in 0..AFTER_TOKENS_PASSES {
2725 compacted_request.messages = replacement.clone();
2726 let measured = crate::domain::estimate_context_usage_for_request(
2727 &compacted_request,
2728 max_context_tokens,
2729 );
2730 if measured.used_tokens == record.after_tokens {
2731 break;
2732 }
2733 record.after_tokens = measured.used_tokens;
2734 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2735 }
2736 compacted_request.messages = replacement.clone();
2740 let after_snapshot =
2741 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2742
2743 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2744 return Err(ModelError::InvalidRequest(format!(
2745 "compaction did not reduce context ({} -> {} tokens)",
2746 before_snapshot.used_tokens, after_snapshot.used_tokens
2747 )));
2748 }
2749
2750 if crate::domain::context_exceeds_hard_limit(
2751 &after_snapshot,
2752 &compacted_request,
2753 request.policy,
2754 ) {
2755 return Err(ModelError::InvalidRequest(format!(
2756 "compacted context still exceeds response reserve ({} tokens used)",
2757 after_snapshot.used_tokens
2758 )));
2759 }
2760
2761 Ok(CompactionResult {
2762 record,
2763 replacement_messages: replacement,
2764 archived_messages: prepared.archived_messages,
2765 before_snapshot,
2766 after_snapshot,
2767 usage: crate::domain::combine_usage(draft_usage, verify_usage),
2768 source_boundaries: request
2769 .chat
2770 .messages
2771 .iter()
2772 .map(crate::domain::CompactionBoundary::from_message)
2773 .collect(),
2774 })
2775}
2776
2777fn compaction_request_fits(
2778 request: &crate::domain::ChatRequest,
2779 max_context_tokens: Option<usize>,
2780) -> bool {
2781 let Some(max_tokens) = max_context_tokens else {
2782 return true;
2783 };
2784 let used = crate::domain::estimate_context_usage_for_request(request, Some(max_tokens));
2785 used.used_tokens.saturating_add(request.max_tokens) <= max_tokens
2786}
2787
2788fn ensure_compaction_request_fits(
2789 request: &crate::domain::ChatRequest,
2790 max_context_tokens: Option<usize>,
2791) -> Result<(), ModelError> {
2792 if compaction_request_fits(request, max_context_tokens) {
2793 Ok(())
2794 } else {
2795 Err(ModelError::InvalidRequest(
2796 "complete compaction request exceeds the model context window".to_string(),
2797 ))
2798 }
2799}
2800
2801async fn collect_compaction_text(
2802 provider: Arc<dyn ModelProvider>,
2803 turn: TurnId,
2804 request: crate::domain::ChatRequest,
2805 token: tokio_util::sync::CancellationToken,
2806) -> Result<(String, Option<TokenUsage>), ModelError> {
2807 crate::providers::model::collect_text(provider, turn, request, token).await
2810}
2811
2812fn record_provider_capabilities(
2813 model_id: &str,
2814 caps: &crate::providers::capabilities::Capabilities,
2815) {
2816 let (provider, model) = split_model_id(model_id);
2817 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2818 for (key, value) in [
2819 ("tools_support", caps.supports_tools.to_string()),
2820 ("vision_support", caps.supports_vision.to_string()),
2821 (
2822 "context_limit",
2823 caps.max_context_tokens
2824 .map(|v| v.to_string())
2825 .unwrap_or_else(|| "unknown".to_string()),
2826 ),
2827 (
2828 "reasoning_parameter_shape",
2829 format!("{:?}", caps.supports_reasoning),
2830 ),
2831 (
2832 "streaming_usage_available",
2833 "provider_dependent".to_string(),
2834 ),
2835 ("token_usage_field_shape", "normalized".to_string()),
2836 ] {
2837 let _ = store
2838 .provider_probes()
2839 .upsert(crate::runtime::NewProviderProbe {
2840 provider: provider.clone(),
2841 model_id: model.clone(),
2842 capability_key: key.to_string(),
2843 capability_value: value,
2844 confidence: "verified".to_string(),
2845 error: None,
2846 });
2847 }
2848 }
2849}
2850
2851fn split_model_id(model_id: &str) -> (String, String) {
2852 match model_id.split_once('/') {
2853 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2854 (provider.to_ascii_lowercase(), model.to_string())
2855 },
2856 _ => ("ollama".to_string(), model_id.to_string()),
2857 }
2858}
2859
2860const MAX_PROJECT_FILES: usize = 20_000;
2863
2864fn walk_project_files(root: &std::path::Path) -> Vec<String> {
2871 let mut files = Vec::new();
2872 for entry in ignore::WalkBuilder::new(root)
2873 .hidden(true)
2874 .follow_links(false)
2875 .build()
2876 .flatten()
2877 {
2878 if files.len() >= MAX_PROJECT_FILES {
2879 break;
2880 }
2881 let path = entry.path();
2882 if path == root {
2883 continue;
2884 }
2885 let Ok(rel) = path.strip_prefix(root) else {
2886 continue;
2887 };
2888 let Some(mut rel) = rel.to_str().map(str::to_string) else {
2889 continue;
2890 };
2891 if std::path::MAIN_SEPARATOR != '/' {
2893 rel = rel.replace(std::path::MAIN_SEPARATOR, "/");
2894 }
2895 if entry.file_type().is_some_and(|t| t.is_dir()) {
2896 rel.push('/');
2897 }
2898 files.push(rel);
2899 }
2900 files.sort();
2901 files
2902}
2903
2904fn is_context_limit_error(error: &ModelError) -> bool {
2905 let text = error.to_string().to_lowercase();
2906 text.contains("context")
2907 && (text.contains("too large")
2908 || text.contains("exceed")
2909 || text.contains("maximum")
2910 || text.contains("token"))
2911}
2912
2913#[allow(clippy::too_many_arguments)]
2915async fn dispatch_execute_tool(
2916 msg_tx: MsgSender,
2917 tools: Option<Arc<ToolRegistry>>,
2918 workdir: PathBuf,
2919 turn: TurnId,
2920 call_id: crate::domain::ToolCallId,
2921 source: crate::models::tool_call::ToolCall,
2922 token: tokio_util::sync::CancellationToken,
2923 background: tokio_util::sync::CancellationToken,
2924 web_bytes: Arc<std::sync::atomic::AtomicUsize>,
2925 config: Arc<crate::app::Config>,
2926 model_id: String,
2927 task_id: Option<String>,
2928 session_id: String,
2929 message_index: usize,
2930 scratchpad: Option<PathBuf>,
2931 safety_mode: crate::runtime::SafetyMode,
2932 plan_file: Option<PathBuf>,
2933 plan_permissions: crate::app::PlanPermissions,
2934 context_percent: Option<u8>,
2935 intent: Option<String>,
2936 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2937 approval: Option<crate::providers::ApprovalBroker>,
2938 questions: Option<crate::providers::QuestionBroker>,
2939 tasks: crate::providers::TaskBroker,
2940) {
2941 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2942
2943 let Some(registry) = tools else {
2944 let _ = msg_tx
2945 .send(Msg::ToolFinished {
2946 turn,
2947 call_id,
2948 outcome: crate::domain::ToolOutcome::error(
2949 "EffectRunner has no ToolRegistry bound",
2950 0.0,
2951 ),
2952 })
2953 .await;
2954 return;
2955 };
2956
2957 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2961 let rest = &source.function.name[5..];
2962 if let Some((server, tool)) = rest.split_once("__") {
2963 (
2964 "mcp_proxy",
2965 serde_json::json!({
2966 "server_name": server,
2967 "tool_name": tool,
2968 "arguments": source.function.arguments.clone(),
2969 }),
2970 )
2971 } else {
2972 let _ = msg_tx
2973 .send(Msg::ToolFinished {
2974 turn,
2975 call_id,
2976 outcome: crate::domain::ToolOutcome::error(
2977 format!("invalid MCP tool name: {}", source.function.name),
2978 0.0,
2979 ),
2980 })
2981 .await;
2982 return;
2983 }
2984 } else {
2985 (
2986 source.function.name.as_str(),
2987 source.function.arguments.clone(),
2988 )
2989 };
2990 let tool_run_id =
2991 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2992
2993 let Some(tool) = registry.get(tool_key) else {
2994 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2995 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2996 let _ = msg_tx
2997 .send(Msg::ToolFinished {
2998 turn,
2999 call_id,
3000 outcome,
3001 })
3002 .await;
3003 return;
3004 };
3005
3006 let (progress_tx, mut progress_rx) = mpsc::channel(16);
3012 let relay_tx = msg_tx.clone();
3013 let relay_token = token.clone();
3014 let progress_relay = spawn_guarded(async move {
3015 loop {
3016 let event = tokio::select! {
3017 biased;
3018 _ = relay_token.cancelled() => break,
3019 ev = progress_rx.recv() => match ev {
3020 Some(ev) => ev,
3021 None => break,
3022 },
3023 };
3024 if relay_tx
3025 .send(Msg::ToolProgress {
3026 turn,
3027 call_id,
3028 event,
3029 })
3030 .await
3031 .is_err()
3032 {
3033 break;
3034 }
3035 }
3036 });
3037
3038 let mut ctx = ExecContext::new(
3039 token,
3040 progress_tx,
3041 call_id,
3042 turn,
3043 workdir,
3044 config,
3045 model_id,
3046 task_id,
3047 Some(session_id),
3048 Some(message_index as i64),
3049 safety_mode,
3050 intent,
3051 classifier,
3052 approval,
3053 questions,
3054 Some(tasks.clone()),
3055 );
3056 ctx.background = background;
3057 ctx.web_bytes = web_bytes;
3058 ctx.plan_file = plan_file;
3059 ctx.plan_permissions = plan_permissions;
3060 ctx.context_percent = context_percent;
3061 ctx.notify = Some(msg_tx.clone());
3064 ctx.scratchpad = scratchpad;
3066 let before_payload = serde_json::json!({
3070 "turn_id": turn.0,
3071 "call_id": call_id.0,
3072 "tool": tool_key,
3073 "arguments": args,
3074 });
3075 let gate = run_plugin_hooks_gated("before_tool_use", before_payload).await;
3076 if !gate.context.is_empty() {
3077 let texts = gate
3080 .context
3081 .iter()
3082 .map(|t| crate::utils::redact_secrets(t))
3083 .collect();
3084 let _ = msg_tx.send(Msg::HookContext { turn, texts }).await;
3085 }
3086 if let Some((plugin, reason)) = gate.deny {
3087 drop(ctx);
3091 let reason = crate::utils::redact_secrets(&reason);
3092 let outcome = crate::domain::ToolOutcome::error(
3093 format!("Denied by plugin hook ({plugin}): {reason}"),
3094 0.0,
3095 );
3096 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
3097 join_logged(progress_relay.take(), "tool_progress_relay").await;
3098 let _ = msg_tx
3099 .send(Msg::ToolFinished {
3100 turn,
3101 call_id,
3102 outcome,
3103 })
3104 .await;
3105 return;
3106 }
3107 let args = gate.updated_input.unwrap_or(args);
3112 let outcome = tool.execute(args, ctx).await;
3113 if !source.function.name.starts_with("task_") {
3118 let (action, target) = crate::domain::display_info_for(&crate::domain::PendingToolCall {
3119 call_id,
3120 source: source.clone(),
3121 });
3122 tasks
3123 .record_evidence(crate::domain::EvidenceEntry {
3124 tool: action,
3125 target,
3126 status: tool_status_label(outcome.status).to_string(),
3127 })
3128 .await;
3129 }
3130 let after_payload = serde_json::json!({
3131 "turn_id": turn.0,
3132 "call_id": call_id.0,
3133 "tool": tool_key,
3134 "status": tool_status_label(outcome.status),
3135 "summary": &outcome.summary,
3136 });
3137 fire_plugin_hooks("after_tool_use", after_payload).await;
3138 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
3139 join_logged(progress_relay.take(), "tool_progress_relay").await;
3140 let _ = msg_tx
3141 .send(Msg::ToolFinished {
3142 turn,
3143 call_id,
3144 outcome,
3145 })
3146 .await;
3147}
3148
3149async fn start_runtime_tool_run(
3150 task_id: Option<&str>,
3151 turn: TurnId,
3152 call_id: crate::domain::ToolCallId,
3153 tool_name: &str,
3154 args: &serde_json::Value,
3155) -> Option<String> {
3156 let task_id = task_id.map(str::to_string);
3160 let tool_name = tool_name.to_string();
3161 let args_json = redacted_json_string(args);
3162 tokio::task::spawn_blocking(move || {
3163 crate::runtime::RuntimeStore::open_default()
3164 .and_then(|store| {
3165 store.tool_runs().start(crate::runtime::NewToolRun {
3166 id: None,
3167 task_id,
3168 turn_id: Some(turn.0.to_string()),
3169 call_id: Some(call_id.0.to_string()),
3170 tool_name,
3171 args_json,
3172 })
3173 })
3174 .map(|record| record.id)
3175 .ok()
3176 })
3177 .await
3178 .ok()
3179 .flatten()
3180}
3181
3182fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
3183 let Some(tool_run_id) = tool_run_id else {
3184 return;
3185 };
3186 let tool_run_id = tool_run_id.to_string();
3187 let status = tool_status_label(outcome.status).to_string();
3188 let output_json = redacted_json_string(&serde_json::json!({
3189 "status": tool_status_label(outcome.status),
3190 "summary": &outcome.summary,
3191 "model_content": &outcome.model_content,
3192 "error": &outcome.error,
3193 "metadata": &outcome.metadata,
3194 "artifacts": &outcome.artifacts,
3195 "duration_secs": outcome.duration_secs,
3196 }));
3197 tokio::task::spawn_blocking(move || {
3200 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
3201 let _ = store
3202 .tool_runs()
3203 .finish(&tool_run_id, &status, output_json.as_deref());
3204 }
3205 });
3206}
3207
3208fn redacted_json_string(value: &serde_json::Value) -> Option<String> {
3213 let mut redacted = value.clone();
3214 crate::utils::redact_json(&mut redacted);
3215 serde_json::to_string(&redacted).ok()
3216}
3217
3218fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
3219 match status {
3220 crate::domain::ToolStatus::Success => "success",
3221 crate::domain::ToolStatus::Error => "error",
3222 crate::domain::ToolStatus::Cancelled => "cancelled",
3223 }
3224}
3225
3226fn runtime_model_info_text(model: &str) -> String {
3227 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
3228 let mut lines = vec![
3229 format!("Model info: {}", model),
3230 format!("- provider: {}", snapshot.provider),
3231 format!("- model: {}", snapshot.model),
3232 format!("- supports tools: {}", snapshot.supports_tools),
3233 format!("- supports vision: {}", snapshot.supports_vision),
3234 format!("- reasoning: {}", snapshot.reasoning),
3235 format!(
3236 "- context limit: {}",
3237 snapshot
3238 .max_context_tokens
3239 .map(|value: usize| value.to_string())
3240 .unwrap_or_else(|| "unknown".to_string())
3241 ),
3242 ];
3243 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
3244 && let Ok(probes) = store
3245 .provider_probes()
3246 .list(Some(&snapshot.provider), Some(&snapshot.model))
3247 && !probes.is_empty()
3248 {
3249 lines.push(String::new());
3250 lines.push("Cached provider reality records:".to_string());
3251 for probe in probes {
3252 lines.push(format!(
3253 "- {} = {} ({})",
3254 probe.capability_key, probe.capability_value, probe.confidence
3255 ));
3256 }
3257 }
3258 lines.join("\n")
3259}
3260
3261async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
3266 use tokio::io::{AsyncBufReadExt, BufReader};
3267 use tokio::process::Command;
3268
3269 let mut cmd = Command::new("ollama");
3270 cmd.arg("pull")
3271 .arg(&model)
3272 .stdin(std::process::Stdio::null())
3273 .stdout(std::process::Stdio::piped())
3274 .stderr(std::process::Stdio::piped())
3275 .kill_on_drop(true);
3276
3277 let mut child = match cmd.spawn() {
3278 Ok(c) => c,
3279 Err(e) => {
3280 let _ = tx
3281 .send(Msg::ModelPullProgress(format!(
3282 "ollama pull failed to start: {}",
3283 e
3284 )))
3285 .await;
3286 return;
3287 },
3288 };
3289
3290 let reader_handle = child.stdout.take().map(|stdout| {
3294 let tx_inner = tx.clone();
3295 tokio::spawn(async move {
3296 let mut reader = BufReader::new(stdout).lines();
3297 while let Ok(Some(line)) = reader.next_line().await {
3298 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
3299 }
3300 })
3301 });
3302
3303 match child.wait().await {
3304 Ok(status) if status.success() => {
3305 let _ = tx.send(Msg::ModelPullFinished { model }).await;
3306 },
3307 Ok(status) => {
3308 let _ = tx
3309 .send(Msg::ModelPullProgress(format!(
3310 "ollama pull exited with status {}",
3311 status.code().unwrap_or(-1)
3312 )))
3313 .await;
3314 },
3315 Err(e) => {
3316 let _ = tx
3317 .send(Msg::ModelPullProgress(format!(
3318 "ollama pull wait error: {}",
3319 e
3320 )))
3321 .await;
3322 },
3323 }
3324
3325 if let Some(handle) = reader_handle {
3328 join_logged(handle, "ollama_pull_reader").await;
3329 }
3330}
3331
3332async fn dispatch_init_mcp_servers(
3341 configs: std::collections::HashMap<String, crate::app::McpServerConfig>,
3342 tx: tokio::sync::mpsc::Sender<Msg>,
3343) {
3344 if configs.is_empty() {
3345 return;
3346 }
3347 crate::mcp::manager_ref::mark_init_started();
3348 let manager = std::sync::Arc::new(crate::mcp::McpServerManager::new(&configs));
3349 crate::mcp::manager_ref::set_manager(manager.clone());
3350 let mut join = tokio::task::JoinSet::new();
3351 for (name, config) in configs {
3352 let manager = manager.clone();
3353 let tx = tx.clone();
3354 join.spawn(async move {
3355 let msg = match manager.start_server(&name, &config).await {
3356 Ok(tools) => Msg::McpServerReady { name, tools },
3357 Err(e) => Msg::McpServerErrored {
3358 name,
3359 reason: e.to_string(),
3360 },
3361 };
3362 let _ = tx.send(msg).await;
3363 });
3364 }
3365 while join.join_next().await.is_some() {}
3366 crate::mcp::manager_ref::mark_init_complete();
3367}
3368
3369async fn dispatch_read_clipboard(tx: MsgSender) {
3382 use crate::domain::ClipboardRead;
3383
3384 enum Outcome {
3385 Image { bytes: Vec<u8>, format: String },
3386 Text(String),
3387 Empty,
3388 Error(String),
3389 }
3390
3391 let outcome = tokio::task::spawn_blocking(|| {
3392 if crate::clipboard::has_image() {
3393 match crate::clipboard::read_image_bytes() {
3394 Ok((bytes, format)) => Outcome::Image { bytes, format },
3395 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
3396 }
3397 } else {
3398 match crate::clipboard::read_text() {
3399 Ok(t) if !t.is_empty() => Outcome::Text(t),
3400 Ok(_) => Outcome::Empty,
3401 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
3402 }
3403 }
3404 })
3405 .await
3406 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
3407
3408 let msg = match outcome {
3413 Outcome::Image { bytes, format } => {
3414 Msg::ClipboardRead(ClipboardRead::Image { bytes, format })
3415 },
3416 Outcome::Text(text) => Msg::ClipboardRead(ClipboardRead::Text(text)),
3417 Outcome::Empty => Msg::ClipboardRead(ClipboardRead::Empty),
3418 Outcome::Error(text) => Msg::ClipboardRead(ClipboardRead::Error(text)),
3419 };
3420 let _ = tx.send(msg).await;
3421}
3422
3423async fn dispatch_probe_vision(
3429 model_id: String,
3430 warn: bool,
3431 providers: Option<Arc<ProviderFactory>>,
3432 tx: MsgSender,
3433) {
3434 let supports_vision = match providers {
3435 Some(factory) => match factory.resolve(&model_id).await {
3436 Ok(provider) => provider.supports_vision().await,
3437 Err(_) => None,
3438 },
3439 None => None,
3440 };
3441 let _ = tx
3442 .send(Msg::ProviderVisionResolved {
3443 model_id,
3444 supports_vision,
3445 warn,
3446 })
3447 .await;
3448}
3449
3450async fn discover_available_models(
3460 providers: Option<Arc<ProviderFactory>>,
3461) -> Vec<crate::domain::state::ModelChoice> {
3462 use crate::domain::state::ModelChoice;
3463 let Some(factory) = providers else {
3464 return Vec::new();
3465 };
3466 let config = factory.config();
3467 let mut out: Vec<ModelChoice> = Vec::new();
3468
3469 if let Some(names) = list_ollama_models_readonly(config).await {
3471 for name in names {
3472 out.push(ModelChoice {
3473 id: format!("ollama/{name}"),
3474 group: "Local (Ollama)".to_string(),
3475 detail: "runs on this machine".to_string(),
3476 ready: true,
3477 });
3478 }
3479 }
3480
3481 for catalog in crate::providers::discovery::provider_catalogs(config).await {
3483 let Some(models) = catalog.models else {
3487 continue;
3488 };
3489 for id in models {
3490 out.push(ModelChoice {
3491 id: format!("{}/{}", catalog.provider.name, id),
3492 group: catalog.provider.name.clone(),
3493 detail: String::new(),
3494 ready: true,
3495 });
3496 }
3497 }
3498
3499 out.sort_by(|a, b| {
3502 let local = |c: &ModelChoice| u8::from(c.group != "Local (Ollama)");
3503 local(a)
3504 .cmp(&local(b))
3505 .then_with(|| a.group.cmp(&b.group))
3506 .then_with(|| a.id.cmp(&b.id))
3507 });
3508 out.dedup_by(|a, b| a.id == b.id);
3509 out
3510}
3511
3512async fn list_ollama_models_readonly(config: &crate::app::Config) -> Option<Vec<String>> {
3518 use crate::models::adapters::ollama::OllamaAdapter;
3519 use crate::models::{BackendConfig, Model};
3520 let backend = BackendConfig {
3521 ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
3522 timeout_secs: 5,
3523 max_idle_per_host: 2,
3524 ollama_autostart: false,
3526 };
3527 match OllamaAdapter::new("__list__", Arc::new(backend)).await {
3528 Ok(adapter) => adapter.list_models().await.ok(),
3529 Err(_) => None,
3530 }
3531}
3532
3533async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
3536 let char_count = text.chars().count();
3537 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
3538 .await
3539 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
3540
3541 let msg = match result {
3542 Ok(()) => Msg::Toast {
3546 text: format!("copied {char_count} chars to clipboard"),
3547 },
3548 Err(e) => Msg::TransientStatus {
3549 text: format!("Copy failed: {e}"),
3550 },
3551 };
3552 let _ = tx.send(msg).await;
3553}
3554
3555fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
3556 use crate::models::{ErrorCategory, ModelError, UserFacingError};
3557 match e {
3558 ModelError::Backend(b) => UserFacingError {
3559 summary: "Backend error".to_string(),
3560 message: b.to_string(),
3561 suggestion: "Check the provider endpoint / API key.".to_string(),
3562 category: ErrorCategory::Connection,
3563 recoverable: true,
3564 },
3565 ModelError::Authentication(msg) => UserFacingError {
3566 summary: "Auth error".to_string(),
3567 message: msg.clone(),
3568 suggestion: "Set the env var the provider expects.".to_string(),
3569 category: ErrorCategory::Auth,
3570 recoverable: false,
3571 },
3572 ModelError::RateLimit {
3573 retry_after,
3574 message,
3575 } => UserFacingError {
3576 summary: "Rate limited".to_string(),
3577 message: message.clone().unwrap_or_else(|| {
3580 "The provider rejected the request with 429 (too many requests).".to_string()
3581 }),
3582 suggestion: match retry_after {
3583 Some(secs) => format!("The provider asked to retry after {secs}s."),
3584 None => "Retry shortly; if it persists, check your plan's quota.".to_string(),
3585 },
3586 category: ErrorCategory::Temporary,
3587 recoverable: true,
3588 },
3589 ModelError::StreamError(msg) => UserFacingError {
3590 summary: "Stream error".to_string(),
3591 message: msg.clone(),
3592 suggestion: "Retry the request.".to_string(),
3593 category: ErrorCategory::Connection,
3594 recoverable: true,
3595 },
3596 other => UserFacingError {
3597 summary: "Model error".to_string(),
3598 message: other.to_string(),
3599 suggestion: String::new(),
3600 category: ErrorCategory::Internal,
3601 recoverable: false,
3602 },
3603 }
3604}
3605
3606#[cfg(test)]
3607mod tests {
3608 use super::*;
3609 use crate::domain::ToolCallId;
3610 use std::time::Duration;
3611
3612 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
3613 EffectRunner::pair(PathBuf::from("/tmp"))
3614 }
3615
3616 #[test]
3619 fn filter_suppressed_drops_only_the_named_tools() {
3620 let def = |name: &str| crate::domain::ToolDefinition {
3621 name: name.to_string(),
3622 description: String::new(),
3623 input_schema: serde_json::json!({}),
3624 };
3625 let tools = vec![def("task_create"), def("task_list"), def("task_update")];
3626 let kept = filter_suppressed(tools.clone(), &["task_create", "task_update"]);
3627 assert_eq!(
3628 kept.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
3629 vec!["task_list"]
3630 );
3631 let kept = filter_suppressed(tools, &[]);
3632 assert_eq!(kept.len(), 3, "empty suppression list is a no-op");
3633 }
3634
3635 #[test]
3636 fn runtime_tool_payloads_are_redacted_before_serialization() {
3637 let payload = serde_json::json!({
3638 "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
3639 "authorization": "opaque-secret-value",
3640 "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
3641 });
3642 let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
3643 assert!(
3644 !serialized.contains("hunter2"),
3645 "URL password leaked: {serialized}"
3646 );
3647 assert!(
3648 !serialized.contains("opaque-signature"),
3649 "signed URL leaked: {serialized}"
3650 );
3651 assert!(
3652 !serialized.contains("private"),
3653 "URL fragment leaked: {serialized}"
3654 );
3655 assert!(
3656 !serialized.contains("opaque-secret-value"),
3657 "credential-named field leaked: {serialized}"
3658 );
3659 assert!(
3660 !serialized.contains("abcdef123456ghijkl"),
3661 "bearer token leaked: {serialized}"
3662 );
3663 assert!(
3664 !serialized.contains("sk-abcdefghijklmnop1234"),
3665 "secret-shaped fetched content leaked: {serialized}"
3666 );
3667 assert!(serialized.contains("[REDACTED]"));
3668 }
3669
3670 #[test]
3671 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
3672 let root = std::env::temp_dir().join(format!(
3673 "mermaid-walk-{}-{:?}",
3674 std::process::id(),
3675 std::thread::current().id()
3676 ));
3677 let _ = std::fs::remove_dir_all(&root);
3678 std::fs::create_dir_all(root.join("src")).unwrap();
3679 std::fs::create_dir_all(root.join("target")).unwrap();
3680 std::fs::create_dir_all(root.join(".git")).unwrap();
3681 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
3682 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
3683 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
3684 std::fs::write(root.join("README.md"), "readme").unwrap();
3685 std::fs::write(root.join(".hidden"), "hidden").unwrap();
3686
3687 let files = walk_project_files(&root);
3688 assert_eq!(
3689 files,
3690 vec![
3691 "README.md".to_string(),
3692 "src/".to_string(),
3693 "src/main.rs".to_string(),
3694 ],
3695 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
3696 );
3697 let _ = std::fs::remove_dir_all(&root);
3698 }
3699
3700 #[test]
3701 fn new_child_suppresses_terminal_title() {
3702 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
3706 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
3707 let tools = Arc::new(ToolRegistry::new());
3708 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
3709 assert!(
3710 !child.terminal_title_enabled,
3711 "subagent child runner must suppress terminal-title escapes"
3712 );
3713 }
3714
3715 #[test]
3716 fn new_child_does_not_own_global_mcp_shutdown() {
3717 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
3722 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
3723 let tools = Arc::new(ToolRegistry::new());
3724 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
3725 assert!(
3726 !child.owns_global_mcp,
3727 "child runner must not reap the shared global MCP manager"
3728 );
3729 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
3730 assert!(
3731 top.owns_global_mcp,
3732 "top-level runner still owns the global MCP reap"
3733 );
3734 }
3735
3736 #[test]
3737 fn parse_prune_plan_extracts_json_amid_prose() {
3738 let plan = parse_prune_plan(
3739 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
3740 )
3741 .expect("should parse");
3742 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
3743 assert_eq!(plan.reason, "dupes");
3744 }
3745
3746 #[test]
3747 fn parse_prune_plan_handles_empty_and_garbage() {
3748 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
3749 .expect("empty plan parses");
3750 assert!(empty.prune.is_empty());
3751 assert!(parse_prune_plan("no json here").is_none());
3752 }
3753
3754 #[test]
3755 fn memory_title_from_text_is_short_and_nonempty() {
3756 assert_eq!(
3757 memory_title_from_text("prefer ripgrep over grep"),
3758 "prefer ripgrep over grep"
3759 );
3760 assert_eq!(memory_title_from_text(" "), "memory");
3761 let long = memory_title_from_text("one two three four five six seven eight nine ten");
3762 assert!(long.split_whitespace().count() <= 8);
3763 }
3764
3765 #[tokio::test]
3766 async fn dispatch_exit_is_noop_on_runner_state() {
3767 let (mut r, _rx) = runner();
3768 r.dispatch(Cmd::Exit);
3769 assert_eq!(r.scope_count(), 0);
3770 }
3771
3772 #[tokio::test]
3773 async fn dispatch_save_emits_session_saved() {
3774 let (mut r, mut rx) = runner();
3775 r.dispatch(Cmd::SaveConversation(
3776 crate::session::ConversationHistory::new(
3777 "/p".to_string(),
3778 "m".to_string(),
3779 chrono::Local::now(),
3780 ),
3781 ));
3782 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3783 .await
3784 .expect("sender emits")
3785 .expect("channel alive");
3786 assert!(matches!(msg, Msg::SessionSaved));
3787 }
3788
3789 #[cfg(unix)]
3790 #[tokio::test]
3791 async fn init_mcp_servers_emits_incremental_errored_msgs() {
3792 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
3796 let mut configs = std::collections::HashMap::new();
3797 for name in ["one", "two"] {
3798 configs.insert(
3799 name.to_string(),
3800 crate::app::McpServerConfig {
3801 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
3802 ..Default::default()
3803 },
3804 );
3805 }
3806 dispatch_init_mcp_servers(configs, tx).await;
3807 let mut errored = Vec::new();
3808 while let Ok(msg) = rx.try_recv() {
3809 match msg {
3810 Msg::McpServerErrored { name, .. } => errored.push(name),
3811 other => panic!("unexpected msg: {other:?}"),
3812 }
3813 }
3814 errored.sort();
3815 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
3816 assert!(crate::mcp::manager_ref::is_ready());
3817 }
3818
3819 #[tokio::test]
3820 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
3821 let (mut r, mut rx) = runner();
3822 let turn = TurnId(77);
3823 {
3824 let scope = r.scope_mut(turn);
3825 scope.spawn(async {
3826 std::future::pending::<()>().await;
3827 });
3828 }
3829 assert_eq!(r.scope_count(), 1);
3830
3831 let start = std::time::Instant::now();
3832 r.dispatch(Cmd::CancelScope(turn));
3833 assert_eq!(r.scope_count(), 0);
3834 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
3835 .await
3836 .expect("bounded cancel should emit terminal message")
3837 .expect("channel alive");
3838 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
3839 assert!(
3840 start.elapsed() < Duration::from_millis(500),
3841 "cancel terminal message took {:?}",
3842 start.elapsed()
3843 );
3844 }
3845
3846 #[tokio::test]
3847 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
3848 let (mut r, mut rx) = runner();
3854 let turn = TurnId(88);
3855 {
3856 let scope = r.scope_mut(turn);
3857 scope.spawn(async {}); }
3859 assert_eq!(r.scope_count(), 1);
3860
3861 tokio::time::sleep(Duration::from_millis(20)).await;
3863 r.dispatch(Cmd::Exit);
3864 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
3865
3866 r.dispatch(Cmd::CancelScope(turn));
3869 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
3870 .await
3871 .expect("cancel on a reaped scope must still emit a terminal message")
3872 .expect("channel alive");
3873 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
3874 }
3875
3876 #[tokio::test]
3877 async fn dispatch_call_model_creates_scope() {
3878 let (mut r, _rx) = runner();
3879 let turn = TurnId(7);
3880 let request = crate::domain::ChatRequest {
3881 model_id: "test/m".to_string(),
3882 messages: vec![],
3883 system_prompt: String::new(),
3884 instructions: None,
3885 reasoning: crate::models::ReasoningLevel::Medium,
3886 temperature: 0.7,
3887 max_tokens: 4096,
3888 tools: vec![],
3889
3890 ollama_num_ctx: None,
3891 ollama_allow_ram_offload: None,
3892 resolved_context_window: None,
3893 resolved_max_output: None,
3894 output_schema: None,
3895 suppress_auto_compact: false,
3896 suppressed_builtin_tools: Vec::new(),
3897 };
3898 r.dispatch(Cmd::CallModel { turn, request });
3899 assert_eq!(r.scope_count(), 1);
3900 }
3901
3902 #[tokio::test]
3906 async fn empty_scopes_are_reaped_on_next_dispatch() {
3907 let (mut r, mut rx) = runner();
3908 let turn = TurnId(42);
3909 let request = crate::domain::ChatRequest {
3910 model_id: "test/m".to_string(),
3911 messages: vec![],
3912 system_prompt: String::new(),
3913 instructions: None,
3914 reasoning: crate::models::ReasoningLevel::Medium,
3915 temperature: 0.7,
3916 max_tokens: 4096,
3917 tools: vec![],
3918
3919 ollama_num_ctx: None,
3920 ollama_allow_ram_offload: None,
3921 resolved_context_window: None,
3922 resolved_max_output: None,
3923 output_schema: None,
3924 suppress_auto_compact: false,
3925 suppressed_builtin_tools: Vec::new(),
3926 };
3927 r.dispatch(Cmd::CallModel { turn, request });
3928 assert_eq!(r.scope_count(), 1);
3929
3930 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3935 .await
3936 .expect("upstream error arrived")
3937 .expect("channel alive");
3938 assert!(matches!(msg, Msg::UpstreamError { .. }));
3939
3940 tokio::task::yield_now().await;
3942
3943 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
3945 assert_eq!(
3946 r.scope_count(),
3947 0,
3948 "completed scope must be reaped on next dispatch"
3949 );
3950 }
3951
3952 #[tokio::test]
3953 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
3954 let (mut r, mut rx) = runner();
3955 let turn = TurnId(7);
3956 let call_id = ToolCallId(1);
3957 let source = crate::models::tool_call::ToolCall {
3958 id: Some("c1".to_string()),
3959 function: crate::models::tool_call::FunctionCall {
3960 name: "read_file".to_string(),
3961 arguments: serde_json::json!({"path": "x"}),
3962 },
3963 };
3964 r.dispatch(Cmd::ExecuteTool {
3965 turn,
3966 call_id,
3967 source,
3968 model_id: "ollama/test".to_string(),
3969 safety_mode: crate::runtime::SafetyMode::Ask,
3970 plan_file: None,
3971 plan_permissions: crate::app::PlanPermissions::default(),
3972 context_percent: None,
3973 intent: None,
3974 session_id: "sess-test".to_string(),
3975 message_index: 0,
3976 scratchpad: None,
3977 });
3978 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3979 .await
3980 .expect("some msg")
3981 .expect("channel alive");
3982 assert!(matches!(
3983 first,
3984 Msg::ToolStarted {
3985 turn: t,
3986 call_id: c,
3987 } if t == turn && c == call_id
3988 ));
3989 }
3990
3991 #[tokio::test]
3992 async fn cancel_scope_before_execute_tool_drops_pending_work() {
3993 let (mut r, _rx) = runner();
3994 let turn = TurnId(9);
3995 r.dispatch(Cmd::CallModel {
3996 turn,
3997 request: crate::domain::ChatRequest {
3998 model_id: "m".to_string(),
3999 messages: vec![],
4000 system_prompt: String::new(),
4001 instructions: None,
4002 reasoning: crate::models::ReasoningLevel::Medium,
4003 temperature: 0.7,
4004 max_tokens: 4096,
4005 tools: vec![],
4006
4007 ollama_num_ctx: None,
4008 ollama_allow_ram_offload: None,
4009 resolved_context_window: None,
4010 resolved_max_output: None,
4011 output_schema: None,
4012 suppress_auto_compact: false,
4013 suppressed_builtin_tools: Vec::new(),
4014 },
4015 });
4016 assert_eq!(r.scope_count(), 1);
4017
4018 r.dispatch(Cmd::CancelScope(turn));
4019 assert_eq!(r.scope_count(), 0);
4020 }
4021
4022 #[tokio::test]
4023 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
4024 let (mut r, _rx) = runner();
4030 let req = || crate::domain::ChatRequest {
4031 model_id: "test/m".to_string(),
4032 messages: vec![],
4033 system_prompt: String::new(),
4034 instructions: None,
4035 reasoning: crate::models::ReasoningLevel::Medium,
4036 temperature: 0.7,
4037 max_tokens: 4096,
4038 tools: vec![],
4039 ollama_num_ctx: None,
4040 ollama_allow_ram_offload: None,
4041 resolved_context_window: None,
4042 resolved_max_output: None,
4043 output_schema: None,
4044 suppress_auto_compact: false,
4045 suppressed_builtin_tools: Vec::new(),
4046 };
4047 let turn = TurnId(123);
4048
4049 r.dispatch(Cmd::CallModel {
4050 turn,
4051 request: req(),
4052 });
4053 assert_eq!(r.scope_count(), 1);
4054
4055 r.dispatch(Cmd::CancelScope(turn));
4057 assert_eq!(r.scope_count(), 0);
4058
4059 r.dispatch(Cmd::CallModel {
4061 turn,
4062 request: req(),
4063 });
4064 assert_eq!(
4065 r.scope_count(),
4066 0,
4067 "a cancelled turn must not be resurrected by a late scoped Cmd"
4068 );
4069
4070 r.dispatch(Cmd::CallModel {
4072 turn: TurnId(124),
4073 request: req(),
4074 });
4075 assert_eq!(
4076 r.scope_count(),
4077 1,
4078 "a fresh turn must still create its scope normally"
4079 );
4080 }
4081
4082 #[tokio::test]
4083 async fn shutdown_drains_pending_saves() {
4084 let (mut r, _rx) = runner();
4085 for _ in 0..5 {
4086 r.dispatch(Cmd::SaveConversation(
4087 crate::session::ConversationHistory::new(
4088 "/p".to_string(),
4089 "m".to_string(),
4090 chrono::Local::now(),
4091 ),
4092 ));
4093 }
4094 let start = std::time::Instant::now();
4096 r.shutdown().await;
4097 assert!(start.elapsed() < Duration::from_secs(2));
4098 }
4099
4100 fn persistence_fixture(
4101 root: &std::path::Path,
4102 archive_id: &str,
4103 ) -> (crate::session::ConversationHistory, PendingCompactionSave) {
4104 let now = chrono::Local::now();
4105 let mut full = crate::session::ConversationHistory::new(
4106 root.display().to_string(),
4107 "test/model".to_string(),
4108 now,
4109 );
4110 full.add_messages(&[crate::models::ChatMessage::user("raw history")], now);
4111 let mut compacted = full.clone();
4112 compacted.replace_messages(
4113 vec![crate::models::ChatMessage::user("compacted checkpoint")],
4114 now,
4115 );
4116 let archive = crate::domain::CompactionArchive {
4117 id: archive_id.to_string(),
4118 conversation_id: full.id.clone(),
4119 created_at: now,
4120 messages: full.messages().to_vec(),
4121 };
4122 let record = crate::domain::CompactionRecord {
4123 id: archive_id.to_string(),
4124 trigger: crate::domain::CompactionTrigger::Manual,
4125 created_at: now,
4126 before_tokens: 100,
4127 after_tokens: 20,
4128 archived_message_count: 1,
4129 preserved_message_count: 1,
4130 preserved_turn_count: 1,
4131 summary_tokens: 10,
4132 duration_secs: 0.1,
4133 review_status: crate::domain::CompactionReviewStatus::Reviewed,
4134 review_error: None,
4135 focus: None,
4136 archive_path: None,
4137 };
4138 (
4139 full,
4140 PendingCompactionSave {
4141 archive,
4142 record,
4143 conversation: compacted,
4144 task_id: None,
4145 },
4146 )
4147 }
4148
4149 #[test]
4150 fn persistence_orders_compaction_before_newer_conversation_save() {
4151 let root = std::env::temp_dir().join(format!(
4152 "mermaid-persistence-order-{}-{:?}",
4153 std::process::id(),
4154 std::thread::current().id()
4155 ));
4156 let _ = std::fs::remove_dir_all(&root);
4157 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
4158 let manager = crate::session::ConversationManager::new(&root).unwrap();
4159 manager.save_conversation(&full).unwrap();
4160
4161 let mut state = PersistenceState::new(root.clone());
4162 let (events, outcome) =
4163 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
4164 outcome.unwrap();
4165 assert_eq!(events.len(), 1);
4166 let mut newer = compaction.conversation;
4167 newer.add_messages(
4168 &[crate::models::ChatMessage::assistant("new assistant reply")],
4169 chrono::Local::now(),
4170 );
4171 let (_, outcome) = state.process(PersistenceJob::Conversation(Box::new(newer)));
4172 outcome.unwrap();
4173
4174 let loaded = crate::session::ConversationManager::new(&root)
4175 .unwrap()
4176 .load_conversation(&full.id)
4177 .unwrap();
4178 assert!(
4179 loaded
4180 .messages()
4181 .iter()
4182 .any(|message| message.content == "new assistant reply")
4183 );
4184 let _ = std::fs::remove_dir_all(root);
4185 }
4186
4187 #[test]
4188 fn failed_archive_blocks_later_stripped_conversation_save() {
4189 let root = std::env::temp_dir().join(format!(
4190 "mermaid-persistence-barrier-{}-{:?}",
4191 std::process::id(),
4192 std::thread::current().id()
4193 ));
4194 let _ = std::fs::remove_dir_all(&root);
4195 let (full, compaction) = persistence_fixture(&root, "../invalid");
4196 let manager = crate::session::ConversationManager::new(&root).unwrap();
4197 manager.save_conversation(&full).unwrap();
4198
4199 let mut state = PersistenceState::new(root.clone());
4200 assert!(
4201 state
4202 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
4203 .1
4204 .is_err()
4205 );
4206 assert!(
4207 state
4208 .process(PersistenceJob::Conversation(Box::new(
4209 compaction.conversation,
4210 )))
4211 .1
4212 .is_err()
4213 );
4214 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
4215
4216 let loaded = crate::session::ConversationManager::new(&root)
4217 .unwrap()
4218 .load_conversation(&full.id)
4219 .unwrap();
4220 assert_eq!(loaded.messages()[0].content, "raw history");
4221 let _ = std::fs::remove_dir_all(root);
4222 }
4223
4224 #[test]
4225 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
4226 let root = std::env::temp_dir().join(format!(
4227 "mermaid-persistence-queue-{}-{:?}",
4228 std::process::id(),
4229 std::thread::current().id()
4230 ));
4231 let _ = std::fs::remove_dir_all(&root);
4232 let (full, first) = persistence_fixture(&root, "../invalid");
4233 let mut second = first.clone();
4234 second.archive.id = "compact_second".to_string();
4235 second.record.id = "compact_second".to_string();
4236
4237 let mut state = PersistenceState::new(root.clone());
4238 assert!(
4239 state
4240 .process(PersistenceJob::Compaction(Box::new(first)))
4241 .1
4242 .is_err()
4243 );
4244 assert!(
4247 state
4248 .process(PersistenceJob::Compaction(Box::new(second)))
4249 .1
4250 .is_err()
4251 );
4252 let queued = state.blocked.get(&full.id).expect("barrier queue");
4253 assert_eq!(queued.len(), 2);
4254 assert_eq!(queued[0].archive.id, "../invalid");
4255 assert_eq!(queued[1].archive.id, "compact_second");
4256 let _ = std::fs::remove_dir_all(root);
4257 }
4258
4259 #[test]
4260 fn retry_all_blocked_attempts_every_conversation() {
4261 let root = std::env::temp_dir().join(format!(
4262 "mermaid-persistence-drain-{}-{:?}",
4263 std::process::id(),
4264 std::thread::current().id()
4265 ));
4266 let _ = std::fs::remove_dir_all(&root);
4267 let (bad_full, bad) = persistence_fixture(&root, "../invalid");
4268 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
4269 good_full.id = "20990101_000000_001".to_string();
4273 good.archive.conversation_id = good_full.id.clone();
4274 good.conversation.id = good_full.id.clone();
4275
4276 let mut state = PersistenceState::new(root.clone());
4277 state
4278 .blocked
4279 .entry(bad_full.id.clone())
4280 .or_default()
4281 .push_back(bad);
4282 state
4283 .blocked
4284 .entry(good_full.id.clone())
4285 .or_default()
4286 .push_back(good);
4287
4288 let (events, outcome) = state.retry_all_blocked();
4292 assert!(outcome.is_err());
4293 assert_eq!(events.len(), 1);
4294 assert_eq!(events[0].id, "compact_good");
4295 assert!(!state.blocked.contains_key(&good_full.id));
4296 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
4297 let loaded = crate::session::ConversationManager::new(&root)
4298 .unwrap()
4299 .load_conversation(&good_full.id)
4300 .unwrap();
4301 assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
4302 let _ = std::fs::remove_dir_all(root);
4303 }
4304
4305 #[test]
4306 fn partially_drained_barrier_reports_its_persisted_events() {
4307 let root = std::env::temp_dir().join(format!(
4308 "mermaid-persistence-partial-{}-{:?}",
4309 std::process::id(),
4310 std::thread::current().id()
4311 ));
4312 let _ = std::fs::remove_dir_all(&root);
4313 let (full, good) = persistence_fixture(&root, "compact_good");
4314 let mut bad = good.clone();
4315 bad.archive.id = "../invalid".to_string();
4316 bad.record.id = "../invalid".to_string();
4317
4318 let mut state = PersistenceState::new(root.clone());
4319 let queue = state.blocked.entry(full.id.clone()).or_default();
4320 queue.push_back(good);
4321 queue.push_back(bad);
4322
4323 let (events, outcome) = state.retry_blocked(&full.id);
4327 assert!(outcome.is_err());
4328 assert_eq!(events.len(), 1);
4329 assert_eq!(events[0].id, "compact_good");
4330 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
4331 let _ = std::fs::remove_dir_all(root);
4332 }
4333}