1mod middleware;
35mod turn_scope;
36
37use std::collections::HashMap;
38use std::path::PathBuf;
39use std::sync::Arc;
40use std::time::Instant;
41
42use tokio::sync::mpsc;
43
44use crate::app::Config;
45use crate::domain::{
46 Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
47};
48use crate::models::{ModelError, TokenUsage};
49use crate::providers::ctx::{ExecContext, StreamContext};
50use crate::providers::model::ModelProvider;
51use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
52
53pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
54pub use turn_scope::TurnScope;
55
56#[cfg(not(test))]
57const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
58#[cfg(test)]
59const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
60
61pub type MsgSender = mpsc::Sender<Msg>;
67
68pub const MSG_CHANNEL_CAPACITY: usize = 512;
74
75pub struct EffectRunner {
78 msg_tx: MsgSender,
79 scopes: HashMap<TurnId, TurnScope>,
85 detached: tokio::task::JoinSet<()>,
89 workdir: PathBuf,
93 providers: Option<Arc<ProviderFactory>>,
98 tools: Option<Arc<ToolRegistry>>,
101 task_id: Option<String>,
103 terminal_title_enabled: bool,
107 approval: Option<crate::providers::ApprovalBroker>,
111}
112
113impl EffectRunner {
114 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
116 Self {
117 msg_tx,
118 scopes: HashMap::new(),
119 detached: tokio::task::JoinSet::new(),
120 workdir,
121 providers: None,
122 tools: None,
123 task_id: None,
124 terminal_title_enabled: true,
125 approval: None,
126 }
127 }
128
129 pub fn with_interactive_approvals(mut self) -> Self {
133 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
134 self
135 }
136
137 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
140 self.task_id = task_id;
141 self
142 }
143
144 pub fn without_terminal_title(mut self) -> Self {
146 self.terminal_title_enabled = false;
147 self
148 }
149
150 pub fn with_bindings(
155 mut self,
156 providers: Arc<ProviderFactory>,
157 tools: Arc<ToolRegistry>,
158 ) -> Self {
159 self.providers = Some(providers);
160 self.tools = Some(tools);
161 self
162 }
163
164 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
168 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
169 (Self::new(tx, workdir), rx)
170 }
171
172 pub fn pair_with_bindings(
175 workdir: PathBuf,
176 config: Config,
177 tools: Arc<ToolRegistry>,
178 ) -> (Self, mpsc::Receiver<Msg>) {
179 let providers = Arc::new(ProviderFactory::new(config));
180 Self::pair_from(workdir, providers, tools)
181 }
182
183 pub fn pair_from(
188 workdir: PathBuf,
189 providers: Arc<ProviderFactory>,
190 tools: Arc<ToolRegistry>,
191 ) -> (Self, mpsc::Receiver<Msg>) {
192 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
193 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
194 }
195
196 pub fn pair_from_with_task(
197 workdir: PathBuf,
198 providers: Arc<ProviderFactory>,
199 tools: Arc<ToolRegistry>,
200 task_id: Option<String>,
201 ) -> (Self, mpsc::Receiver<Msg>) {
202 let (runner, rx) = Self::pair_from(workdir, providers, tools);
203 (runner.with_task_id(task_id), rx)
204 }
205
206 pub fn new_child(
211 msg_tx: MsgSender,
212 workdir: PathBuf,
213 providers: Arc<ProviderFactory>,
214 tools: Arc<ToolRegistry>,
215 ) -> Self {
216 Self::new(msg_tx, workdir).with_bindings(providers, tools)
217 }
218
219 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
223 self.scopes
224 .entry(turn)
225 .or_insert_with(|| TurnScope::new(turn))
226 }
227
228 fn drop_scope(&mut self, turn: TurnId) {
238 if let Some(mut scope) = self.scopes.remove(&turn) {
239 scope.cancel();
240 let tx = self.msg_tx.clone();
241 self.detached.spawn(async move {
242 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
243 .await
244 .is_err()
245 {
246 tracing::warn!(
247 turn = %turn,
248 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
249 "cancel drain timed out; aborting remaining scoped tasks"
250 );
251 }
252 let _ = tx.send(Msg::TurnCancelled(turn)).await;
253 });
254 }
255 }
256
257 pub fn scope_count(&self) -> usize {
260 self.scopes.len()
261 }
262
263 fn reap_empty_scopes(&mut self) {
272 self.scopes.retain(|_, scope| {
273 scope.drain_completed();
274 !scope.is_empty()
275 });
276 }
277
278 pub fn dispatch(&mut self, cmd: Cmd) {
282 self.reap_empty_scopes();
285 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
286
287 match cmd {
288 Cmd::CallModel { turn, mut request } => {
289 let tx = self.msg_tx.clone();
290 let providers = self.providers.clone();
291 if let Some(tools) = &self.tools {
298 let mut enriched = tools.describe_all();
299 enriched.append(&mut request.tools);
300 request.tools = enriched;
301 }
302 let _ = crate::runtime::run_plugin_hooks(
303 "prompt_submit",
304 &serde_json::json!({
305 "turn_id": turn.0,
306 "model_id": request.model_id.clone(),
307 "message_count": request.messages.len(),
308 "tool_count": request.tools.len(),
309 }),
310 );
311 let scope = self.scope_mut(turn);
312 let token = scope.token();
313 scope.spawn(async move {
314 dispatch_call_model(tx, providers, turn, request, token).await;
315 });
316 },
317 Cmd::CompactConversation { turn, mut request } => {
318 let tx = self.msg_tx.clone();
319 let providers = self.providers.clone();
320 if let Some(tools) = &self.tools {
321 let mut enriched = tools.describe_all();
322 enriched.append(&mut request.chat.tools);
323 request.chat.tools = enriched;
324 }
325 let scope = self.scope_mut(turn);
326 let token = scope.token();
327 scope.spawn(async move {
328 dispatch_compact_conversation(tx, providers, turn, request, token).await;
329 });
330 },
331 Cmd::ExecuteTool {
332 turn,
333 call_id,
334 source,
335 model_id,
336 safety_mode,
337 intent,
338 } => {
339 let tx = self.msg_tx.clone();
340 let tools = self.tools.clone();
341 let workdir = self.workdir.clone();
342 let config = self
347 .providers
348 .as_ref()
349 .map(|p| Arc::new(p.config().clone()))
350 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
351 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
356 if safety_mode == crate::runtime::SafetyMode::Auto {
357 self.providers.as_ref().map(|p| {
358 let model = config
359 .safety
360 .auto_classifier_model
361 .clone()
362 .unwrap_or_else(|| model_id.clone());
363 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
364 as Arc<dyn crate::providers::AutoClassifier>
365 })
366 } else {
367 None
368 };
369 let task_id = self.task_id.clone();
370 let approval = self.approval.clone();
371 let scope = self.scope_mut(turn);
372 let token = scope.token();
373 scope.spawn(async move {
374 dispatch_execute_tool(
375 tx,
376 tools,
377 workdir,
378 turn,
379 call_id,
380 source,
381 token,
382 config,
383 model_id,
384 task_id,
385 safety_mode,
386 intent,
387 classifier,
388 approval,
389 )
390 .await;
391 });
392 },
393 Cmd::ResolveApproval { call_id, decision } => {
394 if let Some(broker) = &self.approval {
397 broker.resolve(call_id, decision.into());
398 }
399 },
400 Cmd::CancelScope(turn) => {
401 self.drop_scope(turn);
402 },
403 Cmd::SaveConversation(history) => {
404 let tx = self.msg_tx.clone();
405 let workdir = self.workdir.clone();
406 self.detached.spawn(async move {
407 if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
408 && manager.save_conversation(&history).is_ok()
409 {
410 let _ = tx.send(Msg::SessionSaved).await;
411 } else {
412 tracing::warn!("SaveConversation: failed to write to disk");
413 }
414 });
415 },
416 Cmd::SaveCompactionArchive {
417 archive,
418 record,
419 conversation,
420 } => {
421 let tx = self.msg_tx.clone();
422 let workdir = self.workdir.clone();
423 let task_id = self.task_id.clone();
424 self.detached.spawn(async move {
425 let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
426 return;
427 };
428 match manager.save_compaction_archive(&archive) {
433 Ok(path) => {
434 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
435 let compaction_id = record.id.clone();
436 let conversation_id = archive.conversation_id.clone();
437 let _ =
438 store.compactions().create(crate::runtime::NewCompaction {
439 id: Some(record.id),
440 task_id: task_id.clone(),
441 session_id: Some(archive.conversation_id),
442 source_token_estimate: Some(record.before_tokens as i64),
443 summary_token_count: Some(record.summary_tokens as i64),
444 preserved_turns: Some(record.preserved_message_count as i64),
445 archive_path: Some(path.display().to_string()),
446 verification_status: Some("verified".to_string()),
447 });
448 let _ = crate::runtime::run_plugin_hooks(
449 "compaction",
450 &serde_json::json!({
451 "id": compaction_id,
452 "task_id": task_id.clone(),
453 "session_id": conversation_id,
454 "archive_path": path.display().to_string(),
455 }),
456 );
457 }
458 if manager.save_conversation(&conversation).is_ok() {
459 let _ = tx.send(Msg::SessionSaved).await;
460 } else {
461 tracing::warn!(
462 "SaveCompactionArchive: archive persisted but conversation save failed"
463 );
464 }
465 },
466 Err(err) => {
467 tracing::warn!(
468 error = %err,
469 "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
470 );
471 },
472 }
473 });
474 },
475 Cmd::SaveProcess(process) => {
476 let task_id = self.task_id.clone();
477 self.detached.spawn(async move {
478 let status = match process.status {
479 crate::domain::ManagedProcessStatus::Running => {
480 crate::runtime::ProcessStatus::Running
481 },
482 crate::domain::ManagedProcessStatus::Exited => {
483 crate::runtime::ProcessStatus::Exited
484 },
485 crate::domain::ManagedProcessStatus::Unknown => {
486 crate::runtime::ProcessStatus::Unknown
487 },
488 };
489 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
490 let _ = store.processes().upsert(crate::runtime::NewProcess {
491 id: Some(process.id),
492 task_id,
493 pid: process.pid,
494 command: process.command,
495 cwd: process.cwd,
496 log_path: Some(process.log_path),
497 detected_url: process.detected_url,
498 status,
499 health: None,
500 });
501 }
502 });
503 },
504 Cmd::PersistLastModel(model) => {
505 self.detached.spawn(async move {
506 let _ = crate::app::persist_last_model(&model);
507 });
508 },
509 Cmd::PersistReasoningFor { model_id, level } => {
510 self.detached.spawn(async move {
511 let _ = crate::app::persist_reasoning_for_model(&model_id, level);
512 });
513 },
514 Cmd::RefreshInstructions => {
515 let tx = self.msg_tx.clone();
516 let workdir = self.workdir.clone();
517 self.detached.spawn(async move {
518 let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
519 let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
520 });
521 },
522 Cmd::RefreshMemory => {
523 let tx = self.msg_tx.clone();
524 let workdir = self.workdir.clone();
525 self.detached.spawn(async move {
526 let cfg = crate::app::load_config().unwrap_or_default().memory;
527 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
528 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
529 });
530 },
531 Cmd::ListMemory => {
532 let tx = self.msg_tx.clone();
533 let workdir = self.workdir.clone();
534 self.detached.spawn(async move {
535 let cfg = crate::app::load_config().unwrap_or_default().memory;
536 let text = match crate::app::memory::load(&workdir, &cfg) {
537 Some(mem) => mem.index,
538 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(),
539 };
540 let _ = tx.send(Msg::RuntimeText(text)).await;
541 });
542 },
543 Cmd::RememberMemory { text } => {
544 let tx = self.msg_tx.clone();
545 let workdir = self.workdir.clone();
546 self.detached.spawn(async move {
547 let cfg = crate::app::load_config().unwrap_or_default().memory;
548 let name = memory_title_from_text(&text);
549 let (status, kind) = match crate::app::memory::write_memory(
550 &workdir,
551 crate::app::memory::MemoryScope::ProjectPrivate,
552 &name,
553 &text,
554 &[],
555 &text,
556 ) {
557 Ok(_) => (
558 format!("Remembered: {name}"),
559 crate::domain::StatusKind::Info,
560 ),
561 Err(e) => (
562 format!("Couldn't save memory: {e}"),
563 crate::domain::StatusKind::Error,
564 ),
565 };
566 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
567 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
568 let _ = tx
569 .send(Msg::TransientStatus {
570 text: status,
571 kind,
572 dismiss_ms: 3_000,
573 })
574 .await;
575 });
576 },
577 Cmd::ForgetMemory { id } => {
578 let tx = self.msg_tx.clone();
579 let workdir = self.workdir.clone();
580 self.detached.spawn(async move {
581 let cfg = crate::app::load_config().unwrap_or_default().memory;
582 let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
583 Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
584 Ok(None) => (
585 format!("No memory named '{id}'"),
586 crate::domain::StatusKind::Info,
587 ),
588 Err(e) => (
589 format!("Couldn't forget memory: {e}"),
590 crate::domain::StatusKind::Error,
591 ),
592 };
593 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
594 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
595 let _ = tx
596 .send(Msg::TransientStatus {
597 text: status,
598 kind,
599 dismiss_ms: 3_000,
600 })
601 .await;
602 });
603 },
604 Cmd::ConsolidateMemory { model_id } => {
605 let tx = self.msg_tx.clone();
606 let workdir = self.workdir.clone();
607 let providers = self.providers.clone();
608 self.detached.spawn(async move {
609 consolidate_memory(tx, providers, workdir, model_id).await;
610 });
611 },
612 Cmd::LoadConversation(id) => {
613 let tx = self.msg_tx.clone();
614 let workdir = self.workdir.clone();
615 self.detached.spawn(async move {
616 match crate::session::ConversationManager::new(&workdir) {
617 Ok(mgr) => match mgr.load_conversation(&id) {
618 Ok(history) => {
619 let _ = tx.send(Msg::ConversationLoaded(history)).await;
620 },
621 Err(e) => {
622 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
623 },
624 },
625 Err(e) => {
626 tracing::warn!(error = %e, "ConversationManager init failed");
627 },
628 }
629 });
630 },
631 Cmd::ListConversations => {
632 let tx = self.msg_tx.clone();
633 let workdir = self.workdir.clone();
634 self.detached.spawn(async move {
635 let summaries = match crate::session::ConversationManager::new(&workdir) {
636 Ok(mgr) => mgr
637 .list_conversations()
638 .unwrap_or_default()
639 .into_iter()
640 .map(|h| crate::domain::ConversationSummary {
641 id: h.id.clone(),
642 title: h.title.clone(),
643 message_count: h.messages.len(),
644 updated_at: h.updated_at.to_rfc3339(),
645 })
646 .collect(),
647 Err(_) => Vec::new(),
648 };
649 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
650 });
651 },
652 Cmd::ListRuntimeTasks { limit } => {
653 let tx = self.msg_tx.clone();
654 self.detached.spawn(async move {
655 let tasks = crate::runtime::RuntimeClient::auto()
656 .list_tasks(limit)
657 .map(|read| read.value)
658 .unwrap_or_default();
659 let _ = tx.send(Msg::RuntimeTasksListed(tasks)).await;
660 });
661 },
662 Cmd::LoadRuntimeTask { id } => {
663 let tx = self.msg_tx.clone();
664 self.detached.spawn(async move {
665 let (task, events) = crate::runtime::RuntimeClient::auto()
666 .task_detail(&id)
667 .map(|read| (Some(read.value.task), read.value.events))
668 .unwrap_or((None, Vec::new()));
669 let _ = tx.send(Msg::RuntimeTaskLoaded { task, events }).await;
670 });
671 },
672 Cmd::ListRuntimeProcesses { limit } => {
673 let tx = self.msg_tx.clone();
674 self.detached.spawn(async move {
675 let processes = crate::runtime::RuntimeClient::auto()
676 .list_processes(limit)
677 .map(|read| read.value)
678 .unwrap_or_default();
679 let _ = tx.send(Msg::RuntimeProcessesListed(processes)).await;
680 });
681 },
682 Cmd::ShowRuntimeProcessLogs { id } => {
683 let tx = self.msg_tx.clone();
684 self.detached.spawn(async move {
685 let text = crate::runtime::RuntimeClient::auto()
686 .process_log(&id, None)
687 .map(|log| format!("Process log {}\n\n{}", id, log.content))
688 .unwrap_or_else(|err| format!("Process log error: {}", err));
689 let _ = tx.send(Msg::RuntimeText(text)).await;
690 });
691 },
692 Cmd::StopRuntimeProcess { id } => {
693 let tx = self.msg_tx.clone();
694 self.detached.spawn(async move {
695 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
696 Ok(response) => Msg::TransientStatus {
697 text: format!("Stopped process {} (pid {})", id, response.item.pid),
698 kind: crate::domain::StatusKind::Info,
699 dismiss_ms: 3_000,
700 },
701 Err(err) => Msg::TransientStatus {
702 text: format!("Process stop failed: {}", err),
703 kind: crate::domain::StatusKind::Warn,
704 dismiss_ms: 5_000,
705 },
706 };
707 let _ = tx.send(msg).await;
708 });
709 },
710 Cmd::RestartRuntimeProcess { id } => {
711 let tx = self.msg_tx.clone();
712 self.detached.spawn(async move {
713 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
714 Ok(response) => Msg::TransientStatus {
715 text: format!("Restarted process {} (pid {})", id, response.item.pid),
716 kind: crate::domain::StatusKind::Info,
717 dismiss_ms: 3_000,
718 },
719 Err(err) => Msg::TransientStatus {
720 text: format!("Process restart failed: {}", err),
721 kind: crate::domain::StatusKind::Warn,
722 dismiss_ms: 5_000,
723 },
724 };
725 let _ = tx.send(msg).await;
726 });
727 },
728 Cmd::OpenRuntimeTarget { target } => {
729 self.detached.spawn(async move {
730 let resolved = crate::runtime::RuntimeService::open_default()
731 .and_then(|service| service.resolve_open_target(&target))
732 .unwrap_or(target);
733 crate::utils::open_file(resolved);
734 });
735 },
736 Cmd::ShowRuntimePorts => {
737 let tx = self.msg_tx.clone();
738 self.detached.spawn(async move {
739 let text = crate::runtime::RuntimeClient::auto()
740 .ports()
741 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
742 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
743 let _ = tx.send(Msg::RuntimeText(text)).await;
744 });
745 },
746 Cmd::ListRuntimeApprovals => {
747 let tx = self.msg_tx.clone();
748 self.detached.spawn(async move {
749 let approvals = crate::runtime::RuntimeClient::auto()
750 .list_approvals()
751 .map(|read| read.value)
752 .unwrap_or_default();
753 let _ = tx.send(Msg::RuntimeApprovalsListed(approvals)).await;
754 });
755 },
756 Cmd::DecideRuntimeApproval { id, decision } => {
757 let tx = self.msg_tx.clone();
758 self.detached.spawn(async move {
759 let result = if decision == "approved" {
760 crate::runtime::RuntimeClient::auto().approve(&id)
761 } else {
762 crate::runtime::RuntimeClient::auto().deny(&id)
763 };
764 let msg = match result {
765 Ok(result) => Msg::TransientStatus {
766 text: if result.replayed {
767 format!("Approval {} {}: {}", id, decision, result.summary)
768 } else {
769 format!("Approval {} {}", id, decision)
770 },
771 kind: crate::domain::StatusKind::Info,
772 dismiss_ms: 3_000,
773 },
774 Err(err) => Msg::TransientStatus {
775 text: format!("Approval update failed: {}", err),
776 kind: crate::domain::StatusKind::Warn,
777 dismiss_ms: 5_000,
778 },
779 };
780 let _ = tx.send(msg).await;
781 });
782 },
783 Cmd::ListRuntimeCheckpoints { limit } => {
784 let tx = self.msg_tx.clone();
785 self.detached.spawn(async move {
786 let checkpoints = crate::runtime::RuntimeClient::auto()
787 .list_checkpoints(limit)
788 .map(|read| read.value)
789 .unwrap_or_default();
790 let _ = tx.send(Msg::RuntimeCheckpointsListed(checkpoints)).await;
791 });
792 },
793 Cmd::ListRuntimePlugins => {
794 let tx = self.msg_tx.clone();
795 self.detached.spawn(async move {
796 let plugins = crate::runtime::RuntimeClient::auto()
797 .list_plugins()
798 .map(|read| read.value)
799 .unwrap_or_default();
800 let _ = tx.send(Msg::RuntimePluginsListed(plugins)).await;
801 });
802 },
803 Cmd::UpdateRuntimeTaskStatus {
804 id,
805 status,
806 final_report,
807 } => {
808 let tx = self.msg_tx.clone();
809 self.detached.spawn(async move {
810 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
811 store
812 .tasks()
813 .update_status(&id, status, final_report.as_deref())
814 }) {
815 Ok(()) => Msg::TransientStatus {
816 text: format!("Task {} -> {}", id, status),
817 kind: crate::domain::StatusKind::Info,
818 dismiss_ms: 3_000,
819 },
820 Err(err) => Msg::TransientStatus {
821 text: format!("Task update failed: {}", err),
822 kind: crate::domain::StatusKind::Warn,
823 dismiss_ms: 4_000,
824 },
825 };
826 let _ = tx.send(msg).await;
827 });
828 },
829 Cmd::CreateRuntimeCheckpoint { paths } => {
830 let tx = self.msg_tx.clone();
831 let workdir = self.workdir.clone();
832 self.detached.spawn(async move {
833 let pending_action = Some(serde_json::json!({
834 "source": "tui",
835 "command": "checkpoint",
836 }));
837 let msg =
838 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
839 Ok(manifest) => Msg::TransientStatus {
840 text: format!(
841 "Checkpoint {} created for {} path(s)",
842 manifest.id,
843 manifest.files.len()
844 ),
845 kind: crate::domain::StatusKind::Info,
846 dismiss_ms: 4_000,
847 },
848 Err(err) => Msg::TransientStatus {
849 text: format!("Checkpoint failed: {}", err),
850 kind: crate::domain::StatusKind::Warn,
851 dismiss_ms: 5_000,
852 },
853 };
854 let _ = tx.send(msg).await;
855 });
856 },
857 Cmd::RestoreRuntimeCheckpoint { id } => {
858 let tx = self.msg_tx.clone();
859 self.detached.spawn(async move {
860 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
861 Ok(result) => Msg::TransientStatus {
862 text: format!(
863 "Restored checkpoint {} ({} file(s)){}",
864 result.checkpoint.id,
865 result.checkpoint.files.len(),
866 if result.checkpoint.pending_action.is_some() {
867 "; pending action available in checkpoint manifest"
868 } else {
869 ""
870 }
871 ),
872 kind: crate::domain::StatusKind::Info,
873 dismiss_ms: 4_000,
874 },
875 Err(err) => Msg::TransientStatus {
876 text: format!("Restore failed: {}", err),
877 kind: crate::domain::StatusKind::Warn,
878 dismiss_ms: 5_000,
879 },
880 };
881 let _ = tx.send(msg).await;
882 });
883 },
884 Cmd::ShowRuntimeModelInfo { model } => {
885 let tx = self.msg_tx.clone();
886 self.detached.spawn(async move {
887 let text = runtime_model_info_text(&model);
888 let _ = tx.send(Msg::RuntimeText(text)).await;
889 });
890 },
891 Cmd::InitMcpServers(configs) => {
892 let tx = self.msg_tx.clone();
893 self.detached.spawn(async move {
894 if configs.is_empty() {
895 return;
896 }
897 crate::mcp::manager_ref::mark_init_started();
898 let manager =
899 std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
900 for (name, _cfg) in configs.iter() {
904 let server_tools: Vec<crate::domain::McpToolSpec> = manager
905 .get_all_tools()
906 .iter()
907 .filter(|(server, _)| server == name)
908 .map(|(_, def)| crate::domain::McpToolSpec {
909 name: def.name.clone(),
910 description: def.description.clone(),
911 input_schema: def.input_schema.clone(),
912 })
913 .collect();
914 let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
915 let _ = tx.send(msg).await;
916 }
917 crate::mcp::manager_ref::set_manager(manager);
918 crate::mcp::manager_ref::mark_init_complete();
919 });
920 },
921 Cmd::StopMcpServer { name } => {
922 let tx = self.msg_tx.clone();
923 self.detached.spawn(async move {
924 let _ = tx.send(Msg::McpServerStopped { name }).await;
925 });
926 },
927 Cmd::PullOllamaModel { model } => {
928 let tx = self.msg_tx.clone();
929 self.detached.spawn(async move {
930 dispatch_pull_ollama_model(tx, model).await;
931 });
932 },
933 Cmd::OpenInSystem(path) => {
934 self.detached.spawn(async move {
935 let _ = tokio::task::spawn_blocking(move || {
936 crate::utils::open_file(&path);
937 })
938 .await;
939 });
940 },
941 Cmd::DismissStatusAfter { ms } => {
942 let tx = self.msg_tx.clone();
943 self.detached.spawn(async move {
944 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
945 let _ = tx.send(Msg::StatusDismiss).await;
946 });
947 },
948 Cmd::WriteImageToTemp {
949 path,
950 bytes,
951 format: _,
952 } => {
953 self.detached.spawn(async move {
954 if let Err(e) = tokio::fs::write(&path, &bytes).await {
955 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
956 }
957 });
958 },
959 Cmd::ReadClipboard => {
960 let tx = self.msg_tx.clone();
961 self.detached.spawn(async move {
962 dispatch_read_clipboard(tx).await;
963 });
964 },
965 Cmd::CopyToClipboard(text) => {
966 let tx = self.msg_tx.clone();
967 self.detached.spawn(async move {
968 dispatch_copy_to_clipboard(text, tx).await;
969 });
970 },
971 Cmd::Exit => {
972 },
977 Cmd::SetTerminalTitle(title) => {
978 if !self.terminal_title_enabled {
979 return;
980 }
981 self.detached.spawn(async move {
982 use std::io::Write;
983 let seq = format!("\x1b]2;{}\x07", title);
984 let mut stdout = std::io::stdout();
985 let _ = stdout.write_all(seq.as_bytes());
986 let _ = stdout.flush();
987 });
988 },
989 }
990 }
991
992 pub async fn shutdown(mut self) {
996 for (id, scope) in self.scopes.iter() {
997 tracing::debug!(turn = %id, "shutdown: cancelling scope");
998 scope.cancel();
999 }
1000
1001 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1003
1004 let drain = async {
1005 for (_, mut scope) in self.scopes.drain() {
1006 scope.drain().await;
1007 }
1008 while let Some(result) = self.detached.join_next().await {
1009 if let Err(e) = result
1010 && !e.is_cancelled()
1011 {
1012 tracing::warn!(error = %e, "shutdown: detached task panic");
1013 }
1014 }
1015 };
1016
1017 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1018 }
1019
1020 #[doc(hidden)]
1023 pub fn msg_sender(&self) -> MsgSender {
1024 self.msg_tx.clone()
1025 }
1026}
1027
1028async fn dispatch_call_model(
1033 msg_tx: MsgSender,
1034 providers: Option<Arc<ProviderFactory>>,
1035 turn: TurnId,
1036 mut request: crate::domain::ChatRequest,
1037 token: tokio_util::sync::CancellationToken,
1038) {
1039 use crate::models::UserFacingError;
1040
1041 let Some(factory) = providers else {
1042 let error = UserFacingError {
1043 summary: "not wired".to_string(),
1044 message: "EffectRunner has no ProviderFactory bound".to_string(),
1045 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1046 category: crate::models::ErrorCategory::Internal,
1047 recoverable: false,
1048 };
1049 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1050 return;
1051 };
1052
1053 let provider = match factory.resolve(&request.model_id).await {
1055 Ok(p) => p,
1056 Err(e) => {
1057 let error = classify_error_for_ui(&e);
1058 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1059 return;
1060 },
1061 };
1062 record_provider_capabilities(&request.model_id, provider.capabilities());
1063 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1064 let _ = msg_tx
1065 .send(Msg::TransientStatus {
1066 text: format!(
1067 "{} does not advertise tool support; Mermaid will send the turn without tools",
1068 request.model_id
1069 ),
1070 kind: crate::domain::StatusKind::Warn,
1071 dismiss_ms: 6_000,
1072 })
1073 .await;
1074 request.tools.clear();
1075 }
1076
1077 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1078 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1079 });
1080 let context_snapshot =
1081 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1082 let _ = msg_tx
1083 .send(Msg::ContextUsageEstimated {
1084 turn,
1085 snapshot: context_snapshot.clone(),
1086 })
1087 .await;
1088
1089 let policy = CompactionPolicy::default();
1090 let mut compacted_before_stream = false;
1091 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1092 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1093 match run_compaction(
1094 Arc::clone(&provider),
1095 turn,
1096 compaction,
1097 context_snapshot.clone(),
1098 max_context_tokens,
1099 token.clone(),
1100 )
1101 .await
1102 {
1103 Ok(result) => {
1104 request.messages = result.replacement_messages.clone();
1105 compacted_before_stream = true;
1106 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1107 },
1108 Err(err) => {
1109 let hard_limit =
1110 crate::domain::context_exceeds_hard_limit(&context_snapshot, &request, policy);
1111 let _ = msg_tx
1112 .send(Msg::CompactionFailed {
1113 turn,
1114 trigger: CompactionTrigger::AutoThreshold,
1115 message: err.to_string(),
1116 kind: if hard_limit {
1117 crate::domain::StatusKind::Error
1118 } else {
1119 crate::domain::StatusKind::Warn
1120 },
1121 })
1122 .await;
1123 if hard_limit {
1124 let error = UserFacingError {
1125 summary: "Context too large".to_string(),
1126 message: format!(
1127 "The next request needs {} tokens before response reserve, and automatic compaction failed: {}",
1128 context_snapshot.used_tokens, err
1129 ),
1130 suggestion: "Run /compact with focus instructions, or /clear to start a fresh session.".to_string(),
1131 category: crate::models::ErrorCategory::Config,
1132 recoverable: true,
1133 };
1134 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1135 return;
1136 }
1137 },
1138 }
1139 }
1140
1141 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1144 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1145
1146 let relay_tx = msg_tx.clone();
1149 let relay = tokio::spawn(async move {
1150 while let Some(event) = stream_rx.recv().await {
1151 let msg = match event {
1152 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1153 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1154 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1155 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1157 usage,
1158 thinking_signature,
1159 } => Msg::StreamDone {
1160 turn,
1161 usage,
1162 thinking_signature,
1163 },
1164 };
1165 if relay_tx.send(msg).await.is_err() {
1166 break;
1167 }
1168 }
1169 });
1170
1171 match provider.chat(request.clone(), ctx).await {
1180 Ok(_final_response) => {
1181 },
1183 Err(crate::models::ModelError::Cancelled) => {
1184 },
1186 Err(e) => {
1187 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1188 if retry_context_limit {
1189 let latest_snapshot =
1190 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1191 let compaction =
1192 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1193 match run_compaction(
1194 Arc::clone(&provider),
1195 turn,
1196 compaction,
1197 latest_snapshot,
1198 max_context_tokens,
1199 token.clone(),
1200 )
1201 .await
1202 {
1203 Ok(result) => {
1204 let mut retry_request = request;
1205 retry_request.messages = result.replacement_messages.clone();
1206 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1207 let _ = relay.await;
1208 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1209 .await;
1210 return;
1211 },
1212 Err(compact_err) => {
1213 let _ = msg_tx
1214 .send(Msg::CompactionFailed {
1215 turn,
1216 trigger: CompactionTrigger::ContextLimitRetry,
1217 message: compact_err.to_string(),
1218 kind: crate::domain::StatusKind::Error,
1219 })
1220 .await;
1221 },
1222 }
1223 }
1224 let error = classify_error_for_ui(&e);
1225 run_provider_error_hook(&request.model_id, &error);
1226 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1227 },
1228 }
1229
1230 let _ = relay.await;
1231}
1232
1233async fn dispatch_provider_stream(
1234 msg_tx: MsgSender,
1235 provider: Arc<dyn ModelProvider>,
1236 turn: TurnId,
1237 request: crate::domain::ChatRequest,
1238 token: tokio_util::sync::CancellationToken,
1239) {
1240 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1241 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1242 let relay_tx = msg_tx.clone();
1243 let relay = tokio::spawn(async move {
1244 while let Some(event) = stream_rx.recv().await {
1245 let msg = match event {
1246 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1247 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1248 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1249 StreamEvent::ThinkingSignature(_) => continue,
1250 StreamEvent::Done {
1251 usage,
1252 thinking_signature,
1253 } => Msg::StreamDone {
1254 turn,
1255 usage,
1256 thinking_signature,
1257 },
1258 };
1259 if relay_tx.send(msg).await.is_err() {
1260 break;
1261 }
1262 }
1263 });
1264
1265 let model_id = request.model_id.clone();
1266 match provider.chat(request, ctx).await {
1267 Ok(_) | Err(ModelError::Cancelled) => {},
1268 Err(e) => {
1269 let error = classify_error_for_ui(&e);
1270 run_provider_error_hook(&model_id, &error);
1271 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1272 },
1273 }
1274
1275 let _ = relay.await;
1276}
1277
1278fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1279 let _ = crate::runtime::run_plugin_hooks(
1280 "provider_error",
1281 &serde_json::json!({
1282 "model_id": model_id,
1283 "summary": &error.summary,
1284 "message": &error.message,
1285 "category": format!("{:?}", error.category),
1286 "recoverable": error.recoverable,
1287 }),
1288 );
1289}
1290
1291fn memory_title_from_text(text: &str) -> String {
1295 let first = text
1296 .lines()
1297 .find(|l| !l.trim().is_empty())
1298 .unwrap_or("memory")
1299 .trim();
1300 let title: String = first
1301 .split_whitespace()
1302 .take(8)
1303 .collect::<Vec<_>>()
1304 .join(" ")
1305 .chars()
1306 .take(60)
1307 .collect();
1308 if title.trim().is_empty() {
1309 "memory".to_string()
1310 } else {
1311 title
1312 }
1313}
1314
1315const 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.";
1316
1317#[derive(Debug)]
1318struct PrunePlan {
1319 prune: Vec<String>,
1320 reason: String,
1321}
1322
1323fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1326 let start = text.find('{')?;
1327 let end = text.rfind('}')?;
1328 if end < start {
1329 return None;
1330 }
1331 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1332 let prune = json
1333 .get("prune")?
1334 .as_array()?
1335 .iter()
1336 .filter_map(|v| v.as_str().map(str::to_string))
1337 .collect();
1338 let reason = json
1339 .get("reason")
1340 .and_then(|v| v.as_str())
1341 .unwrap_or("")
1342 .to_string();
1343 Some(PrunePlan { prune, reason })
1344}
1345
1346async fn consolidate_memory(
1350 tx: MsgSender,
1351 providers: Option<Arc<ProviderFactory>>,
1352 workdir: std::path::PathBuf,
1353 model_id: String,
1354) {
1355 let items = crate::app::memory::entries_with_bodies(&workdir);
1356 if items.len() < 2 {
1357 let _ = tx
1358 .send(Msg::RuntimeText(format!(
1359 "Nothing to consolidate — {} memor{} saved.",
1360 items.len(),
1361 if items.len() == 1 { "y" } else { "ies" }
1362 )))
1363 .await;
1364 return;
1365 }
1366 let Some(factory) = providers else {
1367 let _ = tx
1368 .send(Msg::RuntimeText(
1369 "Memory consolidation needs a model provider, which isn't bound in this session."
1370 .to_string(),
1371 ))
1372 .await;
1373 return;
1374 };
1375
1376 let mut listing = String::new();
1377 for (entry, body) in &items {
1378 let id = entry
1379 .path
1380 .file_stem()
1381 .and_then(|s| s.to_str())
1382 .unwrap_or(entry.name.as_str());
1383 listing.push_str(&format!(
1384 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
1385 entry.scope.as_str(),
1386 entry.description,
1387 body.replace('\n', " ").trim(),
1388 ));
1389 }
1390 let user = format!(
1391 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1392 items.len(),
1393 listing
1394 );
1395 let request = crate::domain::ChatRequest {
1396 model_id: model_id.clone(),
1397 messages: vec![crate::models::ChatMessage::user(user)],
1398 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1399 instructions: None,
1400 reasoning: crate::models::ReasoningLevel::None,
1401 temperature: 0.0,
1402 max_tokens: 1024,
1403 tools: Vec::new(),
1404 };
1405
1406 let provider = match factory.resolve(&model_id).await {
1407 Ok(p) => p,
1408 Err(e) => {
1409 let _ = tx
1410 .send(Msg::RuntimeText(format!(
1411 "Memory consolidation failed: {e}"
1412 )))
1413 .await;
1414 return;
1415 },
1416 };
1417 let token = tokio_util::sync::CancellationToken::new();
1418 let text =
1419 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1420 Ok((t, _)) => t,
1421 Err(e) => {
1422 let _ = tx
1423 .send(Msg::RuntimeText(format!(
1424 "Memory consolidation failed: {e}"
1425 )))
1426 .await;
1427 return;
1428 },
1429 };
1430
1431 let Some(plan) = parse_prune_plan(&text) else {
1432 let _ = tx
1433 .send(Msg::RuntimeText(
1434 "Memory consolidation: couldn't parse the model's plan; nothing changed."
1435 .to_string(),
1436 ))
1437 .await;
1438 return;
1439 };
1440 if plan.prune.is_empty() {
1441 let reason = if plan.reason.is_empty() {
1442 String::new()
1443 } else {
1444 format!(" {}", plan.reason)
1445 };
1446 let _ = tx
1447 .send(Msg::RuntimeText(format!(
1448 "Memory consolidation: nothing to prune.{reason}"
1449 )))
1450 .await;
1451 return;
1452 }
1453
1454 let paths: Vec<std::path::PathBuf> = plan
1456 .prune
1457 .iter()
1458 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1459 .collect();
1460 if !paths.is_empty() {
1461 let _ = crate::runtime::create_checkpoint(
1462 &workdir,
1463 &paths,
1464 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1465 );
1466 }
1467
1468 let mut pruned = Vec::new();
1469 for id in &plan.prune {
1470 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1471 pruned.push(id.clone());
1472 }
1473 }
1474
1475 let cfg = crate::app::load_config().unwrap_or_default().memory;
1476 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1477 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1478
1479 let report = if pruned.is_empty() {
1480 "Memory consolidation: the model named facts to prune, but none matched existing memories."
1481 .to_string()
1482 } else {
1483 format!(
1484 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1485 pruned.len(),
1486 if pruned.len() == 1 { "" } else { "s" },
1487 pruned.join(", "),
1488 if plan.reason.is_empty() {
1489 String::new()
1490 } else {
1491 format!(" Reason: {}.", plan.reason)
1492 },
1493 )
1494 };
1495 let _ = tx.send(Msg::RuntimeText(report)).await;
1496}
1497
1498async fn dispatch_compact_conversation(
1499 msg_tx: MsgSender,
1500 providers: Option<Arc<ProviderFactory>>,
1501 turn: TurnId,
1502 request: CompactionRequest,
1503 token: tokio_util::sync::CancellationToken,
1504) {
1505 let Some(factory) = providers else {
1506 let _ = msg_tx
1507 .send(Msg::CompactionFailed {
1508 turn,
1509 trigger: request.trigger,
1510 message: "EffectRunner has no ProviderFactory bound".to_string(),
1511 kind: crate::domain::StatusKind::Error,
1512 })
1513 .await;
1514 return;
1515 };
1516
1517 let provider = match factory.resolve(&request.chat.model_id).await {
1518 Ok(provider) => provider,
1519 Err(err) => {
1520 let _ = msg_tx
1521 .send(Msg::CompactionFailed {
1522 turn,
1523 trigger: request.trigger,
1524 message: err.to_string(),
1525 kind: crate::domain::StatusKind::Error,
1526 })
1527 .await;
1528 return;
1529 },
1530 };
1531
1532 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1533 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1534 });
1535 let before_snapshot =
1536 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1537
1538 let trigger = request.trigger;
1539 match run_compaction(
1540 provider,
1541 turn,
1542 request,
1543 before_snapshot,
1544 max_context_tokens,
1545 token,
1546 )
1547 .await
1548 {
1549 Ok(result) => {
1550 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1551 },
1552 Err(err) => {
1553 let _ = msg_tx
1554 .send(Msg::CompactionFailed {
1555 turn,
1556 trigger,
1557 message: err.to_string(),
1558 kind: crate::domain::StatusKind::Error,
1559 })
1560 .await;
1561 },
1562 }
1563}
1564
1565async fn run_compaction(
1566 provider: Arc<dyn ModelProvider>,
1567 turn: TurnId,
1568 request: CompactionRequest,
1569 before_snapshot: crate::domain::ContextUsageSnapshot,
1570 max_context_tokens: Option<usize>,
1571 token: tokio_util::sync::CancellationToken,
1572) -> Result<CompactionResult, ModelError> {
1573 let started = Instant::now();
1574 let prepared = crate::domain::prepare_compaction(&request, max_context_tokens)
1575 .map_err(|skip| ModelError::InvalidRequest(skip.to_string()))?;
1576
1577 let summary_request = crate::domain::build_summary_request(
1578 &request.chat,
1579 &prepared,
1580 request.instructions.as_deref(),
1581 request.policy,
1582 );
1583 let (draft, draft_usage) =
1584 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1585 .await?;
1586 let draft_summary = crate::domain::normalize_summary(&draft);
1587 if draft_summary.trim().is_empty() {
1588 return Err(ModelError::InvalidRequest(
1589 "compaction produced an empty summary".to_string(),
1590 ));
1591 }
1592
1593 let verify_request = crate::domain::build_verification_request(
1594 &request.chat,
1595 &prepared,
1596 &draft_summary,
1597 request.instructions.as_deref(),
1598 request.policy,
1599 );
1600 let (final_summary, verify_usage, verified, verification_error) =
1601 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1602 Ok((verified_text, verify_usage)) => {
1603 let verified_summary = crate::domain::normalize_summary(&verified_text);
1604 if verified_summary.trim().is_empty() {
1605 (
1606 draft_summary,
1607 verify_usage,
1608 false,
1609 Some("verification returned an empty summary".to_string()),
1610 )
1611 } else {
1612 (verified_summary, verify_usage, true, None)
1613 }
1614 },
1615 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1616 Err(err) => (draft_summary, None, false, Some(err.to_string())),
1617 };
1618
1619 let id = format!(
1620 "compact_{}",
1621 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1622 );
1623 let mut record = crate::domain::CompactionRecord {
1624 id,
1625 trigger: request.trigger,
1626 created_at: chrono::Local::now(),
1627 before_tokens: before_snapshot.used_tokens,
1628 after_tokens: 0,
1629 archived_message_count: prepared.archived_messages.len(),
1630 preserved_message_count: prepared.preserved_messages.len(),
1631 summary_tokens: final_summary.len().div_ceil(4),
1632 duration_secs: started.elapsed().as_secs_f64(),
1633 verified,
1634 verification_error,
1635 focus: request.instructions.clone(),
1636 archive_path: None,
1637 };
1638
1639 let mut replacement =
1640 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1641 let mut compacted_request = request.chat.clone();
1642 compacted_request.messages = replacement.clone();
1643 let mut after_snapshot =
1644 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1645 record.after_tokens = after_snapshot.used_tokens;
1646 record.duration_secs = started.elapsed().as_secs_f64();
1647 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1648 compacted_request.messages = replacement.clone();
1649 after_snapshot =
1650 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1651 record.after_tokens = after_snapshot.used_tokens;
1652
1653 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1654 return Err(ModelError::InvalidRequest(format!(
1655 "compaction did not reduce context ({} -> {} tokens)",
1656 before_snapshot.used_tokens, after_snapshot.used_tokens
1657 )));
1658 }
1659
1660 if crate::domain::context_exceeds_hard_limit(
1661 &after_snapshot,
1662 &compacted_request,
1663 request.policy,
1664 ) {
1665 return Err(ModelError::InvalidRequest(format!(
1666 "compacted context still exceeds response reserve ({} tokens used)",
1667 after_snapshot.used_tokens
1668 )));
1669 }
1670
1671 Ok(CompactionResult {
1672 record,
1673 replacement_messages: replacement,
1674 archived_messages: prepared.archived_messages,
1675 before_snapshot,
1676 after_snapshot,
1677 usage: crate::domain::combine_usage(draft_usage, verify_usage),
1678 })
1679}
1680
1681async fn collect_compaction_text(
1682 provider: Arc<dyn ModelProvider>,
1683 turn: TurnId,
1684 request: crate::domain::ChatRequest,
1685 token: tokio_util::sync::CancellationToken,
1686) -> Result<(String, Option<TokenUsage>), ModelError> {
1687 crate::providers::model::collect_text(provider, turn, request, token).await
1690}
1691
1692fn record_provider_capabilities(
1693 model_id: &str,
1694 caps: &crate::providers::capabilities::Capabilities,
1695) {
1696 let (provider, model) = split_model_id(model_id);
1697 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1698 for (key, value) in [
1699 ("tools_support", caps.supports_tools.to_string()),
1700 ("vision_support", caps.supports_vision.to_string()),
1701 (
1702 "context_limit",
1703 caps.max_context_tokens
1704 .map(|v| v.to_string())
1705 .unwrap_or_else(|| "unknown".to_string()),
1706 ),
1707 (
1708 "reasoning_parameter_shape",
1709 format!("{:?}", caps.supports_reasoning),
1710 ),
1711 (
1712 "streaming_usage_available",
1713 "provider_dependent".to_string(),
1714 ),
1715 ("token_usage_field_shape", "normalized".to_string()),
1716 ] {
1717 let _ = store
1718 .provider_probes()
1719 .upsert(crate::runtime::NewProviderProbe {
1720 provider: provider.clone(),
1721 model_id: model.clone(),
1722 capability_key: key.to_string(),
1723 capability_value: value,
1724 confidence: "verified".to_string(),
1725 error: None,
1726 });
1727 }
1728 }
1729}
1730
1731fn split_model_id(model_id: &str) -> (String, String) {
1732 match model_id.split_once('/') {
1733 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
1734 (provider.to_ascii_lowercase(), model.to_string())
1735 },
1736 _ => ("ollama".to_string(), model_id.to_string()),
1737 }
1738}
1739
1740fn is_context_limit_error(error: &ModelError) -> bool {
1741 let text = error.to_string().to_lowercase();
1742 text.contains("context")
1743 && (text.contains("too large")
1744 || text.contains("exceed")
1745 || text.contains("maximum")
1746 || text.contains("token"))
1747}
1748
1749#[allow(clippy::too_many_arguments)]
1751async fn dispatch_execute_tool(
1752 msg_tx: MsgSender,
1753 tools: Option<Arc<ToolRegistry>>,
1754 workdir: PathBuf,
1755 turn: TurnId,
1756 call_id: crate::domain::ToolCallId,
1757 source: crate::models::tool_call::ToolCall,
1758 token: tokio_util::sync::CancellationToken,
1759 config: Arc<crate::app::Config>,
1760 model_id: String,
1761 task_id: Option<String>,
1762 safety_mode: crate::runtime::SafetyMode,
1763 intent: Option<String>,
1764 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
1765 approval: Option<crate::providers::ApprovalBroker>,
1766) {
1767 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
1768
1769 let Some(registry) = tools else {
1770 let _ = msg_tx
1771 .send(Msg::ToolFinished {
1772 turn,
1773 call_id,
1774 outcome: crate::domain::ToolOutcome::error(
1775 "EffectRunner has no ToolRegistry bound",
1776 0.0,
1777 ),
1778 })
1779 .await;
1780 return;
1781 };
1782
1783 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
1787 let rest = &source.function.name[5..];
1788 if let Some((server, tool)) = rest.split_once("__") {
1789 (
1790 "mcp_proxy",
1791 serde_json::json!({
1792 "server_name": server,
1793 "tool_name": tool,
1794 "arguments": source.function.arguments.clone(),
1795 }),
1796 )
1797 } else {
1798 let _ = msg_tx
1799 .send(Msg::ToolFinished {
1800 turn,
1801 call_id,
1802 outcome: crate::domain::ToolOutcome::error(
1803 format!("invalid MCP tool name: {}", source.function.name),
1804 0.0,
1805 ),
1806 })
1807 .await;
1808 return;
1809 }
1810 } else {
1811 (
1812 source.function.name.as_str(),
1813 source.function.arguments.clone(),
1814 )
1815 };
1816 let tool_run_id = start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args);
1817
1818 let Some(tool) = registry.get(tool_key) else {
1819 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
1820 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
1821 let _ = msg_tx
1822 .send(Msg::ToolFinished {
1823 turn,
1824 call_id,
1825 outcome,
1826 })
1827 .await;
1828 return;
1829 };
1830
1831 let (progress_tx, mut progress_rx) = mpsc::channel(16);
1837 let relay_tx = msg_tx.clone();
1838 let progress_relay = tokio::spawn(async move {
1839 while let Some(event) = progress_rx.recv().await {
1840 if relay_tx
1841 .send(Msg::ToolProgress {
1842 turn,
1843 call_id,
1844 event,
1845 })
1846 .await
1847 .is_err()
1848 {
1849 break;
1850 }
1851 }
1852 });
1853
1854 let ctx = ExecContext::new(
1855 token,
1856 progress_tx,
1857 call_id,
1858 turn,
1859 workdir,
1860 config,
1861 model_id,
1862 task_id,
1863 safety_mode,
1864 intent,
1865 classifier,
1866 approval,
1867 );
1868 let before_payload = serde_json::json!({
1869 "turn_id": turn.0,
1870 "call_id": call_id.0,
1871 "tool": tool_key,
1872 });
1873 let _ = crate::runtime::run_plugin_hooks("before_tool_use", &before_payload);
1874 let outcome = tool.execute(args, ctx).await;
1875 let after_payload = serde_json::json!({
1876 "turn_id": turn.0,
1877 "call_id": call_id.0,
1878 "tool": tool_key,
1879 "status": tool_status_label(outcome.status),
1880 "summary": &outcome.summary,
1881 });
1882 let _ = crate::runtime::run_plugin_hooks("after_tool_use", &after_payload);
1883 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
1884 let _ = progress_relay.await;
1885 let _ = msg_tx
1886 .send(Msg::ToolFinished {
1887 turn,
1888 call_id,
1889 outcome,
1890 })
1891 .await;
1892}
1893
1894fn start_runtime_tool_run(
1895 task_id: Option<&str>,
1896 turn: TurnId,
1897 call_id: crate::domain::ToolCallId,
1898 tool_name: &str,
1899 args: &serde_json::Value,
1900) -> Option<String> {
1901 crate::runtime::RuntimeStore::open_default()
1902 .and_then(|store| {
1903 store.tool_runs().start(crate::runtime::NewToolRun {
1904 id: None,
1905 task_id: task_id.map(str::to_string),
1906 turn_id: Some(turn.0.to_string()),
1907 call_id: Some(call_id.0.to_string()),
1908 tool_name: tool_name.to_string(),
1909 args_json: serde_json::to_string(args).ok(),
1910 })
1911 })
1912 .map(|record| record.id)
1913 .ok()
1914}
1915
1916fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
1917 let Some(tool_run_id) = tool_run_id else {
1918 return;
1919 };
1920 let output_json = serde_json::to_string(&serde_json::json!({
1921 "status": tool_status_label(outcome.status),
1922 "summary": &outcome.summary,
1923 "model_content": &outcome.model_content,
1924 "error": &outcome.error,
1925 "metadata": &outcome.metadata,
1926 "artifacts": &outcome.artifacts,
1927 "duration_secs": outcome.duration_secs,
1928 }))
1929 .ok();
1930 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1931 let _ = store.tool_runs().finish(
1932 tool_run_id,
1933 tool_status_label(outcome.status),
1934 output_json.as_deref(),
1935 );
1936 }
1937}
1938
1939fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
1940 match status {
1941 crate::domain::ToolStatus::Success => "success",
1942 crate::domain::ToolStatus::Error => "error",
1943 crate::domain::ToolStatus::Cancelled => "cancelled",
1944 }
1945}
1946
1947fn runtime_model_info_text(model: &str) -> String {
1948 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
1949 let mut lines = vec![
1950 format!("Model info: {}", model),
1951 format!("- provider: {}", snapshot.provider),
1952 format!("- model: {}", snapshot.model),
1953 format!("- supports tools: {}", snapshot.supports_tools),
1954 format!("- supports vision: {}", snapshot.supports_vision),
1955 format!("- reasoning: {}", snapshot.reasoning),
1956 format!(
1957 "- context limit: {}",
1958 snapshot
1959 .max_context_tokens
1960 .map(|value: usize| value.to_string())
1961 .unwrap_or_else(|| "unknown".to_string())
1962 ),
1963 ];
1964 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
1965 && let Ok(probes) = store
1966 .provider_probes()
1967 .list(Some(&snapshot.provider), Some(&snapshot.model))
1968 && !probes.is_empty()
1969 {
1970 lines.push(String::new());
1971 lines.push("Cached provider reality records:".to_string());
1972 for probe in probes {
1973 lines.push(format!(
1974 "- {} = {} ({})",
1975 probe.capability_key, probe.capability_value, probe.confidence
1976 ));
1977 }
1978 }
1979 lines.join("\n")
1980}
1981
1982async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
1987 use tokio::io::{AsyncBufReadExt, BufReader};
1988 use tokio::process::Command;
1989
1990 let mut cmd = Command::new("ollama");
1991 cmd.arg("pull")
1992 .arg(&model)
1993 .stdin(std::process::Stdio::null())
1994 .stdout(std::process::Stdio::piped())
1995 .stderr(std::process::Stdio::piped())
1996 .kill_on_drop(true);
1997
1998 let mut child = match cmd.spawn() {
1999 Ok(c) => c,
2000 Err(e) => {
2001 let _ = tx
2002 .send(Msg::ModelPullProgress(format!(
2003 "ollama pull failed to start: {}",
2004 e
2005 )))
2006 .await;
2007 return;
2008 },
2009 };
2010
2011 if let Some(stdout) = child.stdout.take() {
2012 let tx_inner = tx.clone();
2013 tokio::spawn(async move {
2014 let mut reader = BufReader::new(stdout).lines();
2015 while let Ok(Some(line)) = reader.next_line().await {
2016 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2017 }
2018 });
2019 }
2020
2021 match child.wait().await {
2022 Ok(status) if status.success() => {
2023 let _ = tx.send(Msg::ModelPullFinished { model }).await;
2024 },
2025 Ok(status) => {
2026 let _ = tx
2027 .send(Msg::ModelPullProgress(format!(
2028 "ollama pull exited with status {}",
2029 status.code().unwrap_or(-1)
2030 )))
2031 .await;
2032 },
2033 Err(e) => {
2034 let _ = tx
2035 .send(Msg::ModelPullProgress(format!(
2036 "ollama pull wait error: {}",
2037 e
2038 )))
2039 .await;
2040 },
2041 }
2042}
2043
2044fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2045 if started {
2046 Msg::McpServerReady {
2047 name: name.to_string(),
2048 tools,
2049 }
2050 } else {
2051 Msg::McpServerErrored {
2052 name: name.to_string(),
2053 reason: "server failed to start or initialize".to_string(),
2054 }
2055 }
2056}
2057
2058async fn dispatch_read_clipboard(tx: MsgSender) {
2068 use crate::domain::{Paste, StatusKind};
2069
2070 enum Outcome {
2071 Image { bytes: Vec<u8>, format: String },
2072 Text(String),
2073 Empty,
2074 Error(String),
2075 }
2076
2077 let outcome = tokio::task::spawn_blocking(|| {
2078 if crate::clipboard::has_image() {
2079 match crate::clipboard::read_image_bytes() {
2080 Ok((bytes, format)) => Outcome::Image { bytes, format },
2081 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2082 }
2083 } else {
2084 match crate::clipboard::read_text() {
2085 Ok(t) if !t.is_empty() => Outcome::Text(t),
2086 Ok(_) => Outcome::Empty,
2087 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2088 }
2089 }
2090 })
2091 .await
2092 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2093
2094 let msg = match outcome {
2095 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2096 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2097 Outcome::Empty => Msg::TransientStatus {
2098 text: "Clipboard is empty".to_string(),
2099 kind: StatusKind::Info,
2100 dismiss_ms: 2_000,
2101 },
2102 Outcome::Error(text) => Msg::TransientStatus {
2103 text,
2104 kind: StatusKind::Warn,
2105 dismiss_ms: 4_000,
2106 },
2107 };
2108 let _ = tx.send(msg).await;
2109}
2110
2111async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2114 use crate::domain::StatusKind;
2115
2116 let char_count = text.chars().count();
2117 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2118 .await
2119 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2120
2121 let msg = match result {
2122 Ok(()) => Msg::TransientStatus {
2123 text: format!("Copied {char_count} chars to clipboard"),
2124 kind: StatusKind::Info,
2125 dismiss_ms: 2_000,
2126 },
2127 Err(e) => Msg::TransientStatus {
2128 text: format!("Copy failed: {e}"),
2129 kind: StatusKind::Warn,
2130 dismiss_ms: 4_000,
2131 },
2132 };
2133 let _ = tx.send(msg).await;
2134}
2135
2136fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2137 use crate::models::{ErrorCategory, ModelError, UserFacingError};
2138 match e {
2139 ModelError::Backend(b) => UserFacingError {
2140 summary: "Backend error".to_string(),
2141 message: b.to_string(),
2142 suggestion: "Check the provider endpoint / API key.".to_string(),
2143 category: ErrorCategory::Connection,
2144 recoverable: true,
2145 },
2146 ModelError::Authentication(msg) => UserFacingError {
2147 summary: "Auth error".to_string(),
2148 message: msg.clone(),
2149 suggestion: "Set the env var the provider expects.".to_string(),
2150 category: ErrorCategory::Auth,
2151 recoverable: false,
2152 },
2153 ModelError::RateLimit { retry_after } => UserFacingError {
2154 summary: "Rate limit".to_string(),
2155 message: format!("retry after {:?}", retry_after),
2156 suggestion: "Wait and try again.".to_string(),
2157 category: ErrorCategory::Temporary,
2158 recoverable: true,
2159 },
2160 ModelError::StreamError(msg) => UserFacingError {
2161 summary: "Stream error".to_string(),
2162 message: msg.clone(),
2163 suggestion: "Retry the request.".to_string(),
2164 category: ErrorCategory::Connection,
2165 recoverable: true,
2166 },
2167 other => UserFacingError {
2168 summary: "Model error".to_string(),
2169 message: other.to_string(),
2170 suggestion: String::new(),
2171 category: ErrorCategory::Internal,
2172 recoverable: false,
2173 },
2174 }
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179 use super::*;
2180 use crate::domain::ToolCallId;
2181 use std::time::Duration;
2182
2183 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2184 EffectRunner::pair(PathBuf::from("/tmp"))
2185 }
2186
2187 #[test]
2188 fn parse_prune_plan_extracts_json_amid_prose() {
2189 let plan = parse_prune_plan(
2190 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2191 )
2192 .expect("should parse");
2193 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2194 assert_eq!(plan.reason, "dupes");
2195 }
2196
2197 #[test]
2198 fn parse_prune_plan_handles_empty_and_garbage() {
2199 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2200 .expect("empty plan parses");
2201 assert!(empty.prune.is_empty());
2202 assert!(parse_prune_plan("no json here").is_none());
2203 }
2204
2205 #[test]
2206 fn memory_title_from_text_is_short_and_nonempty() {
2207 assert_eq!(
2208 memory_title_from_text("prefer ripgrep over grep"),
2209 "prefer ripgrep over grep"
2210 );
2211 assert_eq!(memory_title_from_text(" "), "memory");
2212 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2213 assert!(long.split_whitespace().count() <= 8);
2214 }
2215
2216 #[tokio::test]
2217 async fn dispatch_exit_is_noop_on_runner_state() {
2218 let (mut r, _rx) = runner();
2219 r.dispatch(Cmd::Exit);
2220 assert_eq!(r.scope_count(), 0);
2221 }
2222
2223 #[tokio::test]
2224 async fn dispatch_save_emits_session_saved() {
2225 let (mut r, mut rx) = runner();
2226 r.dispatch(Cmd::SaveConversation(
2227 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2228 ));
2229 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2230 .await
2231 .expect("sender emits")
2232 .expect("channel alive");
2233 assert!(matches!(msg, Msg::SessionSaved));
2234 }
2235
2236 #[tokio::test]
2237 async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2238 let (mut r, mut rx) = runner();
2239 let t0 = std::time::Instant::now();
2240 r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2241 let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2242 .await
2243 .expect("sender emits")
2244 .expect("channel alive");
2245 assert!(matches!(msg, Msg::StatusDismiss));
2246 assert!(t0.elapsed() >= Duration::from_millis(25));
2247 }
2248
2249 #[test]
2250 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2251 let msg = mcp_startup_msg("empty", true, Vec::new());
2252 assert!(matches!(
2253 msg,
2254 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2255 ));
2256 }
2257
2258 #[test]
2259 fn mcp_startup_msg_reports_unstarted_server_as_error() {
2260 let msg = mcp_startup_msg("bad", false, Vec::new());
2261 assert!(matches!(
2262 msg,
2263 Msg::McpServerErrored { name, reason }
2264 if name == "bad" && reason.contains("failed to start")
2265 ));
2266 }
2267
2268 #[tokio::test]
2269 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2270 let (mut r, mut rx) = runner();
2271 let turn = TurnId(77);
2272 {
2273 let scope = r.scope_mut(turn);
2274 scope.spawn(async {
2275 std::future::pending::<()>().await;
2276 });
2277 }
2278 assert_eq!(r.scope_count(), 1);
2279
2280 let start = std::time::Instant::now();
2281 r.dispatch(Cmd::CancelScope(turn));
2282 assert_eq!(r.scope_count(), 0);
2283 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2284 .await
2285 .expect("bounded cancel should emit terminal message")
2286 .expect("channel alive");
2287 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2288 assert!(
2289 start.elapsed() < Duration::from_millis(500),
2290 "cancel terminal message took {:?}",
2291 start.elapsed()
2292 );
2293 }
2294
2295 #[tokio::test]
2296 async fn dispatch_call_model_creates_scope() {
2297 let (mut r, _rx) = runner();
2298 let turn = TurnId(7);
2299 let request = crate::domain::ChatRequest {
2300 model_id: "test/m".to_string(),
2301 messages: vec![],
2302 system_prompt: String::new(),
2303 instructions: None,
2304 reasoning: crate::models::ReasoningLevel::Medium,
2305 temperature: 0.7,
2306 max_tokens: 4096,
2307 tools: vec![],
2308 };
2309 r.dispatch(Cmd::CallModel { turn, request });
2310 assert_eq!(r.scope_count(), 1);
2311 }
2312
2313 #[tokio::test]
2317 async fn empty_scopes_are_reaped_on_next_dispatch() {
2318 let (mut r, mut rx) = runner();
2319 let turn = TurnId(42);
2320 let request = crate::domain::ChatRequest {
2321 model_id: "test/m".to_string(),
2322 messages: vec![],
2323 system_prompt: String::new(),
2324 instructions: None,
2325 reasoning: crate::models::ReasoningLevel::Medium,
2326 temperature: 0.7,
2327 max_tokens: 4096,
2328 tools: vec![],
2329 };
2330 r.dispatch(Cmd::CallModel { turn, request });
2331 assert_eq!(r.scope_count(), 1);
2332
2333 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2338 .await
2339 .expect("upstream error arrived")
2340 .expect("channel alive");
2341 assert!(matches!(msg, Msg::UpstreamError { .. }));
2342
2343 tokio::task::yield_now().await;
2345
2346 r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2348 assert_eq!(
2349 r.scope_count(),
2350 0,
2351 "completed scope must be reaped on next dispatch"
2352 );
2353 }
2354
2355 #[tokio::test]
2356 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2357 let (mut r, mut rx) = runner();
2358 let turn = TurnId(7);
2359 let call_id = ToolCallId(1);
2360 let source = crate::models::tool_call::ToolCall {
2361 id: Some("c1".to_string()),
2362 function: crate::models::tool_call::FunctionCall {
2363 name: "read_file".to_string(),
2364 arguments: serde_json::json!({"path": "x"}),
2365 },
2366 };
2367 r.dispatch(Cmd::ExecuteTool {
2368 turn,
2369 call_id,
2370 source,
2371 model_id: "ollama/test".to_string(),
2372 safety_mode: crate::runtime::SafetyMode::Ask,
2373 intent: None,
2374 });
2375 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2376 .await
2377 .expect("some msg")
2378 .expect("channel alive");
2379 assert!(matches!(
2380 first,
2381 Msg::ToolStarted {
2382 turn: t,
2383 call_id: c,
2384 } if t == turn && c == call_id
2385 ));
2386 }
2387
2388 #[tokio::test]
2389 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2390 let (mut r, _rx) = runner();
2391 let turn = TurnId(9);
2392 r.dispatch(Cmd::CallModel {
2393 turn,
2394 request: crate::domain::ChatRequest {
2395 model_id: "m".to_string(),
2396 messages: vec![],
2397 system_prompt: String::new(),
2398 instructions: None,
2399 reasoning: crate::models::ReasoningLevel::Medium,
2400 temperature: 0.7,
2401 max_tokens: 4096,
2402 tools: vec![],
2403 },
2404 });
2405 assert_eq!(r.scope_count(), 1);
2406
2407 r.dispatch(Cmd::CancelScope(turn));
2408 assert_eq!(r.scope_count(), 0);
2409 }
2410
2411 #[tokio::test]
2412 async fn shutdown_drains_pending_saves() {
2413 let (mut r, _rx) = runner();
2414 for _ in 0..5 {
2415 r.dispatch(Cmd::SaveConversation(
2416 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2417 ));
2418 }
2419 let start = std::time::Instant::now();
2421 r.shutdown().await;
2422 assert!(start.elapsed() < Duration::from_secs(2));
2423 }
2424}