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