Skip to main content

mermaid_cli/domain/
cmd.rs

1//! Everything the reducer asks the outside world to do.
2//!
3//! `Cmd` values are inert data structures. The reducer returns them
4//! alongside each new `State`; `effect::EffectRunner::dispatch` then
5//! turns them into real work (spawning tokio tasks, writing files,
6//! hitting HTTP endpoints, killing processes). The reducer itself
7//! never performs any I/O.
8//!
9//! This is the "effects as data" pattern from Elm/Redux. Three
10//! payoffs this rewrite relies on:
11//!
12//!   1. **Testable reducer.** Assertions are `state, cmds = update(...)
13//!      ; assert_eq!(cmds[0], Cmd::CallModel { … })`. No tokio, no
14//!      mocks, no filesystem.
15//!   2. **Uniform middleware.** Retry, tracing, rate-limiting wrap the
16//!      dispatcher once instead of being re-implemented per adapter.
17//!   3. **Replayable sessions.** `--record` dumps every `Msg`; the
18//!      effects the reducer asked for are fully determined by the Msg
19//!      log + initial state, so `--replay` is a pure fold.
20
21use std::collections::HashMap;
22use std::path::PathBuf;
23
24use crate::app::McpServerConfig;
25use crate::models::ChatMessage;
26use crate::models::ReasoningLevel;
27use crate::models::tool_call::ToolCall as ModelToolCall;
28use crate::runtime::{SafetyMode, TaskStatus};
29use crate::session::ConversationHistory;
30
31use super::question::QuestionResolution;
32use super::state::ApprovalChoice;
33
34use super::compaction::{CompactionArchive, CompactionRecord, CompactionRequest};
35use super::ids::{ToolCallId, TurnId};
36use super::runtime::ManagedProcess;
37
38/// A single side-effect request. Most variants are one-shot; `CallModel`
39/// and `ExecuteTool` spawn long-running tasks inside a per-turn
40/// `TurnScope`.
41// Several variants legitimately carry large payloads (a full
42// `ConversationHistory` / `ChatRequest`). Boxing them would churn ~20
43// construction + match sites for no real gain — `Cmd` values are short-lived
44// and moved, not stored in bulk.
45#[allow(clippy::large_enum_variant)]
46#[derive(Debug, Clone)]
47pub enum Cmd {
48    // ── Model + tool execution (the scope-spawning variants) ────────
49    /// Dispatch the next chat request. Effect runner maps this onto
50    /// `ModelProvider::chat` for the session's active provider.
51    CallModel { turn: TurnId, request: ChatRequest },
52    /// Generate a compact context checkpoint without continuing into
53    /// a normal assistant turn.
54    CompactConversation {
55        turn: TurnId,
56        request: CompactionRequest,
57    },
58    /// Run one tool in parallel with any other tools in the same turn.
59    /// The runner wires `ExecContext::token` to the turn's scope so
60    /// `Cmd::CancelScope` aborts them all at once.
61    ///
62    /// `model_id` is the active session's model id at the moment this
63    /// tool call was emitted. The runner passes it into `ExecContext`
64    /// so tools like `SubagentTool` can spawn children against the
65    /// same provider the parent is using.
66    ExecuteTool {
67        turn: TurnId,
68        call_id: ToolCallId,
69        source: ModelToolCall,
70        model_id: String,
71        /// Effective live safety mode at the moment this call was emitted
72        /// (`state.session.safety_mode`, floored to `ReadOnly` while a plan
73        /// is being drafted). The runner builds the policy gate / Auto
74        /// classifier from this rather than the static config.
75        safety_mode: SafetyMode,
76        /// `Some(path)` while the session is in plan mode: the one path the
77        /// policy gate exempts from the read-only floor, and the flag the
78        /// plan carve-outs (memory writes, known-safe builds) key on.
79        plan_file: Option<std::path::PathBuf>,
80        /// LIVE per-category plan permission levels (`/plan config` edits
81        /// them mid-session; the startup `Config` snapshot in `ExecContext`
82        /// would go stale). Only consulted while `plan_file` is `Some`.
83        plan_permissions: crate::app::PlanPermissions,
84        /// Context-window fill at dispatch, when known. `exit_plan_mode`
85        /// shows it on the clear-context option so the tradeoff is legible.
86        context_percent: Option<u8>,
87        /// The user's stated intent for the turn (latest user message),
88        /// passed to the Auto-mode classifier as alignment context.
89        intent: Option<String>,
90        /// Conversation id at dispatch — checkpoint-anchoring provenance
91        /// (rides into `ExecContext` and onto any checkpoint this call takes).
92        session_id: String,
93        /// Conversation length (`messages().len()`) at dispatch. A fork at
94        /// user-message index `k` discards checkpoints with index > k.
95        message_index: usize,
96        /// Per-session scratch directory (`Session::scratchpad`) at dispatch.
97        /// `None` until `Msg::ScratchpadReady` lands — the runner threads it
98        /// into `ExecContext::scratchpad` for tools to use as temp space.
99        scratchpad: Option<PathBuf>,
100    },
101    /// Cancel every task in the given turn's `TurnScope`. After the
102    /// scope's `JoinSet` drains (bounded by a ~2s timeout), the runner
103    /// emits a single `Msg::TurnCancelled(turn)` — that, not
104    /// `Msg::StreamDone`, is the terminal event that lets the reducer
105    /// transition `Cancelling → Idle`. A same-id `Msg::StreamDone` that
106    /// races the drain does NOT end the turn: `handle_stream_done` only
107    /// acts on a `Generating` turn and restores the prior state
108    /// (`Cancelling`) otherwise, so `TurnCancelled` stays the one
109    /// terminal signal for a cancel.
110    CancelScope(TurnId),
111    /// Ctrl+B: signal the turn's scope to BACKGROUND (not cancel) its running
112    /// work. The scope's background token fires; detachable tools (execute_
113    /// command) move their child to a background process and return. The scope
114    /// is left intact (unlike `CancelScope`).
115    BackgroundScope(TurnId),
116
117    /// Resolve an inline approval prompt: deliver the user's decision to the
118    /// parked tool task via the `ApprovalBroker`. NOT turn-scoped — it's a
119    /// fire-and-forget to the broker (the tool task it unblocks is the
120    /// turn-scoped work).
121    ResolveApproval {
122        call_id: ToolCallId,
123        decision: ApprovalChoice,
124    },
125
126    /// Resolve an `ask_user_question` prompt: deliver the user's answers to the
127    /// parked tool task via the `QuestionBroker`. Like `ResolveApproval`, a
128    /// fire-and-forget to the broker (not turn-scoped).
129    ResolveQuestion {
130        call_id: ToolCallId,
131        resolution: QuestionResolution,
132    },
133
134    /// Overwrite the effect-side `TaskBroker` store. Emitted where the
135    /// reducer changes checklist truth outside the broker's own publish
136    /// cycle: rewind/fork and `/clear` (both clear it) and `--replay`
137    /// re-seeding. Fire-and-forget to the broker, not turn-scoped.
138    SyncTaskStore(crate::domain::tasks::TaskStore),
139    /// Persist the `[plan]` table to the user config file (the `/plan
140    /// config` picker edits live state; this writes it through the
141    /// key-scoped updater so unrelated keys and defaults stay unfrozen).
142    PersistPlanConfig(crate::app::PlanConfig),
143
144    /// A user `/tasks` edit. Routed through the effect runner to the
145    /// `TaskBroker` (the single writer) instead of mutating reducer state
146    /// directly, so a concurrent tool call can't clobber it; the broker's
147    /// `Msg::TasksUpdated` publish brings the result back.
148    UserTaskEdit(crate::domain::tasks::UserTaskEdit),
149
150    /// A task transitioned to completed: run the gated `task_completed`
151    /// plugin hook. A denying hook flips the task back to in_progress via
152    /// the broker and queues a notice for the model's next turn.
153    NotifyTaskCompleted {
154        task: crate::domain::tasks::TaskItem,
155        completed: u32,
156        total: u32,
157    },
158
159    /// Materialize the per-session scratch directory for this conversation
160    /// id (creating it under the private temp dir + stamping its pid lock),
161    /// then report the path back via `Msg::ScratchpadReady`. Emitted at
162    /// startup and whenever the conversation id changes (`/clear`, `/load`,
163    /// rewind fork). The handler also opportunistically sweeps stale sibling
164    /// scratchpads. Fire-and-forget, not turn-scoped.
165    EnsureScratchpad { session_id: String },
166
167    /// `/scratchpad` — list the session scratch directory's contents back
168    /// into the transcript (bounded ASCII listing via `Msg::RuntimeText`).
169    /// Only emitted while `Session::scratchpad` is stamped; carries the
170    /// path so the effect never re-derives it. Fire-and-forget.
171    ListScratchpad { path: PathBuf },
172
173    // ── Persistence ─────────────────────────────────────────────────
174    /// Save the current conversation to disk. No-op if unchanged since
175    /// last save (effect-side idempotence).
176    SaveConversation(ConversationHistory),
177    /// Persist the raw messages removed by a compaction, then the compacted
178    /// (message-stripped) conversation. Both are written by ONE effect task,
179    /// archive first — only overwriting the conversation if the archive
180    /// persisted — so a failed/lagging archive can never lose messages while
181    /// the stripped conversation is saved over the old one.
182    SaveCompactionArchive {
183        archive: CompactionArchive,
184        record: CompactionRecord,
185        conversation: ConversationHistory,
186    },
187    /// Persist a daemon-visible background process record.
188    SaveProcess(ManagedProcess),
189    /// Persist the active model ID as `last_used_model`.
190    PersistLastModel(String),
191    /// Persist reasoning level tied to a specific model ID.
192    PersistReasoningFor {
193        model_id: String,
194        level: ReasoningLevel,
195    },
196    /// Persist (or clear, when `num_ctx` is `None`) a per-model Ollama `num_ctx`
197    /// override set via `/context <n>`/`max`/`auto`.
198    PersistOllamaNumCtxFor {
199        model_id: String,
200        num_ctx: Option<u32>,
201    },
202    /// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
203    PersistOllamaOffload(bool),
204    /// Persist the `/theme` choice as `ui.theme` in the user config file.
205    PersistUiTheme(crate::app::ThemeChoice),
206    /// List saved memories; emits `Msg::RuntimeText` with the rendered list.
207    ListMemory,
208    /// Save free-text to private memory; emits `Msg::MemoryChanged` + status.
209    RememberMemory { text: String },
210    /// Delete a memory by name/id; emits `Msg::MemoryChanged` + status.
211    ForgetMemory { id: String },
212    /// Model-assisted prune of duplicate/obsolete memories, reversible via a
213    /// checkpoint. Emits `Msg::RuntimeText` (the report) + `Msg::MemoryChanged`.
214    ConsolidateMemory { model_id: String },
215    /// Load a specific conversation by ID and emit
216    /// `Msg::ConversationLoaded`. Reducer consumes that event to
217    /// replace the current session.
218    LoadConversation(String),
219    /// Scan the conversations directory for the /load picker. Emits
220    /// `Msg::ConversationsListed` with one `ConversationSummary` per
221    /// saved session (newest first). The reducer transitions to
222    /// `UiMode::ConversationList` and the render shows the picker.
223    ListConversations,
224    /// Discover every model the user could switch to, for the `/model` picker.
225    /// Emits `Msg::AvailableModelsListed`.
226    ///
227    /// Best-effort and strictly read-only: a dead Ollama is NOT started (a
228    /// cloud-only user who stopped it to free VRAM must not have it
229    /// resurrected by opening a list) and an unreachable provider is skipped
230    /// rather than failing the whole listing. Provider keys are resolved
231    /// inside the handler, never carried on this Cmd.
232    ListAvailableModels,
233    /// Walk the project for the @-mention file picker (gitignore-aware,
234    /// capped, sorted). Emits `Msg::ProjectFilesListed` with relative paths
235    /// (directories carry a trailing `/`).
236    ListProjectFiles,
237    /// List durable daemon/runtime tasks.
238    ListRuntimeTasks { limit: usize },
239    /// Load one durable daemon/runtime task and its timeline.
240    LoadRuntimeTask { id: String },
241    /// List durable daemon/runtime background processes.
242    ListRuntimeProcesses { limit: usize },
243    /// Print one durable process log into the conversation.
244    ShowRuntimeProcessLogs { id: String },
245    /// Stop a durable process by pid.
246    StopRuntimeProcess { id: String },
247    /// Cancel a detached background subagent (`None` = all of them). The
248    /// effect layer fires the kill token held by the `SubagentSpawner`; the
249    /// dying child reports back via `Msg::BackgroundAgentFinished`.
250    KillBackgroundAgent { agent_id: Option<String> },
251    /// Restart a durable process using its recorded command/cwd.
252    RestartRuntimeProcess { id: String },
253    /// Open a URL, path, or process target.
254    OpenRuntimeTarget { target: String },
255    /// Show listening ports.
256    ShowRuntimePorts,
257    /// List pending approval records.
258    ListRuntimeApprovals,
259    /// Mark one approval as approved or denied.
260    DecideRuntimeApproval { id: String, decision: String },
261    /// List restore checkpoints.
262    ListRuntimeCheckpoints { limit: usize },
263    /// Query checkpoints of `session_id` anchored strictly past
264    /// `message_index` — fired by rewind/fork so the reducer can tell the
265    /// user which file checkpoints the discarded timeline left behind.
266    /// Replies with `Msg::ForkCheckpointsFound`.
267    ListForkCheckpoints {
268        session_id: String,
269        message_index: usize,
270    },
271    /// List installed plugins.
272    ListRuntimePlugins,
273    /// Update one durable task's status.
274    UpdateRuntimeTaskStatus {
275        id: String,
276        status: TaskStatus,
277        final_report: Option<String>,
278    },
279    /// Create a shadow checkpoint for explicit paths.
280    CreateRuntimeCheckpoint { paths: Vec<PathBuf> },
281    /// Restore files from a shadow checkpoint.
282    RestoreRuntimeCheckpoint { id: String },
283    /// Show provider/model capability information.
284    ShowRuntimeModelInfo { model: String },
285
286    // ── MCP lifecycle ───────────────────────────────────────────────
287    /// Start every configured MCP server; each one emits
288    /// `Msg::McpServerReady` or `Msg::McpServerErrored` as it comes up.
289    InitMcpServers(HashMap<String, McpServerConfig>),
290    /// Stop a running server (e.g. config was removed, or app quit).
291    StopMcpServer { name: String },
292
293    // ── Ollama helpers ──────────────────────────────────────────────
294    /// `ollama pull <model>` with progress → `Msg::ModelPullFinished`.
295    PullOllamaModel { model: String },
296    /// Probe whether `model_id` advertises the `vision` capability (Ollama
297    /// `/api/show`) → `Msg::ProviderVisionResolved`. `warn` rides through so the
298    /// reducer nags only when an image is actually in play (a paste, or a
299    /// `/model` switch with an image already staged); the probe always refreshes
300    /// the capability snapshot regardless.
301    ProbeVision { model_id: String, warn: bool },
302
303    // ── UI side-effects (cross-process) ─────────────────────────────
304    /// `xdg-open` / `open` / `start` on a file path. Used by the
305    /// image-paste preview and the "open in editor" affordance.
306    OpenInSystem(PathBuf),
307
308    // ── Attachments ─────────────────────────────────────────────────
309    /// Persist a pasted image to a temp file so the TUI can open it
310    /// via `OpenInSystem`. Emits no follow-up Msg on success; failure
311    /// is a log-and-drop.
312    WriteImageToTemp {
313        path: PathBuf,
314        bytes: Vec<u8>,
315        format: String,
316    },
317
318    /// Read the system clipboard on a blocking task. The per-platform
319    /// dispatch (xclip / wl-paste / pngpaste / PowerShell) can block
320    /// for hundreds of ms on macOS via osascript, so it never runs on
321    /// the reducer thread. Always emits `Msg::ClipboardRead(..)` —
322    /// `Image`/`Text` on success, `Empty`/`Error` otherwise — so the
323    /// paste-race guard sees every read resolve.
324    ReadClipboard,
325
326    /// Write text to the system clipboard on a blocking task (mirrors
327    /// `ReadClipboard`'s per-platform dispatch). Used by in-app drag-select
328    /// copy. Emits a `Msg::TransientStatus` ("Copied N chars" / failure).
329    CopyToClipboard(String),
330
331    // ── Terminal lifecycle ──────────────────────────────────────────
332    /// Suspend the TUI and open `$VISUAL`/`$EDITOR` on the input draft
333    /// (Ctrl+O / `/editor`). Intercepted by the interactive run loop — it
334    /// owns the terminal and event stream — and never reaches the effect
335    /// runner there; headless drivers log-and-drop it. The round-trip
336    /// resolves as `Msg::EditorReturned`.
337    ComposeInEditor { text: String },
338    /// Exit the main loop. No reply message — the loop observes
339    /// `state.should_exit` after the reducer returns and breaks out.
340    Exit,
341    /// Write the OSC 2 terminal-title sequence. Reducer diffs
342    /// against `ui.last_title_dispatched` so this only fires on
343    /// actual title changes, not every frame.
344    SetTerminalTitle(String),
345    /// Ring the terminal bell (BEL) to draw the user's attention — emitted on
346    /// run completion / a pending approval only while the terminal is
347    /// unfocused. Suppressed in headless mode (same gate as the title).
348    AlertUser,
349}
350
351/// Inputs a model needs to generate a turn. Built by the reducer from
352/// `Session` + `Settings` + current `MERMAID.md` context. Pure data —
353/// no provider-specific knowledge here (that's in
354/// `providers::model::*::chat`).
355/// `Default` exists so adding a field costs one line here instead of a
356/// mechanical edit in every construction site across providers, the CLI and
357/// the tests (adding `suppressed_builtin_tools` took 15). Use
358/// `..ChatRequest::default()` for the fields a caller does not care about.
359#[derive(Debug, Clone, Default)]
360pub struct ChatRequest {
361    pub model_id: String,
362    pub messages: Vec<ChatMessage>,
363    pub system_prompt: String,
364    /// `MERMAID.md` content to suffix onto the system prompt. `None` if
365    /// no file was loaded for this project.
366    pub instructions: Option<String>,
367    pub reasoning: ReasoningLevel,
368    pub temperature: f32,
369    pub max_tokens: usize,
370    /// Tool definitions advertised to the model. Combination of the
371    /// built-in tool set + any advertised MCP tools from `McpState`.
372    pub tools: Vec<ToolDefinition>,
373    /// Per-model Ollama `num_ctx` override (`/context <n>`/`max`), or `None` to
374    /// auto-fit. The one provider-specific knob that rides on the request, the
375    /// same way `reasoning` does — so a live `/context` change applies on the
376    /// next turn without rebuilding the cached provider. Ignored by non-Ollama
377    /// providers.
378    pub ollama_num_ctx: Option<u32>,
379    /// Live Ollama RAM-offload toggle (`/context offload on|off`). Rides on the
380    /// request like `ollama_num_ctx` so a toggle applies on the next turn without
381    /// rebuilding the cached provider (whose `config` is frozen at startup).
382    /// `None` falls back to the persisted `[ollama] allow_ram_offload`. Ignored
383    /// by non-Ollama providers.
384    pub ollama_allow_ram_offload: Option<bool>,
385    /// The model's real context window, filled by the effect layer from
386    /// cache-first live discovery (`resolve_context_window`) just before
387    /// dispatch. The reducer always initializes it `None`; `None` at the
388    /// adapter means "unknown" (no window clamp).
389    pub resolved_context_window: Option<usize>,
390    /// The model's real per-response output ceiling, filled alongside
391    /// `resolved_context_window`. `None` means unknown — adapters that
392    /// require a concrete `max_tokens` (Anthropic) fall back to a floor.
393    pub resolved_max_output: Option<usize>,
394    /// `mermaid run --output-schema`: the JSON Schema the FORMATTING turn
395    /// must conform to. Set only on that dedicated turn (never during the
396    /// agentic loop — Gemini rejects tools+schema, Ollama's `format` would
397    /// degrade tool calling). When `Some`, the request carries no tools:
398    /// the reducer sends none and the effect runner skips the built-ins.
399    pub output_schema: Option<serde_json::Value>,
400    /// Pause automatic threshold compaction for this turn. Set from
401    /// `RuntimeState::auto_compact_suppressed` after an auto-compaction
402    /// failure, so a chronically failing summarizer doesn't burn a model call
403    /// every turn. Rides on the request like `ollama_num_ctx` because the
404    /// effect preflight sees only the request, never `RuntimeState`. Cleared
405    /// by a successful compaction, a manual `/compact`, or a conversation
406    /// switch.
407    pub suppress_auto_compact: bool,
408    /// Built-in tool names the effect layer must NOT advertise on this
409    /// request. Mirrors the `output_schema` empty-tools gate: the reducer
410    /// (which knows the mode) decides, the effect layer (which owns the
411    /// registry) enforces. Plan mode hides the checklist WRITERS here —
412    /// advertising a tool whose description says "create the full initial
413    /// plan" while the gate hard-errors it invites exactly that call.
414    pub suppressed_builtin_tools: Vec<&'static str>,
415}
416
417/// Provider-agnostic tool definition sent in the request. Concrete
418/// adapters (`providers::model::ollama`, etc.) translate this into
419/// whatever wire shape their API expects.
420#[derive(Debug, Clone)]
421pub struct ToolDefinition {
422    pub name: String,
423    pub description: String,
424    pub input_schema: serde_json::Value,
425}
426
427impl ToolDefinition {
428    /// Wire shape: `{type: "function", function: {name, description,
429    /// parameters}}`. This is the OpenAI / Ollama Chat Completions
430    /// format; Anthropic and Gemini adapters translate further from
431    /// here. Single-canonical-shape keeps adapters from drifting.
432    pub fn to_openai_json(&self) -> serde_json::Value {
433        serde_json::json!({
434            "type": "function",
435            "function": {
436                "name": self.name,
437                "description": self.description,
438                "parameters": self.input_schema,
439            }
440        })
441    }
442}
443
444impl Cmd {
445    /// Human-readable tag, for tracing + replay logs. Stable across
446    /// refactors (tests assert against it).
447    pub fn tag(&self) -> &'static str {
448        match self {
449            Cmd::CallModel { .. } => "call_model",
450            Cmd::CompactConversation { .. } => "compact_conversation",
451            Cmd::ExecuteTool { .. } => "execute_tool",
452            Cmd::CancelScope(_) => "cancel_scope",
453            Cmd::BackgroundScope(_) => "background_scope",
454            Cmd::ResolveApproval { .. } => "resolve_approval",
455            Cmd::ResolveQuestion { .. } => "resolve_question",
456            Cmd::SyncTaskStore(_) => "sync_task_store",
457            Cmd::PersistPlanConfig(_) => "persist_plan_config",
458            Cmd::UserTaskEdit(_) => "user_task_edit",
459            Cmd::NotifyTaskCompleted { .. } => "notify_task_completed",
460            Cmd::EnsureScratchpad { .. } => "ensure_scratchpad",
461            Cmd::ListScratchpad { .. } => "list_scratchpad",
462            Cmd::SaveConversation(_) => "save_conversation",
463            Cmd::SaveCompactionArchive { .. } => "save_compaction_archive",
464            Cmd::SaveProcess(_) => "save_process",
465            Cmd::PersistLastModel(_) => "persist_last_model",
466            Cmd::PersistReasoningFor { .. } => "persist_reasoning_for",
467            Cmd::PersistOllamaNumCtxFor { .. } => "persist_ollama_num_ctx_for",
468            Cmd::PersistOllamaOffload(_) => "persist_ollama_offload",
469            Cmd::PersistUiTheme(_) => "persist_ui_theme",
470            Cmd::ListMemory => "list_memory",
471            Cmd::RememberMemory { .. } => "remember_memory",
472            Cmd::ForgetMemory { .. } => "forget_memory",
473            Cmd::ConsolidateMemory { .. } => "consolidate_memory",
474            Cmd::LoadConversation(_) => "load_conversation",
475            Cmd::ListConversations => "list_conversations",
476            Cmd::ListAvailableModels => "list_available_models",
477            Cmd::ListProjectFiles => "list_project_files",
478            Cmd::ListRuntimeTasks { .. } => "list_runtime_tasks",
479            Cmd::LoadRuntimeTask { .. } => "load_runtime_task",
480            Cmd::ListRuntimeProcesses { .. } => "list_runtime_processes",
481            Cmd::ShowRuntimeProcessLogs { .. } => "show_runtime_process_logs",
482            Cmd::StopRuntimeProcess { .. } => "stop_runtime_process",
483            Cmd::KillBackgroundAgent { .. } => "kill_background_agent",
484            Cmd::RestartRuntimeProcess { .. } => "restart_runtime_process",
485            Cmd::OpenRuntimeTarget { .. } => "open_runtime_target",
486            Cmd::ShowRuntimePorts => "show_runtime_ports",
487            Cmd::ListRuntimeApprovals => "list_runtime_approvals",
488            Cmd::DecideRuntimeApproval { .. } => "decide_runtime_approval",
489            Cmd::ListRuntimeCheckpoints { .. } => "list_runtime_checkpoints",
490            Cmd::ListForkCheckpoints { .. } => "list_fork_checkpoints",
491            Cmd::ListRuntimePlugins => "list_runtime_plugins",
492            Cmd::UpdateRuntimeTaskStatus { .. } => "update_runtime_task_status",
493            Cmd::CreateRuntimeCheckpoint { .. } => "create_runtime_checkpoint",
494            Cmd::RestoreRuntimeCheckpoint { .. } => "restore_runtime_checkpoint",
495            Cmd::ShowRuntimeModelInfo { .. } => "show_runtime_model_info",
496            Cmd::InitMcpServers(_) => "init_mcp_servers",
497            Cmd::StopMcpServer { .. } => "stop_mcp_server",
498            Cmd::PullOllamaModel { .. } => "pull_ollama_model",
499            Cmd::ProbeVision { .. } => "probe_vision",
500            Cmd::OpenInSystem(_) => "open_in_system",
501            Cmd::WriteImageToTemp { .. } => "write_image_to_temp",
502            Cmd::ReadClipboard => "read_clipboard",
503            Cmd::CopyToClipboard(_) => "copy_to_clipboard",
504            Cmd::ComposeInEditor { .. } => "compose_in_editor",
505            Cmd::Exit => "exit",
506            Cmd::SetTerminalTitle(_) => "set_terminal_title",
507            Cmd::AlertUser => "alert_user",
508        }
509    }
510
511    /// True iff this command needs to run inside a `TurnScope` so it
512    /// can be cancelled by `Cmd::CancelScope`. The effect runner uses
513    /// this to decide between "spawn into `JoinSet`" and "spawn detached".
514    pub fn is_turn_scoped(&self) -> bool {
515        matches!(
516            self,
517            Cmd::CallModel { .. } | Cmd::CompactConversation { .. } | Cmd::ExecuteTool { .. }
518        )
519    }
520
521    /// The `TurnId` of the scope this command would spawn fresh work
522    /// *into*. Only the scope-spawning variants return `Some` (the same
523    /// set as `is_turn_scoped`); the scope-control variants
524    /// (`CancelScope` / `BackgroundScope`) act on an existing scope
525    /// rather than populating one with new work, so they return `None`.
526    ///
527    /// The effect runner uses this to refuse spawning fresh work for a
528    /// turn it has already cancelled (tombstoned) — a stray post-cancel
529    /// `CallModel`/`ExecuteTool`/`CompactConversation` would otherwise
530    /// resurrect an un-cancelled scope via `scope_mut`'s `or_insert_with`
531    /// (F38). `CancelScope` must keep working on a tombstoned turn, which
532    /// is exactly why it is excluded here.
533    pub fn scope_turn(&self) -> Option<TurnId> {
534        match self {
535            Cmd::CallModel { turn, .. }
536            | Cmd::CompactConversation { turn, .. }
537            | Cmd::ExecuteTool { turn, .. } => Some(*turn),
538            _ => None,
539        }
540    }
541
542    /// For traces + the `--record` file — some `Cmd` payloads are huge
543    /// (think `ChatRequest::messages`). This returns a compact
544    /// identifier that doesn't dump the full payload.
545    pub fn summary(&self) -> String {
546        match self {
547            Cmd::CallModel { turn, request } => format!(
548                "call_model(turn={}, model={}, msgs={})",
549                turn,
550                request.model_id,
551                request.messages.len()
552            ),
553            Cmd::CompactConversation { turn, request } => format!(
554                "compact_conversation(turn={}, model={}, trigger={}, msgs={})",
555                turn,
556                request.chat.model_id,
557                request.trigger.as_str(),
558                request.chat.messages.len()
559            ),
560            Cmd::ExecuteTool {
561                turn,
562                call_id,
563                source,
564                ..
565            } => format!(
566                "execute_tool(turn={}, call={}, fn={})",
567                turn, call_id, source.function.name
568            ),
569            Cmd::CancelScope(turn) => format!("cancel_scope(turn={})", turn),
570            Cmd::BackgroundScope(turn) => format!("background_scope(turn={})", turn),
571            Cmd::ResolveApproval { call_id, decision } => {
572                format!("resolve_approval(call={}, {:?})", call_id, decision)
573            },
574            Cmd::ResolveQuestion {
575                call_id,
576                resolution,
577            } => {
578                let kind = match resolution {
579                    QuestionResolution::Answered { answers, .. } => {
580                        format!("answered({})", answers.len())
581                    },
582                    QuestionResolution::Dismissed => "dismissed".to_string(),
583                    QuestionResolution::Reformulate => "reformulate".to_string(),
584                };
585                format!("resolve_question(call={}, {})", call_id, kind)
586            },
587            Cmd::PersistPlanConfig(_) => "persist_plan_config".to_string(),
588            Cmd::SyncTaskStore(store) => {
589                format!("sync_task_store(tasks={})", store.tasks.len())
590            },
591            Cmd::UserTaskEdit(edit) => format!("user_task_edit({:?})", edit),
592            Cmd::NotifyTaskCompleted {
593                task,
594                completed,
595                total,
596            } => format!(
597                "notify_task_completed(id={}, {}/{})",
598                task.id, completed, total
599            ),
600            Cmd::EnsureScratchpad { session_id } => {
601                format!("ensure_scratchpad(session={})", session_id)
602            },
603            Cmd::ListScratchpad { path } => {
604                format!("list_scratchpad({})", path.display())
605            },
606            Cmd::SaveConversation(c) => format!("save_conversation(id={})", c.id),
607            Cmd::SaveCompactionArchive {
608                archive, record, ..
609            } => format!(
610                "save_compaction_archive(conversation={}, id={})",
611                archive.conversation_id, record.id
612            ),
613            Cmd::SaveProcess(p) => format!("save_process(id={}, pid={})", p.id, p.pid),
614            Cmd::PersistLastModel(m) => format!("persist_last_model({})", m),
615            Cmd::PersistReasoningFor { model_id, level } => {
616                format!("persist_reasoning_for({}, {:?})", model_id, level)
617            },
618            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
619                format!("persist_ollama_num_ctx_for({}, {:?})", model_id, num_ctx)
620            },
621            Cmd::PersistOllamaOffload(enabled) => {
622                format!("persist_ollama_offload({})", enabled)
623            },
624            Cmd::PersistUiTheme(theme) => format!("persist_ui_theme({})", theme.as_str()),
625            Cmd::ListMemory => "list_memory".to_string(),
626            Cmd::RememberMemory { .. } => "remember_memory".to_string(),
627            Cmd::ForgetMemory { .. } => "forget_memory".to_string(),
628            Cmd::ConsolidateMemory { .. } => "consolidate_memory".to_string(),
629            Cmd::LoadConversation(id) => format!("load_conversation({})", id),
630            Cmd::ListConversations => "list_conversations".to_string(),
631            Cmd::ListAvailableModels => "list_available_models".to_string(),
632            Cmd::ListProjectFiles => "list_project_files".to_string(),
633            Cmd::ListRuntimeTasks { limit } => format!("list_runtime_tasks(limit={})", limit),
634            Cmd::LoadRuntimeTask { id } => format!("load_runtime_task({})", id),
635            Cmd::ListRuntimeProcesses { limit } => {
636                format!("list_runtime_processes(limit={})", limit)
637            },
638            Cmd::ShowRuntimeProcessLogs { id } => format!("show_runtime_process_logs({})", id),
639            Cmd::StopRuntimeProcess { id } => format!("stop_runtime_process({})", id),
640            Cmd::KillBackgroundAgent { agent_id } => format!(
641                "kill_background_agent({})",
642                agent_id.as_deref().unwrap_or("all")
643            ),
644            Cmd::RestartRuntimeProcess { id } => format!("restart_runtime_process({})", id),
645            Cmd::OpenRuntimeTarget { target } => format!("open_runtime_target({})", target),
646            Cmd::ShowRuntimePorts => "show_runtime_ports".to_string(),
647            Cmd::ListRuntimeApprovals => "list_runtime_approvals".to_string(),
648            Cmd::DecideRuntimeApproval { id, decision } => {
649                format!("decide_runtime_approval({}, {})", id, decision)
650            },
651            Cmd::ListForkCheckpoints {
652                session_id,
653                message_index,
654            } => {
655                format!("list_fork_checkpoints({session_id} > {message_index})")
656            },
657            Cmd::ListRuntimeCheckpoints { limit } => {
658                format!("list_runtime_checkpoints(limit={})", limit)
659            },
660            Cmd::ListRuntimePlugins => "list_runtime_plugins".to_string(),
661            Cmd::UpdateRuntimeTaskStatus { id, status, .. } => {
662                format!("update_runtime_task_status({}, {})", id, status)
663            },
664            Cmd::CreateRuntimeCheckpoint { paths } => {
665                format!("create_runtime_checkpoint(n={})", paths.len())
666            },
667            Cmd::RestoreRuntimeCheckpoint { id } => format!("restore_runtime_checkpoint({})", id),
668            Cmd::ShowRuntimeModelInfo { model } => format!("show_runtime_model_info({})", model),
669            Cmd::InitMcpServers(m) => format!("init_mcp_servers(n={})", m.len()),
670            Cmd::StopMcpServer { name } => format!("stop_mcp_server({})", name),
671            Cmd::PullOllamaModel { model } => format!("pull_ollama_model({})", model),
672            Cmd::ProbeVision { model_id, warn } => format!("probe_vision({model_id}, warn={warn})"),
673            Cmd::OpenInSystem(p) => format!("open_in_system({})", p.display()),
674            Cmd::WriteImageToTemp {
675                path,
676                format,
677                bytes,
678            } => format!(
679                "write_image_to_temp(path={}, fmt={}, n={})",
680                path.display(),
681                format,
682                bytes.len()
683            ),
684            Cmd::ReadClipboard => "read_clipboard".to_string(),
685            Cmd::CopyToClipboard(t) => format!("copy_to_clipboard(n={})", t.chars().count()),
686            Cmd::ComposeInEditor { text } => {
687                format!("compose_in_editor(n={})", text.chars().count())
688            },
689            Cmd::Exit => "exit".to_string(),
690            Cmd::SetTerminalTitle(t) => format!("set_terminal_title({})", t),
691            Cmd::AlertUser => "alert_user".to_string(),
692        }
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn turn_scoped_variants_marked_correctly() {
702        let request = ChatRequest {
703            model_id: "m".to_string(),
704            messages: vec![],
705            system_prompt: String::new(),
706            instructions: None,
707            reasoning: ReasoningLevel::Medium,
708            temperature: 0.7,
709            max_tokens: 4096,
710            tools: vec![],
711
712            ollama_num_ctx: None,
713            ollama_allow_ram_offload: None,
714            resolved_context_window: None,
715            resolved_max_output: None,
716            output_schema: None,
717            suppress_auto_compact: false,
718            suppressed_builtin_tools: Vec::new(),
719        };
720        assert!(
721            Cmd::CallModel {
722                turn: TurnId(1),
723                request,
724            }
725            .is_turn_scoped()
726        );
727        assert!(
728            !Cmd::SaveConversation(ConversationHistory::new(
729                "/p".to_string(),
730                "m".to_string(),
731                chrono::Local::now()
732            ))
733            .is_turn_scoped()
734        );
735        assert!(!Cmd::Exit.is_turn_scoped());
736    }
737
738    #[test]
739    fn cmd_tags_are_stable() {
740        assert_eq!(Cmd::Exit.tag(), "exit");
741        assert_eq!(Cmd::CancelScope(TurnId(1)).tag(), "cancel_scope");
742    }
743
744    #[test]
745    fn cmd_summary_includes_identifying_fields() {
746        let c = Cmd::CancelScope(TurnId(42));
747        let s = c.summary();
748        assert!(s.contains("turn#42"));
749    }
750}