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