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