Skip to main content

koda_core/
tool_dispatch.rs

1//! Tool execution dispatch — sequential, parallel, and split-batch.
2//!
3//! Routes tool calls from the inference loop to execution, handling
4//! approval flow, parallelization, and result recording.
5//!
6//! ## Dispatch flow
7//!
8//! ```text
9//! Model emits tool calls
10//!   → Classify each call's effect (ReadOnly / LocalMutation / Destructive)
11//!   → Split into read-only batch + mutation batch
12//!   → Read-only tools: execute in parallel (tokio::join)
13//!   → Mutation tools: execute sequentially with approval
14//!   → Record results in DB + inject into conversation
15//! ```
16//!
17//! ## Related modules
18//!
19//! - [`crate::tools`] — tool definitions and `ToolRegistry::execute()`
20//! - [`crate::trust`] — approval mode and effect classification
21//! - `sub_agent_dispatch.rs` — `InvokeAgent` handling (needs provider access)
22//! - `approval_flow.rs` — interactive approval UI flow
23//!
24//! ## Design (DESIGN.md)
25//!
26//! - **Tool Dispatch: Match Statement (P2)**: Tools are dispatched via a
27//!   `match` in `ToolRegistry::execute()`, not a `HashMap<String, Box<dyn Tool>>`.
28//!   Rust's exhaustive matching catches missing handlers at compile time.
29
30use crate::approval_flow::{handle_ask_user, request_approval};
31use crate::config::KodaConfig;
32use crate::db::{Database, Role};
33use crate::engine::{ApprovalDecision, EngineCommand, EngineEvent};
34use crate::file_tracker::FileTracker;
35use crate::persistence::Persistence;
36use crate::preview;
37use crate::providers::ToolCall;
38use crate::sub_agent_cache::SubAgentCache;
39use crate::sub_agent_dispatch;
40use crate::tools;
41use crate::trust::{self, ToolApproval, TrustMode};
42
43use anyhow::Result;
44use std::path::{Path, PathBuf};
45use tokio::sync::mpsc;
46use tokio_util::sync::CancellationToken;
47
48/// Post-execution recording: emit result event, persist to DB, track progress
49/// and file lifecycle. Called after every successful tool execution regardless
50/// of execution strategy (parallel, split-batch, or sequential).
51#[allow(clippy::too_many_arguments)]
52pub(crate) async fn record_tool_result(
53    tc: &ToolCall,
54    result: &str,
55    success: bool,
56    full_output: Option<&str>,
57    db: &Database,
58    session_id: &str,
59    max_result_chars: usize,
60    project_root: &Path,
61    file_tracker: &mut FileTracker,
62    sink: &dyn crate::engine::EngineSink,
63) -> Result<()> {
64    sink.emit(EngineEvent::ToolCallResult {
65        id: tc.id.clone(),
66        name: tc.function_name.clone(),
67        output: result.to_string(),
68    });
69
70    // If we have separate full output (Bash smart summary), use the dedicated
71    // two-column insert so the model sees the summary while RecallContext can
72    // search the full output.
73    if let Some(full) = full_output {
74        db.insert_tool_message_with_full(session_id, result, &tc.id, full)
75            .await?;
76    } else {
77        let stored = truncate_for_history(result, max_result_chars);
78        db.insert_message(
79            session_id,
80            &Role::Tool,
81            Some(&stored),
82            None,
83            Some(&tc.id),
84            None,
85        )
86        .await?;
87    }
88    // (#1077 Phase B) `crate::progress::track_progress` was here. It
89    // scraped tool outputs to maintain a parallel "engine sees what
90    // the model just did" log that then re-injected into the system
91    // prompt next turn. Removed alongside the system-prompt injection
92    // it fed — the model owns its plan via `TodoWrite`, the
93    // conversation history persists it, the engine surfaces
94    // transitions via `EngineEvent::TodoUpdate`. See
95    // `DESIGN.md § Progress Tracking`.
96    let parsed_args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
97    track_file_lifecycle(
98        &tc.function_name,
99        &parsed_args,
100        project_root,
101        file_tracker,
102        success,
103    )
104    .await;
105    Ok(())
106}
107
108/// Truncate a tool result for storage in conversation history.
109/// The `max_chars` limit is set by `OutputCaps::tool_result_chars`.
110fn truncate_for_history(output: &str, max_chars: usize) -> String {
111    if output.len() <= max_chars {
112        return output.to_string();
113    }
114    // Find a safe char boundary
115    let mut end = max_chars;
116    while end > 0 && !output.is_char_boundary(end) {
117        end -= 1;
118    }
119    format!(
120        "{}\n\n[...truncated {} chars. Re-read the file if you need the full content.]",
121        &output[..end],
122        output.len() - end
123    )
124}
125
126/// Resolve the file path from a tool call's arguments.
127///
128/// Used by the file lifecycle tracker to record which paths
129/// Koda creates or deletes (#465). Only relevant for Write and Delete.
130fn resolve_tool_path(
131    tool_name: &str,
132    args: &serde_json::Value,
133    project_root: &Path,
134) -> Option<PathBuf> {
135    if !matches!(tool_name, "Write" | "Delete") {
136        return None;
137    }
138    crate::file_tracker::resolve_file_path_from_args(args, project_root)
139}
140
141/// Update file lifecycle tracker after a tool execution (#465).
142///
143/// - Write → track as owned (Koda created it)
144/// - Delete → untrack (file no longer exists)
145///
146/// Only tracks when `success` is true, using the structured boolean
147/// from `ToolResult` rather than fragile string-prefix matching (#476).
148async fn track_file_lifecycle(
149    tool_name: &str,
150    args: &serde_json::Value,
151    project_root: &Path,
152    file_tracker: &mut FileTracker,
153    success: bool,
154) {
155    if !success {
156        return;
157    }
158    if let Some(path) = resolve_tool_path(tool_name, args, project_root) {
159        match tool_name {
160            "Write" => file_tracker.track_created(path).await,
161            "Delete" => file_tracker.untrack(&path).await,
162            _ => {}
163        }
164    }
165}
166
167/// Decide whether a batch of tool calls can run in parallel.
168///
169/// A batch is parallel-eligible iff every call in it (a) auto-approves
170/// under the current trust mode and (b) doesn't conflict with another
171/// call in the batch on the same target file.
172///
173/// **#1022 B13**: this used to call [`trust::check_tool`] (no
174/// `FileTracker`), which is *not* the same classification the
175/// sequential dispatch loop uses. Sequential calls
176/// [`trust::check_tool_with_tracker`] so that `Delete` of a
177/// Koda-owned file (created via `Write` earlier this session)
178/// downgrades from `NeedsConfirmation` to `AutoApprove` per #465. The
179/// mismatch meant batches like `[Read other.txt, Delete owned.tmp]`
180/// were spuriously refused parallelization — each tool was eligible
181/// in isolation, but the batch fell into the slower split-batch /
182/// sequential path. Pure perf regression, no correctness impact, but
183/// the kind of invariant violation that grows teeth over time as
184/// other path-aware downgrades get added to the tracker path.
185///
186/// Now takes the same `Option<&FileTracker>` the sequential loop
187/// passes, and forwards it to `check_tool_with_tracker`. Same
188/// classification, same answer. Tests guard the regression below
189/// (`test_can_parallelize_delete_owned_file_uses_tracker`).
190pub(crate) fn can_parallelize(
191    tool_calls: &[ToolCall],
192    mode: TrustMode,
193    project_root: &Path,
194    file_tracker: Option<&crate::file_tracker::FileTracker>,
195) -> bool {
196    let all_approved = !tool_calls.iter().any(|tc| {
197        let args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
198        matches!(
199            trust::check_tool_with_tracker(
200                &tc.function_name,
201                &args,
202                mode,
203                Some(project_root),
204                file_tracker,
205            ),
206            ToolApproval::NeedsConfirmation | ToolApproval::Blocked
207        )
208    });
209
210    if !all_approved {
211        return false;
212    }
213
214    let mut seen = std::collections::HashSet::new();
215    let has_conflict = tool_calls.iter().any(|tc| {
216        if !crate::tools::is_mutating_tool(&tc.function_name) {
217            return false;
218        }
219        let args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
220        if let Some(path) = crate::undo::extract_file_path(&tc.function_name, &args) {
221            // If the path is already in the set, we have a conflict
222            !seen.insert(path)
223        } else {
224            false
225        }
226    });
227
228    !has_conflict
229}
230
231/// Execute a single tool call, returning (tool_call_id, result_output, success).
232#[tracing::instrument(skip_all, fields(tool = %tc.function_name))]
233#[allow(clippy::too_many_arguments)]
234pub(crate) async fn execute_one_tool(
235    tc: &ToolCall,
236    project_root: &Path,
237    config: &KodaConfig,
238    db: &Database,
239    _session_id: &str,
240    tools: &crate::tools::ToolRegistry,
241    mode: TrustMode,
242    sink: &dyn crate::engine::EngineSink,
243    cancel: CancellationToken,
244    sub_agent_cache: &SubAgentCache,
245    bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
246    caller_spawner: Option<u32>,
247) -> (String, String, bool, Option<String>) {
248    let (result, success, full_output) = if matches!(
249        tc.function_name.as_str(),
250        "ListBackgroundTasks" | "CancelTask" | "WaitTask"
251    ) {
252        // Layer 2 of #996 — background-task management tools.
253        //
254        // These need the `Arc<BgAgentRegistry>` (not held by the
255        // ToolRegistry) plus the caller's spawner identity (now
256        // threaded as `caller_spawner`), so they can't go through
257        // the generic `tools.execute()` path.
258        let r = crate::tools::bg_task_tools::execute(
259            &tc.function_name,
260            &tc.arguments,
261            bg_agents,
262            &tools.bg_registry,
263            caller_spawner,
264        )
265        .await;
266        (r.output, r.success, r.full_output)
267    } else if tc.function_name == "InvokeAgent" {
268        // Sub-agents inherit the parent's approval mode.
269        //
270        // Runtime invariant: the sub-agent dispatch loop short-circuits
271        // `InvokeAgent` with a refusal (#1022 B7 revised), so this
272        // branch is only ever reached from top-level inference. There
273        // is no actual recursion at runtime.
274        //
275        // *Type*-level cycle still exists, however: `execute_one_tool`
276        // calls `execute_sub_agent`, which calls `execute_one_tool` for
277        // each of the sub-agent's *non-InvokeAgent* tool calls. The
278        // borrow checker can't prove the runtime short-circuit, so it
279        // sees a mutually-recursive `async fn` cycle and rejects the
280        // future as infinitely sized (E0733). `Box::pin` breaks the
281        // *type* cycle by erasing the future to `Pin<Box<dyn Future>>`.
282        // The heap allocation is negligible — we already pay for
283        // workspace setup, DB session, and a provider call.
284        //
285        // #1022 B10: bind the sender to `_` (drops immediately) rather
286        // than `_dummy_tx` (lives until end of scope). With the sender
287        // alive a sub-agent that hits `request_approval` would block
288        // forever on `cmd_rx.recv()`. Dropping at construction makes
289        // the channel closed from the receiver's perspective, which
290        // `request_approval` already handles — it returns `None` and
291        // the sub-agent dispatch loop maps that to a clean auto-reject
292        // tool result the model can act on. Sub-agents have no path to
293        // the user's prompt by design.
294        let (_, mut dummy_rx) = mpsc::channel(1);
295        let policy = tools.sandbox_policy().clone();
296        let read_cache = tools.file_read_cache();
297        let fut = sub_agent_dispatch::execute_sub_agent(
298            project_root,
299            config,
300            db,
301            &tc.arguments,
302            mode,
303            sink,
304            cancel.clone(),
305            // Sub-agents get a fresh command channel (they auto-approve in all modes)
306            &mut dummy_rx,
307            Some(read_cache),
308            sub_agent_cache,
309            _session_id,
310            bg_agents,
311            // Phase 5 PR-4 of #934: hand the parent's effective policy
312            // to the child so `compose()` can stack restrictions.
313            &policy,
314            // Phase E of #996: parent's spawner identity. The new
315            // sub-agent uses this to tag any bg-sub-agent reservation
316            // it makes (so the parent owns/can-cancel its bg children),
317            // and allocates a fresh `my_invocation_id` internally for
318            // its own bg-task scoping.
319            caller_spawner,
320            // Layer 4 of #996 + #1076: foreground sub-agents are not
321            // tracked in the bg-agent registry, so there is no
322            // `BgStatusEmitter` to fan out per-iteration heartbeats
323            // to. Pass `None` to skip the per-iteration emit.
324            None,
325        );
326        match Box::pin(fut).await {
327            Ok(output) => (output, true, None),
328            Err(e) => (format!("Error invoking sub-agent: {e}"), false, None),
329        }
330    } else {
331        // Invalidate sub-agent cache on file mutations
332        if crate::tools::is_mutating_tool(&tc.function_name) {
333            sub_agent_cache.invalidate();
334        }
335        let streaming = if tc.function_name == "Bash" {
336            Some((sink, tc.id.as_str()))
337        } else {
338            None
339        };
340        let r = tools
341            .execute(&tc.function_name, &tc.arguments, streaming, caller_spawner)
342            .await;
343        (r.output, r.success, r.full_output)
344    };
345
346    (tc.id.clone(), result, success, full_output)
347}
348
349/// Pre-flight validate a tool call, then execute it.
350///
351/// Used by the parallel + split-batch arms (#1022 B14). The sequential
352/// arm keeps its own pre-execute validation step because it runs *before*
353/// approval prompting — we don't want to bother the user with a
354/// confirmation that's guaranteed to fail. Parallel/split-batch only
355/// reach this point when every tool was already classified `AutoApprove`,
356/// so validate-then-execute is the right order.
357#[allow(clippy::too_many_arguments)]
358async fn validate_then_execute_one_tool(
359    tc: &ToolCall,
360    project_root: &Path,
361    config: &KodaConfig,
362    db: &Database,
363    session_id: &str,
364    tools: &crate::tools::ToolRegistry,
365    mode: TrustMode,
366    sink: &dyn crate::engine::EngineSink,
367    cancel: CancellationToken,
368    sub_agent_cache: &SubAgentCache,
369    bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
370    caller_spawner: Option<u32>,
371) -> (String, String, bool, Option<String>) {
372    let parsed_args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
373
374    let validation_error = tools::validate::validate_with_registry(
375        tools,
376        &tc.function_name,
377        &parsed_args,
378        project_root,
379    )
380    .await;
381
382    if let Some(error) = validation_error {
383        return (
384            tc.id.clone(),
385            format!("Validation error: {error}"),
386            false,
387            None,
388        );
389    }
390
391    execute_one_tool(
392        tc,
393        project_root,
394        config,
395        db,
396        session_id,
397        tools,
398        mode,
399        sink,
400        cancel,
401        sub_agent_cache,
402        bg_agents,
403        caller_spawner,
404    )
405    .await
406}
407
408/// Run multiple tool calls concurrently and store results.
409#[allow(clippy::too_many_arguments)]
410pub(crate) async fn execute_tools_parallel(
411    tool_calls: &[ToolCall],
412    project_root: &Path,
413    config: &KodaConfig,
414    db: &Database,
415    session_id: &str,
416    tools: &crate::tools::ToolRegistry,
417    mode: TrustMode,
418    sink: &dyn crate::engine::EngineSink,
419    cancel: CancellationToken,
420    sub_agent_cache: &SubAgentCache,
421    file_tracker: &mut FileTracker,
422    bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
423    caller_spawner: Option<u32>,
424) -> Result<()> {
425    let count = tool_calls.len();
426    sink.emit(EngineEvent::Info {
427        message: format!("Running {count} tools in parallel..."),
428    });
429
430    // Launch all tool calls concurrently
431    let futures: Vec<_> = tool_calls
432        .iter()
433        .map(|tc| {
434            // #1022 B14: validate before executing. The sequential arm
435            // does this *before* approval; here every tool is already
436            // AutoApproved (see `can_parallelize`) so validate-then-execute
437            // is the right order.
438            validate_then_execute_one_tool(
439                tc,
440                project_root,
441                config,
442                db,
443                session_id,
444                tools,
445                mode,
446                sink,
447                cancel.clone(),
448                sub_agent_cache,
449                bg_agents,
450                caller_spawner,
451            )
452        })
453        .collect();
454    let results = futures_util::future::join_all(futures).await;
455
456    // Emit banner + result together so each tool's output is visually grouped
457    for (i, (tc_id, result, success, full_output)) in results.into_iter().enumerate() {
458        sink.emit(EngineEvent::ToolCallStart {
459            id: tc_id.clone(),
460            name: tool_calls[i].function_name.clone(),
461            args: serde_json::from_str(&tool_calls[i].arguments).unwrap_or_default(),
462            is_sub_agent: false,
463        });
464        record_tool_result(
465            &tool_calls[i],
466            &result,
467            success,
468            full_output.as_deref(),
469            db,
470            session_id,
471            tools.caps.tool_result_chars,
472            project_root,
473            file_tracker,
474            sink,
475        )
476        .await?;
477    }
478    Ok(())
479}
480
481/// Split a mixed batch: run parallelizable tools concurrently, then
482/// execute remaining tools sequentially.
483///
484/// This is the key optimization for mixed batches like
485/// `[InvokeAgent, InvokeAgent, Write]` — the two sub-agents run in
486/// parallel while the Write waits for confirmation.
487#[allow(clippy::too_many_arguments)]
488pub(crate) async fn execute_tools_split_batch(
489    tool_calls: &[ToolCall],
490    project_root: &Path,
491    config: &KodaConfig,
492    db: &Database,
493    session_id: &str,
494    tools: &crate::tools::ToolRegistry,
495    mode: TrustMode,
496    sink: &dyn crate::engine::EngineSink,
497    cancel: CancellationToken,
498    cmd_rx: &mut mpsc::Receiver<EngineCommand>,
499    sub_agent_cache: &SubAgentCache,
500    file_tracker: &mut FileTracker,
501    bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
502    caller_spawner: Option<u32>,
503) -> Result<()> {
504    // Partition into parallelizable vs sequential
505    let (parallel, sequential): (Vec<_>, Vec<_>) = tool_calls.iter().partition(|tc| {
506        let args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
507        matches!(
508            trust::check_tool(&tc.function_name, &args, mode, Some(project_root),),
509            ToolApproval::AutoApprove
510        )
511    });
512
513    // Run parallelizable tools concurrently (if more than one)
514    if parallel.len() > 1 {
515        sink.emit(EngineEvent::Info {
516            message: format!("Running {} tools in parallel...", parallel.len()),
517        });
518
519        let futures: Vec<_> = parallel
520            .iter()
521            .map(|tc| {
522                // #1022 B14: validate before executing. Same reasoning
523                // as `execute_tools_parallel` — every tool here is
524                // already AutoApproved.
525                validate_then_execute_one_tool(
526                    tc,
527                    project_root,
528                    config,
529                    db,
530                    session_id,
531                    tools,
532                    mode,
533                    sink,
534                    cancel.clone(),
535                    sub_agent_cache,
536                    bg_agents,
537                    caller_spawner,
538                )
539            })
540            .collect();
541        let results = futures_util::future::join_all(futures).await;
542
543        for (j, (tc_id, result, success, full_output)) in results.into_iter().enumerate() {
544            sink.emit(EngineEvent::ToolCallStart {
545                id: tc_id.clone(),
546                name: parallel[j].function_name.clone(),
547                args: serde_json::from_str(&parallel[j].arguments).unwrap_or_default(),
548                is_sub_agent: false,
549            });
550            record_tool_result(
551                parallel[j],
552                &result,
553                success,
554                full_output.as_deref(),
555                db,
556                session_id,
557                tools.caps.tool_result_chars,
558                project_root,
559                file_tracker,
560                sink,
561            )
562            .await?;
563        }
564    } else {
565        // 0–1 parallelizable tools — just run sequentially
566        for tc in &parallel {
567            let calls = std::slice::from_ref(*tc);
568            execute_tools_sequential(
569                calls,
570                project_root,
571                config,
572                db,
573                session_id,
574                tools,
575                mode,
576                sink,
577                cancel.clone(),
578                cmd_rx,
579                sub_agent_cache,
580                file_tracker,
581                bg_agents,
582                caller_spawner,
583            )
584            .await?;
585        }
586    }
587
588    // Run non-parallelizable tools sequentially
589    if !sequential.is_empty() {
590        let seq_calls: Vec<ToolCall> = sequential.into_iter().cloned().collect();
591        execute_tools_sequential(
592            &seq_calls,
593            project_root,
594            config,
595            db,
596            session_id,
597            tools,
598            mode,
599            sink,
600            cancel.clone(),
601            cmd_rx,
602            sub_agent_cache,
603            file_tracker,
604            bg_agents,
605            caller_spawner,
606        )
607        .await?;
608    }
609
610    Ok(())
611}
612
613/// Run tool calls one at a time (when confirmation is needed, or single call).
614#[allow(clippy::too_many_arguments)]
615pub(crate) async fn execute_tools_sequential(
616    tool_calls: &[ToolCall],
617    project_root: &Path,
618    config: &KodaConfig,
619    db: &Database,
620    session_id: &str,
621    tools: &crate::tools::ToolRegistry,
622    mode: TrustMode,
623    sink: &dyn crate::engine::EngineSink,
624    cancel: CancellationToken,
625    cmd_rx: &mut mpsc::Receiver<EngineCommand>,
626    sub_agent_cache: &SubAgentCache,
627    file_tracker: &mut FileTracker,
628    bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
629    caller_spawner: Option<u32>,
630) -> Result<()> {
631    for tc in tool_calls {
632        // Check for interrupt before each tool
633        if cancel.is_cancelled() {
634            sink.emit(EngineEvent::Warn {
635                message: "Interrupted".into(),
636            });
637            return Ok(());
638        }
639
640        let parsed_args: serde_json::Value =
641            serde_json::from_str(&tc.arguments).unwrap_or_default();
642
643        sink.emit(EngineEvent::ToolCallStart {
644            id: tc.id.clone(),
645            name: tc.function_name.clone(),
646            args: parsed_args.clone(),
647            is_sub_agent: false,
648        });
649
650        // AskUser: pause inference, show question in TUI, wait for typed answer.
651        // Handled here (not in execute_one_tool) because it needs sink + cmd_rx.
652        if tc.function_name == "AskUser" {
653            let answer = handle_ask_user(sink, cmd_rx, &cancel, &parsed_args).await;
654            let result = match answer {
655                Some(text) if !text.trim().is_empty() => text,
656                Some(_) => "User did not provide an answer.".into(),
657                None => return Ok(()), // cancelled
658            };
659            record_tool_result(
660                tc,
661                &result,
662                true,
663                None, // AskUser has no full_output
664                db,
665                session_id,
666                tools.caps.tool_result_chars,
667                project_root,
668                file_tracker,
669                sink,
670            )
671            .await?;
672            continue;
673        }
674
675        // Pre-flight validation: catch errors before bothering the user
676        // with an approval prompt that will inevitably fail.
677        if let Some(error) = tools::validate::validate_with_registry(
678            tools,
679            &tc.function_name,
680            &parsed_args,
681            project_root,
682        )
683        .await
684        {
685            record_tool_result(
686                tc,
687                &format!("Validation error: {error}"),
688                false,
689                None,
690                db,
691                session_id,
692                tools.caps.tool_result_chars,
693                project_root,
694                file_tracker,
695                sink,
696            )
697            .await?;
698            continue;
699        }
700
701        // Check approval for this tool call (with file ownership awareness, #465)
702        let approval = trust::check_tool_with_tracker(
703            &tc.function_name,
704            &parsed_args,
705            mode,
706            Some(project_root),
707            Some(file_tracker),
708        );
709
710        match approval {
711            ToolApproval::AutoApprove => {
712                // Execute without asking
713            }
714            ToolApproval::Blocked => {
715                // Plan mode: emit ActionBlocked event, let the client render it
716                let detail = tools::describe_action(&tc.function_name, &parsed_args);
717                let diff_preview =
718                    preview::compute(&tc.function_name, &parsed_args, project_root).await;
719                sink.emit(EngineEvent::ActionBlocked {
720                    tool_name: tc.function_name.clone(),
721                    detail: detail.clone(),
722                    preview: diff_preview,
723                });
724                db.insert_message(
725                    session_id,
726                    &Role::Tool,
727                    Some("[safe mode] Action blocked. You are in read-only mode. DO NOT retry this command. Describe what you would do instead. The user must press Shift+Tab to switch to auto or strict mode."),
728                    None,
729                    Some(&tc.id),
730                    None,
731                )
732                .await?;
733                continue;
734            }
735            ToolApproval::NeedsConfirmation => {
736                let detail = tools::describe_action(&tc.function_name, &parsed_args);
737                let diff_preview =
738                    preview::compute(&tc.function_name, &parsed_args, project_root).await;
739                let effect = crate::trust::resolve_tool_effect_with_registry(
740                    &tc.function_name,
741                    &parsed_args,
742                    tools,
743                );
744
745                match request_approval(
746                    sink,
747                    cmd_rx,
748                    &cancel,
749                    &tc.function_name,
750                    &detail,
751                    diff_preview,
752                    effect,
753                )
754                .await
755                {
756                    Some(ApprovalDecision::Approve) => {}
757                    Some(ApprovalDecision::Reject) => {
758                        db.insert_message(
759                            session_id,
760                            &Role::Tool,
761                            Some("User rejected this action."),
762                            None,
763                            Some(&tc.id),
764                            None,
765                        )
766                        .await?;
767                        continue;
768                    }
769                    Some(ApprovalDecision::RejectWithFeedback { feedback }) => {
770                        let result = format!("User rejected this action with feedback: {feedback}");
771                        db.insert_message(
772                            session_id,
773                            &Role::Tool,
774                            Some(&result),
775                            None,
776                            Some(&tc.id),
777                            None,
778                        )
779                        .await?;
780                        continue;
781                    }
782                    Some(ApprovalDecision::RejectAuto { reason }) => {
783                        // #1022 B15: distinct from Reject so the model knows
784                        // there's no human in the loop — it should adapt its
785                        // plan to the structural constraint, not ask for
786                        // clarification.
787                        let result = format!("[auto-rejected: {reason}]");
788                        db.insert_message(
789                            session_id,
790                            &Role::Tool,
791                            Some(&result),
792                            None,
793                            Some(&tc.id),
794                            None,
795                        )
796                        .await?;
797                        continue;
798                    }
799                    None => {
800                        // Cancelled
801                        return Ok(());
802                    }
803                }
804            }
805        }
806
807        let (_, result, success, full_output) = execute_one_tool(
808            tc,
809            project_root,
810            config,
811            db,
812            session_id,
813            tools,
814            mode,
815            sink,
816            cancel.clone(),
817            sub_agent_cache,
818            bg_agents,
819            caller_spawner,
820        )
821        .await;
822        record_tool_result(
823            tc,
824            &result,
825            success,
826            full_output.as_deref(),
827            db,
828            session_id,
829            tools.caps.tool_result_chars,
830            project_root,
831            file_tracker,
832            sink,
833        )
834        .await?;
835    }
836    Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::providers::ToolCall;
843
844    fn make_tool_call(name: &str) -> ToolCall {
845        ToolCall {
846            id: "t1".to_string(),
847            function_name: name.to_string(),
848            arguments: "{}".to_string(),
849            thought_signature: None,
850        }
851    }
852
853    #[test]
854    fn test_can_parallelize_read_only() {
855        let calls = vec![make_tool_call("Read"), make_tool_call("Grep")];
856        assert!(can_parallelize(
857            &calls,
858            TrustMode::Safe,
859            Path::new("/test/project"),
860            None,
861        ));
862    }
863
864    #[test]
865    fn test_cannot_parallelize_writes() {
866        let calls = vec![make_tool_call("Read"), make_tool_call("Write")];
867        assert!(!can_parallelize(
868            &calls,
869            TrustMode::Safe,
870            Path::new("/test/project"),
871            None,
872        ));
873    }
874
875    #[test]
876    fn test_cannot_parallelize_bash() {
877        // Dangerous bash command should prevent parallelization
878        let calls = vec![
879            make_tool_call("Read"),
880            ToolCall {
881                id: "t2".to_string(),
882                function_name: "Bash".to_string(),
883                arguments: r#"{"command": "rm -rf /tmp/test"}"#.to_string(),
884                thought_signature: None,
885            },
886        ];
887        assert!(!can_parallelize(
888            &calls,
889            TrustMode::Safe,
890            Path::new("/test/project"),
891            None,
892        ));
893    }
894
895    #[test]
896    fn test_can_parallelize_agents() {
897        let calls = vec![make_tool_call("InvokeAgent"), make_tool_call("InvokeAgent")];
898        assert!(can_parallelize(
899            &calls,
900            TrustMode::Safe,
901            Path::new("/test/project"),
902            None,
903        ));
904    }
905
906    #[test]
907    fn test_cannot_parallelize_same_file_edits() {
908        let calls = vec![
909            ToolCall {
910                id: "t1".to_string(),
911                function_name: "Edit".to_string(),
912                arguments: r#"{"file_path": "src/main.rs"}"#.to_string(),
913                thought_signature: None,
914            },
915            ToolCall {
916                id: "t2".to_string(),
917                function_name: "Edit".to_string(),
918                arguments: r#"{"file_path": "src/main.rs"}"#.to_string(),
919                thought_signature: None,
920            },
921        ];
922        assert!(!can_parallelize(
923            &calls,
924            TrustMode::Auto, // Auto mode would normally allow parallelization
925            Path::new("/test/project"),
926            None,
927        ));
928    }
929
930    #[test]
931    fn test_can_parallelize_different_file_edits() {
932        let calls = vec![
933            ToolCall {
934                id: "t1".to_string(),
935                function_name: "Edit".to_string(),
936                arguments: r#"{"file_path": "src/main.rs"}"#.to_string(),
937                thought_signature: None,
938            },
939            ToolCall {
940                id: "t2".to_string(),
941                function_name: "Edit".to_string(),
942                arguments: r#"{"file_path": "src/lib.rs"}"#.to_string(),
943                thought_signature: None,
944            },
945        ];
946        assert!(can_parallelize(
947            &calls,
948            TrustMode::Auto,
949            Path::new("/test/project"),
950            None,
951        ));
952    }
953
954    #[test]
955    fn test_is_mutating_tool() {
956        assert!(crate::tools::is_mutating_tool("Write"));
957        assert!(crate::tools::is_mutating_tool("Edit"));
958        assert!(crate::tools::is_mutating_tool("Delete"));
959        assert!(crate::tools::is_mutating_tool("Bash"));
960        assert!(crate::tools::is_mutating_tool("MemoryWrite"));
961        assert!(!crate::tools::is_mutating_tool("Read"));
962        assert!(!crate::tools::is_mutating_tool("List"));
963        // InvokeAgent is ReadOnly (sub-agents inherit parent's approval mode)
964        assert!(!crate::tools::is_mutating_tool("InvokeAgent"));
965    }
966
967    #[test]
968    fn test_mixed_batch_not_fully_parallelizable() {
969        let calls = vec![make_tool_call("InvokeAgent"), make_tool_call("Write")];
970        assert!(!can_parallelize(
971            &calls,
972            TrustMode::Safe,
973            Path::new("/test/project"),
974            None,
975        ));
976    }
977
978    #[test]
979    fn test_mixed_batch_fully_parallelizable_in_auto() {
980        let calls = vec![make_tool_call("InvokeAgent"), make_tool_call("Write")];
981        assert!(can_parallelize(
982            &calls,
983            TrustMode::Auto,
984            Path::new("/test/project"),
985            None,
986        ));
987    }
988
989    /// #1022 B13 regression: `can_parallelize` must use the same
990    /// approval classification the sequential dispatch loop uses,
991    /// i.e. `check_tool_with_tracker` not `check_tool`. Without the
992    /// tracker, `Delete owned.tmp` looks like `NeedsConfirmation`
993    /// (because Delete is Destructive in Safe mode); with the tracker
994    /// it auto-approves (#465: Koda created it, Koda removes it). The
995    /// bug spuriously refused parallelization for batches that
996    /// included a Delete of a file Koda created earlier in the
997    /// session — pure perf regression, but the kind of
998    /// classification mismatch that compounds.
999    #[tokio::test]
1000    async fn test_can_parallelize_delete_owned_file_uses_tracker() {
1001        let dir = tempfile::TempDir::new().unwrap();
1002        let db = crate::db::Database::open(&dir.path().join("test.db"))
1003            .await
1004            .unwrap();
1005        let mut tracker = crate::file_tracker::FileTracker::new("test-sess", db).await;
1006        // Canonicalize root so the tracked path matches what
1007        // `resolve_file_path_from_args` produces at lookup time — on
1008        // macOS, tempdirs live under `/var/folders/...` but
1009        // `canonicalize()` resolves to `/private/var/folders/...`.
1010        // Production code goes through canonicalization on both write
1011        // and lookup, so we mirror that here.
1012        let root = dir.path().join("project");
1013        std::fs::create_dir_all(&root).unwrap();
1014        let root = root.canonicalize().unwrap();
1015        let owned_abs = root.join("temp_output.md");
1016        std::fs::write(&owned_abs, "").unwrap();
1017        tracker
1018            .track_created(owned_abs.canonicalize().unwrap())
1019            .await;
1020
1021        // Batch: Read other.txt + Delete owned.tmp. Both auto-approve
1022        // when the tracker is consulted; without the tracker the
1023        // Delete is misclassified as NeedsConfirmation.
1024        let calls = vec![
1025            ToolCall {
1026                id: "t1".to_string(),
1027                function_name: "Read".to_string(),
1028                arguments: r#"{"path": "other.txt"}"#.to_string(),
1029                thought_signature: None,
1030            },
1031            ToolCall {
1032                id: "t2".to_string(),
1033                function_name: "Delete".to_string(),
1034                arguments: r#"{"path": "temp_output.md"}"#.to_string(),
1035                thought_signature: None,
1036            },
1037        ];
1038
1039        // Bug repro: without the tracker, Safe mode refuses
1040        // parallelization because Delete → NeedsConfirmation.
1041        assert!(
1042            !can_parallelize(&calls, TrustMode::Safe, &root, None),
1043            "sanity: without tracker, Delete must look like NeedsConfirmation"
1044        );
1045
1046        // Fix proof: with the tracker, Delete of owned file
1047        // auto-approves → batch is parallelizable.
1048        assert!(
1049            can_parallelize(&calls, TrustMode::Safe, &root, Some(&tracker)),
1050            "with tracker, Delete of Koda-owned file must be \
1051             parallel-eligible (matches sequential path classification)"
1052        );
1053    }
1054}