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