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}
108
109impl EffectRunner {
110 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
112 Self {
113 msg_tx,
114 scopes: HashMap::new(),
115 detached: tokio::task::JoinSet::new(),
116 workdir,
117 providers: None,
118 tools: None,
119 task_id: None,
120 terminal_title_enabled: true,
121 }
122 }
123
124 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
127 self.task_id = task_id;
128 self
129 }
130
131 pub fn without_terminal_title(mut self) -> Self {
133 self.terminal_title_enabled = false;
134 self
135 }
136
137 pub fn with_bindings(
142 mut self,
143 providers: Arc<ProviderFactory>,
144 tools: Arc<ToolRegistry>,
145 ) -> Self {
146 self.providers = Some(providers);
147 self.tools = Some(tools);
148 self
149 }
150
151 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
155 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
156 (Self::new(tx, workdir), rx)
157 }
158
159 pub fn pair_with_bindings(
162 workdir: PathBuf,
163 config: Config,
164 tools: Arc<ToolRegistry>,
165 ) -> (Self, mpsc::Receiver<Msg>) {
166 let providers = Arc::new(ProviderFactory::new(config));
167 Self::pair_from(workdir, providers, tools)
168 }
169
170 pub fn pair_from(
175 workdir: PathBuf,
176 providers: Arc<ProviderFactory>,
177 tools: Arc<ToolRegistry>,
178 ) -> (Self, mpsc::Receiver<Msg>) {
179 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
180 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
181 }
182
183 pub fn pair_from_with_task(
184 workdir: PathBuf,
185 providers: Arc<ProviderFactory>,
186 tools: Arc<ToolRegistry>,
187 task_id: Option<String>,
188 ) -> (Self, mpsc::Receiver<Msg>) {
189 let (runner, rx) = Self::pair_from(workdir, providers, tools);
190 (runner.with_task_id(task_id), rx)
191 }
192
193 pub fn new_child(
198 msg_tx: MsgSender,
199 workdir: PathBuf,
200 providers: Arc<ProviderFactory>,
201 tools: Arc<ToolRegistry>,
202 ) -> Self {
203 Self::new(msg_tx, workdir).with_bindings(providers, tools)
204 }
205
206 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
210 self.scopes
211 .entry(turn)
212 .or_insert_with(|| TurnScope::new(turn))
213 }
214
215 fn drop_scope(&mut self, turn: TurnId) {
225 if let Some(mut scope) = self.scopes.remove(&turn) {
226 scope.cancel();
227 let tx = self.msg_tx.clone();
228 self.detached.spawn(async move {
229 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
230 .await
231 .is_err()
232 {
233 tracing::warn!(
234 turn = %turn,
235 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
236 "cancel drain timed out; aborting remaining scoped tasks"
237 );
238 }
239 let _ = tx.send(Msg::TurnCancelled(turn)).await;
240 });
241 }
242 }
243
244 pub fn scope_count(&self) -> usize {
247 self.scopes.len()
248 }
249
250 fn reap_empty_scopes(&mut self) {
259 self.scopes.retain(|_, scope| {
260 scope.drain_completed();
261 !scope.is_empty()
262 });
263 }
264
265 pub fn dispatch(&mut self, cmd: Cmd) {
269 self.reap_empty_scopes();
272 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
273
274 match cmd {
275 Cmd::CallModel { turn, mut request } => {
276 let tx = self.msg_tx.clone();
277 let providers = self.providers.clone();
278 if let Some(tools) = &self.tools {
285 let mut enriched = tools.describe_all();
286 enriched.append(&mut request.tools);
287 request.tools = enriched;
288 }
289 let _ = crate::runtime::run_plugin_hooks(
290 "prompt_submit",
291 &serde_json::json!({
292 "turn_id": turn.0,
293 "model_id": request.model_id.clone(),
294 "message_count": request.messages.len(),
295 "tool_count": request.tools.len(),
296 }),
297 );
298 let scope = self.scope_mut(turn);
299 let token = scope.token();
300 scope.spawn(async move {
301 dispatch_call_model(tx, providers, turn, request, token).await;
302 });
303 },
304 Cmd::CompactConversation { turn, mut request } => {
305 let tx = self.msg_tx.clone();
306 let providers = self.providers.clone();
307 if let Some(tools) = &self.tools {
308 let mut enriched = tools.describe_all();
309 enriched.append(&mut request.chat.tools);
310 request.chat.tools = enriched;
311 }
312 let scope = self.scope_mut(turn);
313 let token = scope.token();
314 scope.spawn(async move {
315 dispatch_compact_conversation(tx, providers, turn, request, token).await;
316 });
317 },
318 Cmd::ExecuteTool {
319 turn,
320 call_id,
321 source,
322 model_id,
323 safety_mode,
324 intent,
325 } => {
326 let tx = self.msg_tx.clone();
327 let tools = self.tools.clone();
328 let workdir = self.workdir.clone();
329 let config = self
334 .providers
335 .as_ref()
336 .map(|p| Arc::new(p.config().clone()))
337 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
338 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
343 if safety_mode == crate::runtime::SafetyMode::Auto {
344 self.providers.as_ref().map(|p| {
345 let model = config
346 .safety
347 .auto_classifier_model
348 .clone()
349 .unwrap_or_else(|| model_id.clone());
350 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
351 as Arc<dyn crate::providers::AutoClassifier>
352 })
353 } else {
354 None
355 };
356 let task_id = self.task_id.clone();
357 let scope = self.scope_mut(turn);
358 let token = scope.token();
359 scope.spawn(async move {
360 dispatch_execute_tool(
361 tx,
362 tools,
363 workdir,
364 turn,
365 call_id,
366 source,
367 token,
368 config,
369 model_id,
370 task_id,
371 safety_mode,
372 intent,
373 classifier,
374 )
375 .await;
376 });
377 },
378 Cmd::CancelScope(turn) => {
379 self.drop_scope(turn);
380 },
381 Cmd::SaveConversation(history) => {
382 let tx = self.msg_tx.clone();
383 let workdir = self.workdir.clone();
384 self.detached.spawn(async move {
385 if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
386 && manager.save_conversation(&history).is_ok()
387 {
388 let _ = tx.send(Msg::SessionSaved).await;
389 } else {
390 tracing::warn!("SaveConversation: failed to write to disk");
391 }
392 });
393 },
394 Cmd::SaveCompactionArchive {
395 archive,
396 record,
397 conversation,
398 } => {
399 let tx = self.msg_tx.clone();
400 let workdir = self.workdir.clone();
401 let task_id = self.task_id.clone();
402 self.detached.spawn(async move {
403 let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
404 return;
405 };
406 match manager.save_compaction_archive(&archive) {
411 Ok(path) => {
412 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
413 let compaction_id = record.id.clone();
414 let conversation_id = archive.conversation_id.clone();
415 let _ =
416 store.compactions().create(crate::runtime::NewCompaction {
417 id: Some(record.id),
418 task_id: task_id.clone(),
419 session_id: Some(archive.conversation_id),
420 source_token_estimate: Some(record.before_tokens as i64),
421 summary_token_count: Some(record.summary_tokens as i64),
422 preserved_turns: Some(record.preserved_message_count as i64),
423 archive_path: Some(path.display().to_string()),
424 verification_status: Some("verified".to_string()),
425 });
426 let _ = crate::runtime::run_plugin_hooks(
427 "compaction",
428 &serde_json::json!({
429 "id": compaction_id,
430 "task_id": task_id.clone(),
431 "session_id": conversation_id,
432 "archive_path": path.display().to_string(),
433 }),
434 );
435 }
436 if manager.save_conversation(&conversation).is_ok() {
437 let _ = tx.send(Msg::SessionSaved).await;
438 } else {
439 tracing::warn!(
440 "SaveCompactionArchive: archive persisted but conversation save failed"
441 );
442 }
443 },
444 Err(err) => {
445 tracing::warn!(
446 error = %err,
447 "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
448 );
449 },
450 }
451 });
452 },
453 Cmd::SaveProcess(process) => {
454 let task_id = self.task_id.clone();
455 self.detached.spawn(async move {
456 let status = match process.status {
457 crate::domain::ManagedProcessStatus::Running => {
458 crate::runtime::ProcessStatus::Running
459 },
460 crate::domain::ManagedProcessStatus::Exited => {
461 crate::runtime::ProcessStatus::Exited
462 },
463 crate::domain::ManagedProcessStatus::Unknown => {
464 crate::runtime::ProcessStatus::Unknown
465 },
466 };
467 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
468 let _ = store.processes().upsert(crate::runtime::NewProcess {
469 id: Some(process.id),
470 task_id,
471 pid: process.pid,
472 command: process.command,
473 cwd: process.cwd,
474 log_path: Some(process.log_path),
475 detected_url: process.detected_url,
476 status,
477 health: None,
478 });
479 }
480 });
481 },
482 Cmd::PersistLastModel(model) => {
483 self.detached.spawn(async move {
484 let _ = crate::app::persist_last_model(&model);
485 });
486 },
487 Cmd::PersistReasoningFor { model_id, level } => {
488 self.detached.spawn(async move {
489 let _ = crate::app::persist_reasoning_for_model(&model_id, level);
490 });
491 },
492 Cmd::RefreshInstructions => {
493 let tx = self.msg_tx.clone();
494 let workdir = self.workdir.clone();
495 self.detached.spawn(async move {
496 let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
497 let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
498 });
499 },
500 Cmd::LoadConversation(id) => {
501 let tx = self.msg_tx.clone();
502 let workdir = self.workdir.clone();
503 self.detached.spawn(async move {
504 match crate::session::ConversationManager::new(&workdir) {
505 Ok(mgr) => match mgr.load_conversation(&id) {
506 Ok(history) => {
507 let _ = tx.send(Msg::ConversationLoaded(history)).await;
508 },
509 Err(e) => {
510 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
511 },
512 },
513 Err(e) => {
514 tracing::warn!(error = %e, "ConversationManager init failed");
515 },
516 }
517 });
518 },
519 Cmd::ListConversations => {
520 let tx = self.msg_tx.clone();
521 let workdir = self.workdir.clone();
522 self.detached.spawn(async move {
523 let summaries = match crate::session::ConversationManager::new(&workdir) {
524 Ok(mgr) => mgr
525 .list_conversations()
526 .unwrap_or_default()
527 .into_iter()
528 .map(|h| crate::domain::ConversationSummary {
529 id: h.id.clone(),
530 title: h.title.clone(),
531 message_count: h.messages.len(),
532 updated_at: h.updated_at.to_rfc3339(),
533 })
534 .collect(),
535 Err(_) => Vec::new(),
536 };
537 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
538 });
539 },
540 Cmd::ListRuntimeTasks { limit } => {
541 let tx = self.msg_tx.clone();
542 self.detached.spawn(async move {
543 let tasks = crate::runtime::RuntimeClient::auto()
544 .list_tasks(limit)
545 .map(|read| read.value)
546 .unwrap_or_default();
547 let _ = tx.send(Msg::RuntimeTasksListed(tasks)).await;
548 });
549 },
550 Cmd::LoadRuntimeTask { id } => {
551 let tx = self.msg_tx.clone();
552 self.detached.spawn(async move {
553 let (task, events) = crate::runtime::RuntimeClient::auto()
554 .task_detail(&id)
555 .map(|read| (Some(read.value.task), read.value.events))
556 .unwrap_or((None, Vec::new()));
557 let _ = tx.send(Msg::RuntimeTaskLoaded { task, events }).await;
558 });
559 },
560 Cmd::ListRuntimeProcesses { limit } => {
561 let tx = self.msg_tx.clone();
562 self.detached.spawn(async move {
563 let processes = crate::runtime::RuntimeClient::auto()
564 .list_processes(limit)
565 .map(|read| read.value)
566 .unwrap_or_default();
567 let _ = tx.send(Msg::RuntimeProcessesListed(processes)).await;
568 });
569 },
570 Cmd::ShowRuntimeProcessLogs { id } => {
571 let tx = self.msg_tx.clone();
572 self.detached.spawn(async move {
573 let text = crate::runtime::RuntimeClient::auto()
574 .process_log(&id, None)
575 .map(|log| format!("Process log {}\n\n{}", id, log.content))
576 .unwrap_or_else(|err| format!("Process log error: {}", err));
577 let _ = tx.send(Msg::RuntimeText(text)).await;
578 });
579 },
580 Cmd::StopRuntimeProcess { id } => {
581 let tx = self.msg_tx.clone();
582 self.detached.spawn(async move {
583 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
584 Ok(response) => Msg::TransientStatus {
585 text: format!("Stopped process {} (pid {})", id, response.item.pid),
586 kind: crate::domain::StatusKind::Info,
587 dismiss_ms: 3_000,
588 },
589 Err(err) => Msg::TransientStatus {
590 text: format!("Process stop failed: {}", err),
591 kind: crate::domain::StatusKind::Warn,
592 dismiss_ms: 5_000,
593 },
594 };
595 let _ = tx.send(msg).await;
596 });
597 },
598 Cmd::RestartRuntimeProcess { id } => {
599 let tx = self.msg_tx.clone();
600 self.detached.spawn(async move {
601 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
602 Ok(response) => Msg::TransientStatus {
603 text: format!("Restarted process {} (pid {})", id, response.item.pid),
604 kind: crate::domain::StatusKind::Info,
605 dismiss_ms: 3_000,
606 },
607 Err(err) => Msg::TransientStatus {
608 text: format!("Process restart failed: {}", err),
609 kind: crate::domain::StatusKind::Warn,
610 dismiss_ms: 5_000,
611 },
612 };
613 let _ = tx.send(msg).await;
614 });
615 },
616 Cmd::OpenRuntimeTarget { target } => {
617 self.detached.spawn(async move {
618 let resolved = crate::runtime::RuntimeService::open_default()
619 .and_then(|service| service.resolve_open_target(&target))
620 .unwrap_or(target);
621 crate::utils::open_file(resolved);
622 });
623 },
624 Cmd::ShowRuntimePorts => {
625 let tx = self.msg_tx.clone();
626 self.detached.spawn(async move {
627 let text = crate::runtime::RuntimeClient::auto()
628 .ports()
629 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
630 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
631 let _ = tx.send(Msg::RuntimeText(text)).await;
632 });
633 },
634 Cmd::ListRuntimeApprovals => {
635 let tx = self.msg_tx.clone();
636 self.detached.spawn(async move {
637 let approvals = crate::runtime::RuntimeClient::auto()
638 .list_approvals()
639 .map(|read| read.value)
640 .unwrap_or_default();
641 let _ = tx.send(Msg::RuntimeApprovalsListed(approvals)).await;
642 });
643 },
644 Cmd::DecideRuntimeApproval { id, decision } => {
645 let tx = self.msg_tx.clone();
646 self.detached.spawn(async move {
647 let result = if decision == "approved" {
648 crate::runtime::RuntimeClient::auto().approve(&id)
649 } else {
650 crate::runtime::RuntimeClient::auto().deny(&id)
651 };
652 let msg = match result {
653 Ok(result) => Msg::TransientStatus {
654 text: if result.replayed {
655 format!("Approval {} {}: {}", id, decision, result.summary)
656 } else {
657 format!("Approval {} {}", id, decision)
658 },
659 kind: crate::domain::StatusKind::Info,
660 dismiss_ms: 3_000,
661 },
662 Err(err) => Msg::TransientStatus {
663 text: format!("Approval update failed: {}", err),
664 kind: crate::domain::StatusKind::Warn,
665 dismiss_ms: 5_000,
666 },
667 };
668 let _ = tx.send(msg).await;
669 });
670 },
671 Cmd::ListRuntimeCheckpoints { limit } => {
672 let tx = self.msg_tx.clone();
673 self.detached.spawn(async move {
674 let checkpoints = crate::runtime::RuntimeClient::auto()
675 .list_checkpoints(limit)
676 .map(|read| read.value)
677 .unwrap_or_default();
678 let _ = tx.send(Msg::RuntimeCheckpointsListed(checkpoints)).await;
679 });
680 },
681 Cmd::ListRuntimeMemory => {
682 let tx = self.msg_tx.clone();
683 self.detached.spawn(async move {
684 let memory = crate::runtime::RuntimeClient::auto()
685 .list_memory(None)
686 .map(|read| read.value)
687 .unwrap_or_default();
688 let _ = tx.send(Msg::RuntimeMemoryListed(memory)).await;
689 });
690 },
691 Cmd::ListRuntimePlugins => {
692 let tx = self.msg_tx.clone();
693 self.detached.spawn(async move {
694 let plugins = crate::runtime::RuntimeClient::auto()
695 .list_plugins()
696 .map(|read| read.value)
697 .unwrap_or_default();
698 let _ = tx.send(Msg::RuntimePluginsListed(plugins)).await;
699 });
700 },
701 Cmd::UpdateRuntimeTaskStatus {
702 id,
703 status,
704 final_report,
705 } => {
706 let tx = self.msg_tx.clone();
707 self.detached.spawn(async move {
708 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
709 store
710 .tasks()
711 .update_status(&id, status, final_report.as_deref())
712 }) {
713 Ok(()) => Msg::TransientStatus {
714 text: format!("Task {} -> {}", id, status),
715 kind: crate::domain::StatusKind::Info,
716 dismiss_ms: 3_000,
717 },
718 Err(err) => Msg::TransientStatus {
719 text: format!("Task update failed: {}", err),
720 kind: crate::domain::StatusKind::Warn,
721 dismiss_ms: 4_000,
722 },
723 };
724 let _ = tx.send(msg).await;
725 });
726 },
727 Cmd::CreateRuntimeCheckpoint { paths } => {
728 let tx = self.msg_tx.clone();
729 let workdir = self.workdir.clone();
730 self.detached.spawn(async move {
731 let pending_action = Some(serde_json::json!({
732 "source": "tui",
733 "command": "checkpoint",
734 }));
735 let msg =
736 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
737 Ok(manifest) => Msg::TransientStatus {
738 text: format!(
739 "Checkpoint {} created for {} path(s)",
740 manifest.id,
741 manifest.files.len()
742 ),
743 kind: crate::domain::StatusKind::Info,
744 dismiss_ms: 4_000,
745 },
746 Err(err) => Msg::TransientStatus {
747 text: format!("Checkpoint failed: {}", err),
748 kind: crate::domain::StatusKind::Warn,
749 dismiss_ms: 5_000,
750 },
751 };
752 let _ = tx.send(msg).await;
753 });
754 },
755 Cmd::RestoreRuntimeCheckpoint { id } => {
756 let tx = self.msg_tx.clone();
757 self.detached.spawn(async move {
758 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
759 Ok(result) => Msg::TransientStatus {
760 text: format!(
761 "Restored checkpoint {} ({} file(s)){}",
762 result.checkpoint.id,
763 result.checkpoint.files.len(),
764 if result.checkpoint.pending_action.is_some() {
765 "; pending action available in checkpoint manifest"
766 } else {
767 ""
768 }
769 ),
770 kind: crate::domain::StatusKind::Info,
771 dismiss_ms: 4_000,
772 },
773 Err(err) => Msg::TransientStatus {
774 text: format!("Restore failed: {}", err),
775 kind: crate::domain::StatusKind::Warn,
776 dismiss_ms: 5_000,
777 },
778 };
779 let _ = tx.send(msg).await;
780 });
781 },
782 Cmd::RememberRuntimeMemory { key, value } => {
783 let tx = self.msg_tx.clone();
784 let workdir = self.workdir.clone();
785 self.detached.spawn(async move {
786 let msg = match remember_runtime_memory(&workdir, key, value) {
787 Ok(id) => Msg::TransientStatus {
788 text: format!("Memory written: {}", id),
789 kind: crate::domain::StatusKind::Info,
790 dismiss_ms: 3_000,
791 },
792 Err(err) => Msg::TransientStatus {
793 text: format!("Memory write failed: {}", err),
794 kind: crate::domain::StatusKind::Warn,
795 dismiss_ms: 4_000,
796 },
797 };
798 let _ = tx.send(msg).await;
799 });
800 },
801 Cmd::EditRuntimeMemory { id, value } => {
802 let tx = self.msg_tx.clone();
803 self.detached.spawn(async move {
804 let msg = match crate::runtime::RuntimeClient::auto()
805 .edit_memory(&id, &value, "tui")
806 {
807 Ok(_) => Msg::TransientStatus {
808 text: format!("Updated memory {}", id),
809 kind: crate::domain::StatusKind::Info,
810 dismiss_ms: 3_000,
811 },
812 Err(err) => Msg::TransientStatus {
813 text: format!("Memory update failed: {}", err),
814 kind: crate::domain::StatusKind::Warn,
815 dismiss_ms: 5_000,
816 },
817 };
818 let _ = tx.send(msg).await;
819 });
820 },
821 Cmd::ForgetRuntimeMemory { id } => {
822 let tx = self.msg_tx.clone();
823 self.detached.spawn(async move {
824 let msg = match crate::runtime::RuntimeClient::auto().forget_memory(&id) {
825 Ok(()) => Msg::TransientStatus {
826 text: format!("Forgot {}", id),
827 kind: crate::domain::StatusKind::Info,
828 dismiss_ms: 3_000,
829 },
830 Err(err) => Msg::TransientStatus {
831 text: format!("Forget failed: {}", err),
832 kind: crate::domain::StatusKind::Warn,
833 dismiss_ms: 4_000,
834 },
835 };
836 let _ = tx.send(msg).await;
837 });
838 },
839 Cmd::ShowRuntimeModelInfo { model } => {
840 let tx = self.msg_tx.clone();
841 self.detached.spawn(async move {
842 let text = runtime_model_info_text(&model);
843 let _ = tx.send(Msg::RuntimeText(text)).await;
844 });
845 },
846 Cmd::InitMcpServers(configs) => {
847 let tx = self.msg_tx.clone();
848 self.detached.spawn(async move {
849 if configs.is_empty() {
850 return;
851 }
852 crate::mcp::manager_ref::mark_init_started();
853 let manager =
854 std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
855 for (name, _cfg) in configs.iter() {
859 let server_tools: Vec<crate::domain::McpToolSpec> = manager
860 .get_all_tools()
861 .iter()
862 .filter(|(server, _)| server == name)
863 .map(|(_, def)| crate::domain::McpToolSpec {
864 name: def.name.clone(),
865 description: def.description.clone(),
866 input_schema: def.input_schema.clone(),
867 })
868 .collect();
869 let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
870 let _ = tx.send(msg).await;
871 }
872 crate::mcp::manager_ref::set_manager(manager);
873 crate::mcp::manager_ref::mark_init_complete();
874 });
875 },
876 Cmd::StopMcpServer { name } => {
877 let tx = self.msg_tx.clone();
878 self.detached.spawn(async move {
879 let _ = tx.send(Msg::McpServerStopped { name }).await;
880 });
881 },
882 Cmd::PullOllamaModel { model } => {
883 let tx = self.msg_tx.clone();
884 self.detached.spawn(async move {
885 dispatch_pull_ollama_model(tx, model).await;
886 });
887 },
888 Cmd::OpenInSystem(path) => {
889 self.detached.spawn(async move {
890 let _ = tokio::task::spawn_blocking(move || {
891 crate::utils::open_file(&path);
892 })
893 .await;
894 });
895 },
896 Cmd::DismissStatusAfter { ms } => {
897 let tx = self.msg_tx.clone();
898 self.detached.spawn(async move {
899 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
900 let _ = tx.send(Msg::StatusDismiss).await;
901 });
902 },
903 Cmd::WriteImageToTemp {
904 path,
905 bytes,
906 format: _,
907 } => {
908 self.detached.spawn(async move {
909 if let Err(e) = tokio::fs::write(&path, &bytes).await {
910 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
911 }
912 });
913 },
914 Cmd::ReadClipboard => {
915 let tx = self.msg_tx.clone();
916 self.detached.spawn(async move {
917 dispatch_read_clipboard(tx).await;
918 });
919 },
920 Cmd::Exit => {
921 },
926 Cmd::SetTerminalTitle(title) => {
927 if !self.terminal_title_enabled {
928 return;
929 }
930 self.detached.spawn(async move {
931 use std::io::Write;
932 let seq = format!("\x1b]2;{}\x07", title);
933 let mut stdout = std::io::stdout();
934 let _ = stdout.write_all(seq.as_bytes());
935 let _ = stdout.flush();
936 });
937 },
938 }
939 }
940
941 pub async fn shutdown(mut self) {
945 for (id, scope) in self.scopes.iter() {
946 tracing::debug!(turn = %id, "shutdown: cancelling scope");
947 scope.cancel();
948 }
949
950 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
952
953 let drain = async {
954 for (_, mut scope) in self.scopes.drain() {
955 scope.drain().await;
956 }
957 while let Some(result) = self.detached.join_next().await {
958 if let Err(e) = result
959 && !e.is_cancelled()
960 {
961 tracing::warn!(error = %e, "shutdown: detached task panic");
962 }
963 }
964 };
965
966 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
967 }
968
969 #[doc(hidden)]
972 pub fn msg_sender(&self) -> MsgSender {
973 self.msg_tx.clone()
974 }
975}
976
977async fn dispatch_call_model(
982 msg_tx: MsgSender,
983 providers: Option<Arc<ProviderFactory>>,
984 turn: TurnId,
985 mut request: crate::domain::ChatRequest,
986 token: tokio_util::sync::CancellationToken,
987) {
988 use crate::models::UserFacingError;
989
990 let Some(factory) = providers else {
991 let error = UserFacingError {
992 summary: "not wired".to_string(),
993 message: "EffectRunner has no ProviderFactory bound".to_string(),
994 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
995 category: crate::models::ErrorCategory::Internal,
996 recoverable: false,
997 };
998 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
999 return;
1000 };
1001
1002 let provider = match factory.resolve(&request.model_id).await {
1004 Ok(p) => p,
1005 Err(e) => {
1006 let error = classify_error_for_ui(&e);
1007 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1008 return;
1009 },
1010 };
1011 record_provider_capabilities(&request.model_id, provider.capabilities());
1012 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1013 let _ = msg_tx
1014 .send(Msg::TransientStatus {
1015 text: format!(
1016 "{} does not advertise tool support; Mermaid will send the turn without tools",
1017 request.model_id
1018 ),
1019 kind: crate::domain::StatusKind::Warn,
1020 dismiss_ms: 6_000,
1021 })
1022 .await;
1023 request.tools.clear();
1024 }
1025
1026 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1027 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1028 });
1029 let context_snapshot =
1030 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1031 let _ = msg_tx
1032 .send(Msg::ContextUsageEstimated {
1033 turn,
1034 snapshot: context_snapshot.clone(),
1035 })
1036 .await;
1037
1038 let policy = CompactionPolicy::default();
1039 let mut compacted_before_stream = false;
1040 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1041 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1042 match run_compaction(
1043 Arc::clone(&provider),
1044 turn,
1045 compaction,
1046 context_snapshot.clone(),
1047 max_context_tokens,
1048 token.clone(),
1049 )
1050 .await
1051 {
1052 Ok(result) => {
1053 request.messages = result.replacement_messages.clone();
1054 compacted_before_stream = true;
1055 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1056 },
1057 Err(err) => {
1058 let hard_limit =
1059 crate::domain::context_exceeds_hard_limit(&context_snapshot, &request, policy);
1060 let _ = msg_tx
1061 .send(Msg::CompactionFailed {
1062 turn,
1063 trigger: CompactionTrigger::AutoThreshold,
1064 message: err.to_string(),
1065 kind: if hard_limit {
1066 crate::domain::StatusKind::Error
1067 } else {
1068 crate::domain::StatusKind::Warn
1069 },
1070 })
1071 .await;
1072 if hard_limit {
1073 let error = UserFacingError {
1074 summary: "Context too large".to_string(),
1075 message: format!(
1076 "The next request needs {} tokens before response reserve, and automatic compaction failed: {}",
1077 context_snapshot.used_tokens, err
1078 ),
1079 suggestion: "Run /compact with focus instructions, or /clear to start a fresh session.".to_string(),
1080 category: crate::models::ErrorCategory::Config,
1081 recoverable: true,
1082 };
1083 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1084 return;
1085 }
1086 },
1087 }
1088 }
1089
1090 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1093 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1094
1095 let relay_tx = msg_tx.clone();
1098 let relay = tokio::spawn(async move {
1099 while let Some(event) = stream_rx.recv().await {
1100 let msg = match event {
1101 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1102 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1103 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1104 StreamEvent::ThinkingSignature(_) => continue, StreamEvent::Done {
1106 usage,
1107 thinking_signature,
1108 } => Msg::StreamDone {
1109 turn,
1110 usage,
1111 thinking_signature,
1112 },
1113 };
1114 if relay_tx.send(msg).await.is_err() {
1115 break;
1116 }
1117 }
1118 });
1119
1120 match provider.chat(request.clone(), ctx).await {
1129 Ok(_final_response) => {
1130 },
1132 Err(crate::models::ModelError::Cancelled) => {
1133 },
1135 Err(e) => {
1136 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1137 if retry_context_limit {
1138 let latest_snapshot =
1139 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1140 let compaction =
1141 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1142 match run_compaction(
1143 Arc::clone(&provider),
1144 turn,
1145 compaction,
1146 latest_snapshot,
1147 max_context_tokens,
1148 token.clone(),
1149 )
1150 .await
1151 {
1152 Ok(result) => {
1153 let mut retry_request = request;
1154 retry_request.messages = result.replacement_messages.clone();
1155 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1156 let _ = relay.await;
1157 dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1158 .await;
1159 return;
1160 },
1161 Err(compact_err) => {
1162 let _ = msg_tx
1163 .send(Msg::CompactionFailed {
1164 turn,
1165 trigger: CompactionTrigger::ContextLimitRetry,
1166 message: compact_err.to_string(),
1167 kind: crate::domain::StatusKind::Error,
1168 })
1169 .await;
1170 },
1171 }
1172 }
1173 let error = classify_error_for_ui(&e);
1174 run_provider_error_hook(&request.model_id, &error);
1175 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1176 },
1177 }
1178
1179 let _ = relay.await;
1180}
1181
1182async fn dispatch_provider_stream(
1183 msg_tx: MsgSender,
1184 provider: Arc<dyn ModelProvider>,
1185 turn: TurnId,
1186 request: crate::domain::ChatRequest,
1187 token: tokio_util::sync::CancellationToken,
1188) {
1189 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1190 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1191 let relay_tx = msg_tx.clone();
1192 let relay = tokio::spawn(async move {
1193 while let Some(event) = stream_rx.recv().await {
1194 let msg = match event {
1195 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1196 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1197 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1198 StreamEvent::ThinkingSignature(_) => continue,
1199 StreamEvent::Done {
1200 usage,
1201 thinking_signature,
1202 } => Msg::StreamDone {
1203 turn,
1204 usage,
1205 thinking_signature,
1206 },
1207 };
1208 if relay_tx.send(msg).await.is_err() {
1209 break;
1210 }
1211 }
1212 });
1213
1214 let model_id = request.model_id.clone();
1215 match provider.chat(request, ctx).await {
1216 Ok(_) | Err(ModelError::Cancelled) => {},
1217 Err(e) => {
1218 let error = classify_error_for_ui(&e);
1219 run_provider_error_hook(&model_id, &error);
1220 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1221 },
1222 }
1223
1224 let _ = relay.await;
1225}
1226
1227fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1228 let _ = crate::runtime::run_plugin_hooks(
1229 "provider_error",
1230 &serde_json::json!({
1231 "model_id": model_id,
1232 "summary": &error.summary,
1233 "message": &error.message,
1234 "category": format!("{:?}", error.category),
1235 "recoverable": error.recoverable,
1236 }),
1237 );
1238}
1239
1240async fn dispatch_compact_conversation(
1241 msg_tx: MsgSender,
1242 providers: Option<Arc<ProviderFactory>>,
1243 turn: TurnId,
1244 request: CompactionRequest,
1245 token: tokio_util::sync::CancellationToken,
1246) {
1247 let Some(factory) = providers else {
1248 let _ = msg_tx
1249 .send(Msg::CompactionFailed {
1250 turn,
1251 trigger: request.trigger,
1252 message: "EffectRunner has no ProviderFactory bound".to_string(),
1253 kind: crate::domain::StatusKind::Error,
1254 })
1255 .await;
1256 return;
1257 };
1258
1259 let provider = match factory.resolve(&request.chat.model_id).await {
1260 Ok(provider) => provider,
1261 Err(err) => {
1262 let _ = msg_tx
1263 .send(Msg::CompactionFailed {
1264 turn,
1265 trigger: request.trigger,
1266 message: err.to_string(),
1267 kind: crate::domain::StatusKind::Error,
1268 })
1269 .await;
1270 return;
1271 },
1272 };
1273
1274 let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1275 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1276 });
1277 let before_snapshot =
1278 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1279
1280 let trigger = request.trigger;
1281 match run_compaction(
1282 provider,
1283 turn,
1284 request,
1285 before_snapshot,
1286 max_context_tokens,
1287 token,
1288 )
1289 .await
1290 {
1291 Ok(result) => {
1292 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1293 },
1294 Err(err) => {
1295 let _ = msg_tx
1296 .send(Msg::CompactionFailed {
1297 turn,
1298 trigger,
1299 message: err.to_string(),
1300 kind: crate::domain::StatusKind::Error,
1301 })
1302 .await;
1303 },
1304 }
1305}
1306
1307async fn run_compaction(
1308 provider: Arc<dyn ModelProvider>,
1309 turn: TurnId,
1310 request: CompactionRequest,
1311 before_snapshot: crate::domain::ContextUsageSnapshot,
1312 max_context_tokens: Option<usize>,
1313 token: tokio_util::sync::CancellationToken,
1314) -> Result<CompactionResult, ModelError> {
1315 let started = Instant::now();
1316 let prepared = crate::domain::prepare_compaction(&request, max_context_tokens)
1317 .map_err(|skip| ModelError::InvalidRequest(skip.to_string()))?;
1318
1319 let summary_request = crate::domain::build_summary_request(
1320 &request.chat,
1321 &prepared,
1322 request.instructions.as_deref(),
1323 request.policy,
1324 );
1325 let (draft, draft_usage) =
1326 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
1327 .await?;
1328 let draft_summary = crate::domain::normalize_summary(&draft);
1329 if draft_summary.trim().is_empty() {
1330 return Err(ModelError::InvalidRequest(
1331 "compaction produced an empty summary".to_string(),
1332 ));
1333 }
1334
1335 let verify_request = crate::domain::build_verification_request(
1336 &request.chat,
1337 &prepared,
1338 &draft_summary,
1339 request.instructions.as_deref(),
1340 request.policy,
1341 );
1342 let (final_summary, verify_usage, verified, verification_error) =
1343 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
1344 Ok((verified_text, verify_usage)) => {
1345 let verified_summary = crate::domain::normalize_summary(&verified_text);
1346 if verified_summary.trim().is_empty() {
1347 (
1348 draft_summary,
1349 verify_usage,
1350 false,
1351 Some("verification returned an empty summary".to_string()),
1352 )
1353 } else {
1354 (verified_summary, verify_usage, true, None)
1355 }
1356 },
1357 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
1358 Err(err) => (draft_summary, None, false, Some(err.to_string())),
1359 };
1360
1361 let id = format!(
1362 "compact_{}",
1363 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
1364 );
1365 let mut record = crate::domain::CompactionRecord {
1366 id,
1367 trigger: request.trigger,
1368 created_at: chrono::Local::now(),
1369 before_tokens: before_snapshot.used_tokens,
1370 after_tokens: 0,
1371 archived_message_count: prepared.archived_messages.len(),
1372 preserved_message_count: prepared.preserved_messages.len(),
1373 summary_tokens: final_summary.len().div_ceil(4),
1374 duration_secs: started.elapsed().as_secs_f64(),
1375 verified,
1376 verification_error,
1377 focus: request.instructions.clone(),
1378 archive_path: None,
1379 };
1380
1381 let mut replacement =
1382 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1383 let mut compacted_request = request.chat.clone();
1384 compacted_request.messages = replacement.clone();
1385 let mut after_snapshot =
1386 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1387 record.after_tokens = after_snapshot.used_tokens;
1388 record.duration_secs = started.elapsed().as_secs_f64();
1389 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
1390 compacted_request.messages = replacement.clone();
1391 after_snapshot =
1392 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
1393 record.after_tokens = after_snapshot.used_tokens;
1394
1395 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
1396 return Err(ModelError::InvalidRequest(format!(
1397 "compaction did not reduce context ({} -> {} tokens)",
1398 before_snapshot.used_tokens, after_snapshot.used_tokens
1399 )));
1400 }
1401
1402 if crate::domain::context_exceeds_hard_limit(
1403 &after_snapshot,
1404 &compacted_request,
1405 request.policy,
1406 ) {
1407 return Err(ModelError::InvalidRequest(format!(
1408 "compacted context still exceeds response reserve ({} tokens used)",
1409 after_snapshot.used_tokens
1410 )));
1411 }
1412
1413 Ok(CompactionResult {
1414 record,
1415 replacement_messages: replacement,
1416 archived_messages: prepared.archived_messages,
1417 before_snapshot,
1418 after_snapshot,
1419 usage: crate::domain::combine_usage(draft_usage, verify_usage),
1420 })
1421}
1422
1423async fn collect_compaction_text(
1424 provider: Arc<dyn ModelProvider>,
1425 turn: TurnId,
1426 request: crate::domain::ChatRequest,
1427 token: tokio_util::sync::CancellationToken,
1428) -> Result<(String, Option<TokenUsage>), ModelError> {
1429 crate::providers::model::collect_text(provider, turn, request, token).await
1432}
1433
1434fn record_provider_capabilities(
1435 model_id: &str,
1436 caps: &crate::providers::capabilities::Capabilities,
1437) {
1438 let (provider, model) = split_model_id(model_id);
1439 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1440 for (key, value) in [
1441 ("tools_support", caps.supports_tools.to_string()),
1442 ("vision_support", caps.supports_vision.to_string()),
1443 (
1444 "context_limit",
1445 caps.max_context_tokens
1446 .map(|v| v.to_string())
1447 .unwrap_or_else(|| "unknown".to_string()),
1448 ),
1449 (
1450 "reasoning_parameter_shape",
1451 format!("{:?}", caps.supports_reasoning),
1452 ),
1453 (
1454 "streaming_usage_available",
1455 "provider_dependent".to_string(),
1456 ),
1457 ("token_usage_field_shape", "normalized".to_string()),
1458 ] {
1459 let _ = store
1460 .provider_probes()
1461 .upsert(crate::runtime::NewProviderProbe {
1462 provider: provider.clone(),
1463 model_id: model.clone(),
1464 capability_key: key.to_string(),
1465 capability_value: value,
1466 confidence: "verified".to_string(),
1467 error: None,
1468 });
1469 }
1470 }
1471}
1472
1473fn split_model_id(model_id: &str) -> (String, String) {
1474 match model_id.split_once('/') {
1475 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
1476 (provider.to_ascii_lowercase(), model.to_string())
1477 },
1478 _ => ("ollama".to_string(), model_id.to_string()),
1479 }
1480}
1481
1482fn is_context_limit_error(error: &ModelError) -> bool {
1483 let text = error.to_string().to_lowercase();
1484 text.contains("context")
1485 && (text.contains("too large")
1486 || text.contains("exceed")
1487 || text.contains("maximum")
1488 || text.contains("token"))
1489}
1490
1491#[allow(clippy::too_many_arguments)]
1493async fn dispatch_execute_tool(
1494 msg_tx: MsgSender,
1495 tools: Option<Arc<ToolRegistry>>,
1496 workdir: PathBuf,
1497 turn: TurnId,
1498 call_id: crate::domain::ToolCallId,
1499 source: crate::models::tool_call::ToolCall,
1500 token: tokio_util::sync::CancellationToken,
1501 config: Arc<crate::app::Config>,
1502 model_id: String,
1503 task_id: Option<String>,
1504 safety_mode: crate::runtime::SafetyMode,
1505 intent: Option<String>,
1506 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
1507) {
1508 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
1509
1510 let Some(registry) = tools else {
1511 let _ = msg_tx
1512 .send(Msg::ToolFinished {
1513 turn,
1514 call_id,
1515 outcome: crate::domain::ToolOutcome::error(
1516 "EffectRunner has no ToolRegistry bound",
1517 0.0,
1518 ),
1519 })
1520 .await;
1521 return;
1522 };
1523
1524 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
1528 let rest = &source.function.name[5..];
1529 if let Some((server, tool)) = rest.split_once("__") {
1530 (
1531 "mcp_proxy",
1532 serde_json::json!({
1533 "server_name": server,
1534 "tool_name": tool,
1535 "arguments": source.function.arguments.clone(),
1536 }),
1537 )
1538 } else {
1539 let _ = msg_tx
1540 .send(Msg::ToolFinished {
1541 turn,
1542 call_id,
1543 outcome: crate::domain::ToolOutcome::error(
1544 format!("invalid MCP tool name: {}", source.function.name),
1545 0.0,
1546 ),
1547 })
1548 .await;
1549 return;
1550 }
1551 } else {
1552 (
1553 source.function.name.as_str(),
1554 source.function.arguments.clone(),
1555 )
1556 };
1557 let tool_run_id = start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args);
1558
1559 let Some(tool) = registry.get(tool_key) else {
1560 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
1561 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
1562 let _ = msg_tx
1563 .send(Msg::ToolFinished {
1564 turn,
1565 call_id,
1566 outcome,
1567 })
1568 .await;
1569 return;
1570 };
1571
1572 let (progress_tx, mut progress_rx) = mpsc::channel(16);
1578 let relay_tx = msg_tx.clone();
1579 let progress_relay = tokio::spawn(async move {
1580 while let Some(event) = progress_rx.recv().await {
1581 if relay_tx
1582 .send(Msg::ToolProgress {
1583 turn,
1584 call_id,
1585 event,
1586 })
1587 .await
1588 .is_err()
1589 {
1590 break;
1591 }
1592 }
1593 });
1594
1595 let ctx = ExecContext::new(
1596 token,
1597 progress_tx,
1598 call_id,
1599 turn,
1600 workdir,
1601 config,
1602 model_id,
1603 task_id,
1604 safety_mode,
1605 intent,
1606 classifier,
1607 );
1608 let before_payload = serde_json::json!({
1609 "turn_id": turn.0,
1610 "call_id": call_id.0,
1611 "tool": tool_key,
1612 });
1613 let _ = crate::runtime::run_plugin_hooks("before_tool_use", &before_payload);
1614 let outcome = tool.execute(args, ctx).await;
1615 let after_payload = serde_json::json!({
1616 "turn_id": turn.0,
1617 "call_id": call_id.0,
1618 "tool": tool_key,
1619 "status": tool_status_label(outcome.status),
1620 "summary": &outcome.summary,
1621 });
1622 let _ = crate::runtime::run_plugin_hooks("after_tool_use", &after_payload);
1623 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
1624 let _ = progress_relay.await;
1625 let _ = msg_tx
1626 .send(Msg::ToolFinished {
1627 turn,
1628 call_id,
1629 outcome,
1630 })
1631 .await;
1632}
1633
1634fn start_runtime_tool_run(
1635 task_id: Option<&str>,
1636 turn: TurnId,
1637 call_id: crate::domain::ToolCallId,
1638 tool_name: &str,
1639 args: &serde_json::Value,
1640) -> Option<String> {
1641 crate::runtime::RuntimeStore::open_default()
1642 .and_then(|store| {
1643 store.tool_runs().start(crate::runtime::NewToolRun {
1644 id: None,
1645 task_id: task_id.map(str::to_string),
1646 turn_id: Some(turn.0.to_string()),
1647 call_id: Some(call_id.0.to_string()),
1648 tool_name: tool_name.to_string(),
1649 args_json: serde_json::to_string(args).ok(),
1650 })
1651 })
1652 .map(|record| record.id)
1653 .ok()
1654}
1655
1656fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
1657 let Some(tool_run_id) = tool_run_id else {
1658 return;
1659 };
1660 let output_json = serde_json::to_string(&serde_json::json!({
1661 "status": tool_status_label(outcome.status),
1662 "summary": &outcome.summary,
1663 "model_content": &outcome.model_content,
1664 "error": &outcome.error,
1665 "metadata": &outcome.metadata,
1666 "artifacts": &outcome.artifacts,
1667 "duration_secs": outcome.duration_secs,
1668 }))
1669 .ok();
1670 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1671 let _ = store.tool_runs().finish(
1672 tool_run_id,
1673 tool_status_label(outcome.status),
1674 output_json.as_deref(),
1675 );
1676 }
1677}
1678
1679fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
1680 match status {
1681 crate::domain::ToolStatus::Success => "success",
1682 crate::domain::ToolStatus::Error => "error",
1683 crate::domain::ToolStatus::Cancelled => "cancelled",
1684 }
1685}
1686
1687fn runtime_model_info_text(model: &str) -> String {
1688 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
1689 let mut lines = vec![
1690 format!("Model info: {}", model),
1691 format!("- provider: {}", snapshot.provider),
1692 format!("- model: {}", snapshot.model),
1693 format!("- supports tools: {}", snapshot.supports_tools),
1694 format!("- supports vision: {}", snapshot.supports_vision),
1695 format!("- reasoning: {}", snapshot.reasoning),
1696 format!(
1697 "- context limit: {}",
1698 snapshot
1699 .max_context_tokens
1700 .map(|value: usize| value.to_string())
1701 .unwrap_or_else(|| "unknown".to_string())
1702 ),
1703 ];
1704 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
1705 && let Ok(probes) = store
1706 .provider_probes()
1707 .list(Some(&snapshot.provider), Some(&snapshot.model))
1708 && !probes.is_empty()
1709 {
1710 lines.push(String::new());
1711 lines.push("Cached provider reality records:".to_string());
1712 for probe in probes {
1713 lines.push(format!(
1714 "- {} = {} ({})",
1715 probe.capability_key, probe.capability_value, probe.confidence
1716 ));
1717 }
1718 }
1719 lines.join("\n")
1720}
1721
1722async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
1727 use tokio::io::{AsyncBufReadExt, BufReader};
1728 use tokio::process::Command;
1729
1730 let mut cmd = Command::new("ollama");
1731 cmd.arg("pull")
1732 .arg(&model)
1733 .stdin(std::process::Stdio::null())
1734 .stdout(std::process::Stdio::piped())
1735 .stderr(std::process::Stdio::piped())
1736 .kill_on_drop(true);
1737
1738 let mut child = match cmd.spawn() {
1739 Ok(c) => c,
1740 Err(e) => {
1741 let _ = tx
1742 .send(Msg::ModelPullProgress(format!(
1743 "ollama pull failed to start: {}",
1744 e
1745 )))
1746 .await;
1747 return;
1748 },
1749 };
1750
1751 if let Some(stdout) = child.stdout.take() {
1752 let tx_inner = tx.clone();
1753 tokio::spawn(async move {
1754 let mut reader = BufReader::new(stdout).lines();
1755 while let Ok(Some(line)) = reader.next_line().await {
1756 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
1757 }
1758 });
1759 }
1760
1761 match child.wait().await {
1762 Ok(status) if status.success() => {
1763 let _ = tx.send(Msg::ModelPullFinished { model }).await;
1764 },
1765 Ok(status) => {
1766 let _ = tx
1767 .send(Msg::ModelPullProgress(format!(
1768 "ollama pull exited with status {}",
1769 status.code().unwrap_or(-1)
1770 )))
1771 .await;
1772 },
1773 Err(e) => {
1774 let _ = tx
1775 .send(Msg::ModelPullProgress(format!(
1776 "ollama pull wait error: {}",
1777 e
1778 )))
1779 .await;
1780 },
1781 }
1782}
1783
1784fn remember_runtime_memory(
1785 workdir: &std::path::Path,
1786 key: String,
1787 value: String,
1788) -> anyhow::Result<String> {
1789 let project_path = workdir.display().to_string();
1790 let entry = crate::runtime::RuntimeClient::auto()
1791 .remember_memory(Some(project_path), &key, &value, "tui")?
1792 .value;
1793 Ok(entry.id)
1794}
1795
1796fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
1797 if started {
1798 Msg::McpServerReady {
1799 name: name.to_string(),
1800 tools,
1801 }
1802 } else {
1803 Msg::McpServerErrored {
1804 name: name.to_string(),
1805 reason: "server failed to start or initialize".to_string(),
1806 }
1807 }
1808}
1809
1810async fn dispatch_read_clipboard(tx: MsgSender) {
1820 use crate::domain::{Paste, StatusKind};
1821
1822 enum Outcome {
1823 Image { bytes: Vec<u8>, format: String },
1824 Text(String),
1825 Empty,
1826 Error(String),
1827 }
1828
1829 let outcome = tokio::task::spawn_blocking(|| {
1830 if crate::clipboard::has_image() {
1831 match crate::clipboard::read_image_bytes() {
1832 Ok((bytes, format)) => Outcome::Image { bytes, format },
1833 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
1834 }
1835 } else {
1836 match crate::clipboard::read_text() {
1837 Ok(t) if !t.is_empty() => Outcome::Text(t),
1838 Ok(_) => Outcome::Empty,
1839 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
1840 }
1841 }
1842 })
1843 .await
1844 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
1845
1846 let msg = match outcome {
1847 Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
1848 Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
1849 Outcome::Empty => Msg::TransientStatus {
1850 text: "Clipboard is empty".to_string(),
1851 kind: StatusKind::Info,
1852 dismiss_ms: 2_000,
1853 },
1854 Outcome::Error(text) => Msg::TransientStatus {
1855 text,
1856 kind: StatusKind::Warn,
1857 dismiss_ms: 4_000,
1858 },
1859 };
1860 let _ = tx.send(msg).await;
1861}
1862
1863fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
1864 use crate::models::{ErrorCategory, ModelError, UserFacingError};
1865 match e {
1866 ModelError::Backend(b) => UserFacingError {
1867 summary: "Backend error".to_string(),
1868 message: b.to_string(),
1869 suggestion: "Check the provider endpoint / API key.".to_string(),
1870 category: ErrorCategory::Connection,
1871 recoverable: true,
1872 },
1873 ModelError::Authentication(msg) => UserFacingError {
1874 summary: "Auth error".to_string(),
1875 message: msg.clone(),
1876 suggestion: "Set the env var the provider expects.".to_string(),
1877 category: ErrorCategory::Auth,
1878 recoverable: false,
1879 },
1880 ModelError::RateLimit { retry_after } => UserFacingError {
1881 summary: "Rate limit".to_string(),
1882 message: format!("retry after {:?}", retry_after),
1883 suggestion: "Wait and try again.".to_string(),
1884 category: ErrorCategory::Temporary,
1885 recoverable: true,
1886 },
1887 ModelError::StreamError(msg) => UserFacingError {
1888 summary: "Stream error".to_string(),
1889 message: msg.clone(),
1890 suggestion: "Retry the request.".to_string(),
1891 category: ErrorCategory::Connection,
1892 recoverable: true,
1893 },
1894 other => UserFacingError {
1895 summary: "Model error".to_string(),
1896 message: other.to_string(),
1897 suggestion: String::new(),
1898 category: ErrorCategory::Internal,
1899 recoverable: false,
1900 },
1901 }
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906 use super::*;
1907 use crate::domain::ToolCallId;
1908 use std::time::Duration;
1909
1910 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
1911 EffectRunner::pair(PathBuf::from("/tmp"))
1912 }
1913
1914 #[tokio::test]
1915 async fn dispatch_exit_is_noop_on_runner_state() {
1916 let (mut r, _rx) = runner();
1917 r.dispatch(Cmd::Exit);
1918 assert_eq!(r.scope_count(), 0);
1919 }
1920
1921 #[tokio::test]
1922 async fn dispatch_save_emits_session_saved() {
1923 let (mut r, mut rx) = runner();
1924 r.dispatch(Cmd::SaveConversation(
1925 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
1926 ));
1927 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
1928 .await
1929 .expect("sender emits")
1930 .expect("channel alive");
1931 assert!(matches!(msg, Msg::SessionSaved));
1932 }
1933
1934 #[tokio::test]
1935 async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
1936 let (mut r, mut rx) = runner();
1937 let t0 = std::time::Instant::now();
1938 r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
1939 let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
1940 .await
1941 .expect("sender emits")
1942 .expect("channel alive");
1943 assert!(matches!(msg, Msg::StatusDismiss));
1944 assert!(t0.elapsed() >= Duration::from_millis(25));
1945 }
1946
1947 #[test]
1948 fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
1949 let msg = mcp_startup_msg("empty", true, Vec::new());
1950 assert!(matches!(
1951 msg,
1952 Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
1953 ));
1954 }
1955
1956 #[test]
1957 fn mcp_startup_msg_reports_unstarted_server_as_error() {
1958 let msg = mcp_startup_msg("bad", false, Vec::new());
1959 assert!(matches!(
1960 msg,
1961 Msg::McpServerErrored { name, reason }
1962 if name == "bad" && reason.contains("failed to start")
1963 ));
1964 }
1965
1966 #[tokio::test]
1967 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
1968 let (mut r, mut rx) = runner();
1969 let turn = TurnId(77);
1970 {
1971 let scope = r.scope_mut(turn);
1972 scope.spawn(async {
1973 std::future::pending::<()>().await;
1974 });
1975 }
1976 assert_eq!(r.scope_count(), 1);
1977
1978 let start = std::time::Instant::now();
1979 r.dispatch(Cmd::CancelScope(turn));
1980 assert_eq!(r.scope_count(), 0);
1981 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
1982 .await
1983 .expect("bounded cancel should emit terminal message")
1984 .expect("channel alive");
1985 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
1986 assert!(
1987 start.elapsed() < Duration::from_millis(500),
1988 "cancel terminal message took {:?}",
1989 start.elapsed()
1990 );
1991 }
1992
1993 #[tokio::test]
1994 async fn dispatch_call_model_creates_scope() {
1995 let (mut r, _rx) = runner();
1996 let turn = TurnId(7);
1997 let request = crate::domain::ChatRequest {
1998 model_id: "test/m".to_string(),
1999 messages: vec![],
2000 system_prompt: String::new(),
2001 instructions: None,
2002 reasoning: crate::models::ReasoningLevel::Medium,
2003 temperature: 0.7,
2004 max_tokens: 4096,
2005 tools: vec![],
2006 };
2007 r.dispatch(Cmd::CallModel { turn, request });
2008 assert_eq!(r.scope_count(), 1);
2009 }
2010
2011 #[tokio::test]
2015 async fn empty_scopes_are_reaped_on_next_dispatch() {
2016 let (mut r, mut rx) = runner();
2017 let turn = TurnId(42);
2018 let request = crate::domain::ChatRequest {
2019 model_id: "test/m".to_string(),
2020 messages: vec![],
2021 system_prompt: String::new(),
2022 instructions: None,
2023 reasoning: crate::models::ReasoningLevel::Medium,
2024 temperature: 0.7,
2025 max_tokens: 4096,
2026 tools: vec![],
2027 };
2028 r.dispatch(Cmd::CallModel { turn, request });
2029 assert_eq!(r.scope_count(), 1);
2030
2031 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2036 .await
2037 .expect("upstream error arrived")
2038 .expect("channel alive");
2039 assert!(matches!(msg, Msg::UpstreamError { .. }));
2040
2041 tokio::task::yield_now().await;
2043
2044 r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2046 assert_eq!(
2047 r.scope_count(),
2048 0,
2049 "completed scope must be reaped on next dispatch"
2050 );
2051 }
2052
2053 #[tokio::test]
2054 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2055 let (mut r, mut rx) = runner();
2056 let turn = TurnId(7);
2057 let call_id = ToolCallId(1);
2058 let source = crate::models::tool_call::ToolCall {
2059 id: Some("c1".to_string()),
2060 function: crate::models::tool_call::FunctionCall {
2061 name: "read_file".to_string(),
2062 arguments: serde_json::json!({"path": "x"}),
2063 },
2064 };
2065 r.dispatch(Cmd::ExecuteTool {
2066 turn,
2067 call_id,
2068 source,
2069 model_id: "ollama/test".to_string(),
2070 safety_mode: crate::runtime::SafetyMode::Ask,
2071 intent: None,
2072 });
2073 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2074 .await
2075 .expect("some msg")
2076 .expect("channel alive");
2077 assert!(matches!(
2078 first,
2079 Msg::ToolStarted {
2080 turn: t,
2081 call_id: c,
2082 } if t == turn && c == call_id
2083 ));
2084 }
2085
2086 #[tokio::test]
2087 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2088 let (mut r, _rx) = runner();
2089 let turn = TurnId(9);
2090 r.dispatch(Cmd::CallModel {
2091 turn,
2092 request: crate::domain::ChatRequest {
2093 model_id: "m".to_string(),
2094 messages: vec![],
2095 system_prompt: String::new(),
2096 instructions: None,
2097 reasoning: crate::models::ReasoningLevel::Medium,
2098 temperature: 0.7,
2099 max_tokens: 4096,
2100 tools: vec![],
2101 },
2102 });
2103 assert_eq!(r.scope_count(), 1);
2104
2105 r.dispatch(Cmd::CancelScope(turn));
2106 assert_eq!(r.scope_count(), 0);
2107 }
2108
2109 #[tokio::test]
2110 async fn shutdown_drains_pending_saves() {
2111 let (mut r, _rx) = runner();
2112 for _ in 0..5 {
2113 r.dispatch(Cmd::SaveConversation(
2114 crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2115 ));
2116 }
2117 let start = std::time::Instant::now();
2119 r.shutdown().await;
2120 assert!(start.elapsed() < Duration::from_secs(2));
2121 }
2122}