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::state::ApprovalChoice;
32
33use super::compaction::{CompactionArchive, CompactionRecord, CompactionRequest};
34use super::ids::{ToolCallId, TurnId};
35use super::runtime::ManagedProcess;
36
37/// A single side-effect request. Most variants are one-shot; `CallModel`
38/// and `ExecuteTool` spawn long-running tasks inside a per-turn
39/// `TurnScope`.
40// Several variants legitimately carry large payloads (a full
41// `ConversationHistory` / `ChatRequest`). Boxing them would churn ~20
42// construction + match sites for no real gain — `Cmd` values are short-lived
43// and moved, not stored in bulk.
44#[allow(clippy::large_enum_variant)]
45#[derive(Debug, Clone)]
46pub enum Cmd {
47    // ── Model + tool execution (the scope-spawning variants) ────────
48    /// Dispatch the next chat request. Effect runner maps this onto
49    /// `ModelProvider::chat` for the session's active provider.
50    CallModel { turn: TurnId, request: ChatRequest },
51    /// Generate a compact context checkpoint without continuing into
52    /// a normal assistant turn.
53    CompactConversation {
54        turn: TurnId,
55        request: CompactionRequest,
56    },
57    /// Run one tool in parallel with any other tools in the same turn.
58    /// The runner wires `ExecContext::token` to the turn's scope so
59    /// `Cmd::CancelScope` aborts them all at once.
60    ///
61    /// `model_id` is the active session's model id at the moment this
62    /// tool call was emitted. The runner passes it into `ExecContext`
63    /// so tools like `SubagentTool` can spawn children against the
64    /// same provider the parent is using.
65    ExecuteTool {
66        turn: TurnId,
67        call_id: ToolCallId,
68        source: ModelToolCall,
69        model_id: String,
70        /// Effective live safety mode (from `state.session.safety_mode`) at
71        /// the moment this call was emitted. The runner builds the policy
72        /// gate / Auto classifier from this rather than the static config.
73        safety_mode: SafetyMode,
74        /// The user's stated intent for the turn (latest user message),
75        /// passed to the Auto-mode classifier as alignment context.
76        intent: Option<String>,
77    },
78    /// Cancel every task in the given turn's `TurnScope`. After the
79    /// scope's `JoinSet` drains (bounded by a ~2s timeout), the runner
80    /// emits a single `Msg::TurnCancelled(turn)` — that, not
81    /// `Msg::StreamDone`, is the terminal event that lets the reducer
82    /// transition `Cancelling → Idle`. A same-id `Msg::StreamDone` that
83    /// races the drain does NOT end the turn: `handle_stream_done` only
84    /// acts on a `Generating` turn and restores the prior state
85    /// (`Cancelling`) otherwise, so `TurnCancelled` stays the one
86    /// terminal signal for a cancel.
87    CancelScope(TurnId),
88    /// Ctrl+B: signal the turn's scope to BACKGROUND (not cancel) its running
89    /// work. The scope's background token fires; detachable tools (execute_
90    /// command) move their child to a background process and return. The scope
91    /// is left intact (unlike `CancelScope`).
92    BackgroundScope(TurnId),
93
94    /// Resolve an inline approval prompt: deliver the user's decision to the
95    /// parked tool task via the `ApprovalBroker`. NOT turn-scoped — it's a
96    /// fire-and-forget to the broker (the tool task it unblocks is the
97    /// turn-scoped work).
98    ResolveApproval {
99        call_id: ToolCallId,
100        decision: ApprovalChoice,
101    },
102
103    // ── Persistence ─────────────────────────────────────────────────
104    /// Save the current conversation to disk. No-op if unchanged since
105    /// last save (effect-side idempotence).
106    SaveConversation(ConversationHistory),
107    /// Persist the raw messages removed by a compaction, then the compacted
108    /// (message-stripped) conversation. Both are written by ONE effect task,
109    /// archive first — only overwriting the conversation if the archive
110    /// persisted — so a failed/lagging archive can never lose messages while
111    /// the stripped conversation is saved over the old one.
112    SaveCompactionArchive {
113        archive: CompactionArchive,
114        record: CompactionRecord,
115        conversation: ConversationHistory,
116    },
117    /// Persist a daemon-visible background process record.
118    SaveProcess(ManagedProcess),
119    /// Persist the active model ID as `last_used_model`.
120    PersistLastModel(String),
121    /// Persist reasoning level tied to a specific model ID.
122    PersistReasoningFor {
123        model_id: String,
124        level: ReasoningLevel,
125    },
126    /// Persist (or clear, when `num_ctx` is `None`) a per-model Ollama `num_ctx`
127    /// override set via `/context <n>`/`max`/`auto`.
128    PersistOllamaNumCtxFor {
129        model_id: String,
130        num_ctx: Option<u32>,
131    },
132    /// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
133    PersistOllamaOffload(bool),
134    /// Re-stat `MERMAID.md` (cheap); emits `Msg::InstructionsChanged`
135    /// only when the mtime moved or the file appeared/disappeared.
136    RefreshInstructions,
137    /// Re-scan the memory directories (cheap); emits `Msg::MemoryChanged`.
138    RefreshMemory,
139    /// List saved memories; emits `Msg::RuntimeText` with the rendered list.
140    ListMemory,
141    /// Save free-text to private memory; emits `Msg::MemoryChanged` + status.
142    RememberMemory { text: String },
143    /// Delete a memory by name/id; emits `Msg::MemoryChanged` + status.
144    ForgetMemory { id: String },
145    /// Model-assisted prune of duplicate/obsolete memories, reversible via a
146    /// checkpoint. Emits `Msg::RuntimeText` (the report) + `Msg::MemoryChanged`.
147    ConsolidateMemory { model_id: String },
148    /// Load a specific conversation by ID and emit
149    /// `Msg::ConversationLoaded`. Reducer consumes that event to
150    /// replace the current session.
151    LoadConversation(String),
152    /// Scan the conversations directory for the /load picker. Emits
153    /// `Msg::ConversationsListed` with one `ConversationSummary` per
154    /// saved session (newest first). The reducer transitions to
155    /// `UiMode::ConversationList` and the render shows the picker.
156    ListConversations,
157    /// List durable daemon/runtime tasks.
158    ListRuntimeTasks { limit: usize },
159    /// Load one durable daemon/runtime task and its timeline.
160    LoadRuntimeTask { id: String },
161    /// List durable daemon/runtime background processes.
162    ListRuntimeProcesses { limit: usize },
163    /// Print one durable process log into the conversation.
164    ShowRuntimeProcessLogs { id: String },
165    /// Stop a durable process by pid.
166    StopRuntimeProcess { id: String },
167    /// Restart a durable process using its recorded command/cwd.
168    RestartRuntimeProcess { id: String },
169    /// Open a URL, path, or process target.
170    OpenRuntimeTarget { target: String },
171    /// Show listening ports.
172    ShowRuntimePorts,
173    /// List pending approval records.
174    ListRuntimeApprovals,
175    /// Mark one approval as approved or denied.
176    DecideRuntimeApproval { id: String, decision: String },
177    /// List restore checkpoints.
178    ListRuntimeCheckpoints { limit: usize },
179    /// List installed plugins.
180    ListRuntimePlugins,
181    /// Update one durable task's status.
182    UpdateRuntimeTaskStatus {
183        id: String,
184        status: TaskStatus,
185        final_report: Option<String>,
186    },
187    /// Create a shadow checkpoint for explicit paths.
188    CreateRuntimeCheckpoint { paths: Vec<PathBuf> },
189    /// Restore files from a shadow checkpoint.
190    RestoreRuntimeCheckpoint { id: String },
191    /// Show provider/model capability information.
192    ShowRuntimeModelInfo { model: String },
193
194    // ── MCP lifecycle ───────────────────────────────────────────────
195    /// Start every configured MCP server; each one emits
196    /// `Msg::McpServerReady` or `Msg::McpServerErrored` as it comes up.
197    InitMcpServers(HashMap<String, McpServerConfig>),
198    /// Stop a running server (e.g. config was removed, or app quit).
199    StopMcpServer { name: String },
200
201    // ── Ollama helpers ──────────────────────────────────────────────
202    /// `ollama pull <model>` with progress → `Msg::ModelPullFinished`.
203    PullOllamaModel { model: String },
204
205    // ── UI side-effects (cross-process) ─────────────────────────────
206    /// `xdg-open` / `open` / `start` on a file path. Used by the
207    /// image-paste preview and the "open in editor" affordance.
208    OpenInSystem(PathBuf),
209
210    // ── Status line ─────────────────────────────────────────────────
211    /// Schedule `Msg::StatusDismiss` after `ms` milliseconds. Reducer
212    /// uses this to self-clear transient status lines.
213    DismissStatusAfter { ms: u64 },
214
215    // ── Attachments ─────────────────────────────────────────────────
216    /// Persist a pasted image to a temp file so the TUI can open it
217    /// via `OpenInSystem`. Emits no follow-up Msg on success; failure
218    /// is a log-and-drop.
219    WriteImageToTemp {
220        path: PathBuf,
221        bytes: Vec<u8>,
222        format: String,
223    },
224
225    /// Read the system clipboard on a blocking task. The per-platform
226    /// dispatch (xclip / wl-paste / pngpaste / PowerShell) can block
227    /// for hundreds of ms on macOS via osascript, so it never runs on
228    /// the reducer thread. Emits `Msg::Paste(Paste::Image|Text)` on
229    /// success; `Msg::TransientStatus` when the clipboard is empty or
230    /// the read fails.
231    ReadClipboard,
232
233    /// Write text to the system clipboard on a blocking task (mirrors
234    /// `ReadClipboard`'s per-platform dispatch). Used by in-app drag-select
235    /// copy. Emits a `Msg::TransientStatus` ("Copied N chars" / failure).
236    CopyToClipboard(String),
237
238    // ── Terminal lifecycle ──────────────────────────────────────────
239    /// Exit the main loop. No reply message — the loop observes
240    /// `state.should_exit` after the reducer returns and breaks out.
241    Exit,
242    /// Write the OSC 2 terminal-title sequence. Reducer diffs
243    /// against `ui.last_title_dispatched` so this only fires on
244    /// actual title changes, not every frame.
245    SetTerminalTitle(String),
246}
247
248/// Inputs a model needs to generate a turn. Built by the reducer from
249/// `Session` + `Settings` + current `MERMAID.md` context. Pure data —
250/// no provider-specific knowledge here (that's in
251/// `providers::model::*::chat`).
252#[derive(Debug, Clone)]
253pub struct ChatRequest {
254    pub model_id: String,
255    pub messages: Vec<ChatMessage>,
256    pub system_prompt: String,
257    /// `MERMAID.md` content to suffix onto the system prompt. `None` if
258    /// no file was loaded for this project.
259    pub instructions: Option<String>,
260    pub reasoning: ReasoningLevel,
261    pub temperature: f32,
262    pub max_tokens: usize,
263    /// Tool definitions advertised to the model. Combination of the
264    /// built-in tool set + any advertised MCP tools from `McpState`.
265    pub tools: Vec<ToolDefinition>,
266    /// Per-model Ollama `num_ctx` override (`/context <n>`/`max`), or `None` to
267    /// auto-fit. The one provider-specific knob that rides on the request, the
268    /// same way `reasoning` does — so a live `/context` change applies on the
269    /// next turn without rebuilding the cached provider. Ignored by non-Ollama
270    /// providers.
271    pub ollama_num_ctx: Option<u32>,
272    /// Live Ollama RAM-offload toggle (`/context offload on|off`). Rides on the
273    /// request like `ollama_num_ctx` so a toggle applies on the next turn without
274    /// rebuilding the cached provider (whose `config` is frozen at startup).
275    /// `None` falls back to the persisted `[ollama] allow_ram_offload`. Ignored
276    /// by non-Ollama providers.
277    pub ollama_allow_ram_offload: Option<bool>,
278}
279
280/// Provider-agnostic tool definition sent in the request. Concrete
281/// adapters (`providers::model::ollama`, etc.) translate this into
282/// whatever wire shape their API expects.
283#[derive(Debug, Clone)]
284pub struct ToolDefinition {
285    pub name: String,
286    pub description: String,
287    pub input_schema: serde_json::Value,
288}
289
290impl ToolDefinition {
291    /// Wire shape: `{type: "function", function: {name, description,
292    /// parameters}}`. This is the OpenAI / Ollama Chat Completions
293    /// format; Anthropic and Gemini adapters translate further from
294    /// here. Single-canonical-shape keeps adapters from drifting.
295    pub fn to_openai_json(&self) -> serde_json::Value {
296        serde_json::json!({
297            "type": "function",
298            "function": {
299                "name": self.name,
300                "description": self.description,
301                "parameters": self.input_schema,
302            }
303        })
304    }
305}
306
307impl Cmd {
308    /// Human-readable tag, for tracing + replay logs. Stable across
309    /// refactors (tests assert against it).
310    pub fn tag(&self) -> &'static str {
311        match self {
312            Cmd::CallModel { .. } => "call_model",
313            Cmd::CompactConversation { .. } => "compact_conversation",
314            Cmd::ExecuteTool { .. } => "execute_tool",
315            Cmd::CancelScope(_) => "cancel_scope",
316            Cmd::BackgroundScope(_) => "background_scope",
317            Cmd::ResolveApproval { .. } => "resolve_approval",
318            Cmd::SaveConversation(_) => "save_conversation",
319            Cmd::SaveCompactionArchive { .. } => "save_compaction_archive",
320            Cmd::SaveProcess(_) => "save_process",
321            Cmd::PersistLastModel(_) => "persist_last_model",
322            Cmd::PersistReasoningFor { .. } => "persist_reasoning_for",
323            Cmd::PersistOllamaNumCtxFor { .. } => "persist_ollama_num_ctx_for",
324            Cmd::PersistOllamaOffload(_) => "persist_ollama_offload",
325            Cmd::RefreshInstructions => "refresh_instructions",
326            Cmd::RefreshMemory => "refresh_memory",
327            Cmd::ListMemory => "list_memory",
328            Cmd::RememberMemory { .. } => "remember_memory",
329            Cmd::ForgetMemory { .. } => "forget_memory",
330            Cmd::ConsolidateMemory { .. } => "consolidate_memory",
331            Cmd::LoadConversation(_) => "load_conversation",
332            Cmd::ListConversations => "list_conversations",
333            Cmd::ListRuntimeTasks { .. } => "list_runtime_tasks",
334            Cmd::LoadRuntimeTask { .. } => "load_runtime_task",
335            Cmd::ListRuntimeProcesses { .. } => "list_runtime_processes",
336            Cmd::ShowRuntimeProcessLogs { .. } => "show_runtime_process_logs",
337            Cmd::StopRuntimeProcess { .. } => "stop_runtime_process",
338            Cmd::RestartRuntimeProcess { .. } => "restart_runtime_process",
339            Cmd::OpenRuntimeTarget { .. } => "open_runtime_target",
340            Cmd::ShowRuntimePorts => "show_runtime_ports",
341            Cmd::ListRuntimeApprovals => "list_runtime_approvals",
342            Cmd::DecideRuntimeApproval { .. } => "decide_runtime_approval",
343            Cmd::ListRuntimeCheckpoints { .. } => "list_runtime_checkpoints",
344            Cmd::ListRuntimePlugins => "list_runtime_plugins",
345            Cmd::UpdateRuntimeTaskStatus { .. } => "update_runtime_task_status",
346            Cmd::CreateRuntimeCheckpoint { .. } => "create_runtime_checkpoint",
347            Cmd::RestoreRuntimeCheckpoint { .. } => "restore_runtime_checkpoint",
348            Cmd::ShowRuntimeModelInfo { .. } => "show_runtime_model_info",
349            Cmd::InitMcpServers(_) => "init_mcp_servers",
350            Cmd::StopMcpServer { .. } => "stop_mcp_server",
351            Cmd::PullOllamaModel { .. } => "pull_ollama_model",
352            Cmd::OpenInSystem(_) => "open_in_system",
353            Cmd::DismissStatusAfter { .. } => "dismiss_status_after",
354            Cmd::WriteImageToTemp { .. } => "write_image_to_temp",
355            Cmd::ReadClipboard => "read_clipboard",
356            Cmd::CopyToClipboard(_) => "copy_to_clipboard",
357            Cmd::Exit => "exit",
358            Cmd::SetTerminalTitle(_) => "set_terminal_title",
359        }
360    }
361
362    /// True iff this command needs to run inside a `TurnScope` so it
363    /// can be cancelled by `Cmd::CancelScope`. The effect runner uses
364    /// this to decide between "spawn into `JoinSet`" and "spawn detached".
365    pub fn is_turn_scoped(&self) -> bool {
366        matches!(
367            self,
368            Cmd::CallModel { .. } | Cmd::CompactConversation { .. } | Cmd::ExecuteTool { .. }
369        )
370    }
371
372    /// The `TurnId` of the scope this command would spawn fresh work
373    /// *into*. Only the scope-spawning variants return `Some` (the same
374    /// set as `is_turn_scoped`); the scope-control variants
375    /// (`CancelScope` / `BackgroundScope`) act on an existing scope
376    /// rather than populating one with new work, so they return `None`.
377    ///
378    /// The effect runner uses this to refuse spawning fresh work for a
379    /// turn it has already cancelled (tombstoned) — a stray post-cancel
380    /// `CallModel`/`ExecuteTool`/`CompactConversation` would otherwise
381    /// resurrect an un-cancelled scope via `scope_mut`'s `or_insert_with`
382    /// (F38). `CancelScope` must keep working on a tombstoned turn, which
383    /// is exactly why it is excluded here.
384    pub fn scope_turn(&self) -> Option<TurnId> {
385        match self {
386            Cmd::CallModel { turn, .. }
387            | Cmd::CompactConversation { turn, .. }
388            | Cmd::ExecuteTool { turn, .. } => Some(*turn),
389            _ => None,
390        }
391    }
392
393    /// For traces + the `--record` file — some `Cmd` payloads are huge
394    /// (think `ChatRequest::messages`). This returns a compact
395    /// identifier that doesn't dump the full payload.
396    pub fn summary(&self) -> String {
397        match self {
398            Cmd::CallModel { turn, request } => format!(
399                "call_model(turn={}, model={}, msgs={})",
400                turn,
401                request.model_id,
402                request.messages.len()
403            ),
404            Cmd::CompactConversation { turn, request } => format!(
405                "compact_conversation(turn={}, model={}, trigger={}, msgs={})",
406                turn,
407                request.chat.model_id,
408                request.trigger.as_str(),
409                request.chat.messages.len()
410            ),
411            Cmd::ExecuteTool {
412                turn,
413                call_id,
414                source,
415                ..
416            } => format!(
417                "execute_tool(turn={}, call={}, fn={})",
418                turn, call_id, source.function.name
419            ),
420            Cmd::CancelScope(turn) => format!("cancel_scope(turn={})", turn),
421            Cmd::BackgroundScope(turn) => format!("background_scope(turn={})", turn),
422            Cmd::ResolveApproval { call_id, decision } => {
423                format!("resolve_approval(call={}, {:?})", call_id, decision)
424            },
425            Cmd::SaveConversation(c) => format!("save_conversation(id={})", c.id),
426            Cmd::SaveCompactionArchive {
427                archive, record, ..
428            } => format!(
429                "save_compaction_archive(conversation={}, id={})",
430                archive.conversation_id, record.id
431            ),
432            Cmd::SaveProcess(p) => format!("save_process(id={}, pid={})", p.id, p.pid),
433            Cmd::PersistLastModel(m) => format!("persist_last_model({})", m),
434            Cmd::PersistReasoningFor { model_id, level } => {
435                format!("persist_reasoning_for({}, {:?})", model_id, level)
436            },
437            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
438                format!("persist_ollama_num_ctx_for({}, {:?})", model_id, num_ctx)
439            },
440            Cmd::PersistOllamaOffload(enabled) => {
441                format!("persist_ollama_offload({})", enabled)
442            },
443            Cmd::RefreshInstructions => "refresh_instructions".to_string(),
444            Cmd::RefreshMemory => "refresh_memory".to_string(),
445            Cmd::ListMemory => "list_memory".to_string(),
446            Cmd::RememberMemory { .. } => "remember_memory".to_string(),
447            Cmd::ForgetMemory { .. } => "forget_memory".to_string(),
448            Cmd::ConsolidateMemory { .. } => "consolidate_memory".to_string(),
449            Cmd::LoadConversation(id) => format!("load_conversation({})", id),
450            Cmd::ListConversations => "list_conversations".to_string(),
451            Cmd::ListRuntimeTasks { limit } => format!("list_runtime_tasks(limit={})", limit),
452            Cmd::LoadRuntimeTask { id } => format!("load_runtime_task({})", id),
453            Cmd::ListRuntimeProcesses { limit } => {
454                format!("list_runtime_processes(limit={})", limit)
455            },
456            Cmd::ShowRuntimeProcessLogs { id } => format!("show_runtime_process_logs({})", id),
457            Cmd::StopRuntimeProcess { id } => format!("stop_runtime_process({})", id),
458            Cmd::RestartRuntimeProcess { id } => format!("restart_runtime_process({})", id),
459            Cmd::OpenRuntimeTarget { target } => format!("open_runtime_target({})", target),
460            Cmd::ShowRuntimePorts => "show_runtime_ports".to_string(),
461            Cmd::ListRuntimeApprovals => "list_runtime_approvals".to_string(),
462            Cmd::DecideRuntimeApproval { id, decision } => {
463                format!("decide_runtime_approval({}, {})", id, decision)
464            },
465            Cmd::ListRuntimeCheckpoints { limit } => {
466                format!("list_runtime_checkpoints(limit={})", limit)
467            },
468            Cmd::ListRuntimePlugins => "list_runtime_plugins".to_string(),
469            Cmd::UpdateRuntimeTaskStatus { id, status, .. } => {
470                format!("update_runtime_task_status({}, {})", id, status)
471            },
472            Cmd::CreateRuntimeCheckpoint { paths } => {
473                format!("create_runtime_checkpoint(n={})", paths.len())
474            },
475            Cmd::RestoreRuntimeCheckpoint { id } => format!("restore_runtime_checkpoint({})", id),
476            Cmd::ShowRuntimeModelInfo { model } => format!("show_runtime_model_info({})", model),
477            Cmd::InitMcpServers(m) => format!("init_mcp_servers(n={})", m.len()),
478            Cmd::StopMcpServer { name } => format!("stop_mcp_server({})", name),
479            Cmd::PullOllamaModel { model } => format!("pull_ollama_model({})", model),
480            Cmd::OpenInSystem(p) => format!("open_in_system({})", p.display()),
481            Cmd::DismissStatusAfter { ms } => format!("dismiss_status_after({}ms)", ms),
482            Cmd::WriteImageToTemp {
483                path,
484                format,
485                bytes,
486            } => format!(
487                "write_image_to_temp(path={}, fmt={}, n={})",
488                path.display(),
489                format,
490                bytes.len()
491            ),
492            Cmd::ReadClipboard => "read_clipboard".to_string(),
493            Cmd::CopyToClipboard(t) => format!("copy_to_clipboard(n={})", t.chars().count()),
494            Cmd::Exit => "exit".to_string(),
495            Cmd::SetTerminalTitle(t) => format!("set_terminal_title({})", t),
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn turn_scoped_variants_marked_correctly() {
506        let request = ChatRequest {
507            model_id: "m".to_string(),
508            messages: vec![],
509            system_prompt: String::new(),
510            instructions: None,
511            reasoning: ReasoningLevel::Medium,
512            temperature: 0.7,
513            max_tokens: 4096,
514            tools: vec![],
515
516            ollama_num_ctx: None,
517            ollama_allow_ram_offload: None,
518        };
519        assert!(
520            Cmd::CallModel {
521                turn: TurnId(1),
522                request,
523            }
524            .is_turn_scoped()
525        );
526        assert!(
527            !Cmd::SaveConversation(ConversationHistory::new("/p".to_string(), "m".to_string()))
528                .is_turn_scoped()
529        );
530        assert!(!Cmd::RefreshInstructions.is_turn_scoped());
531        assert!(!Cmd::Exit.is_turn_scoped());
532    }
533
534    #[test]
535    fn cmd_tags_are_stable() {
536        assert_eq!(Cmd::Exit.tag(), "exit");
537        assert_eq!(Cmd::RefreshInstructions.tag(), "refresh_instructions");
538        assert_eq!(Cmd::CancelScope(TurnId(1)).tag(), "cancel_scope");
539    }
540
541    #[test]
542    fn cmd_summary_includes_identifying_fields() {
543        let c = Cmd::CancelScope(TurnId(42));
544        let s = c.summary();
545        assert!(s.contains("turn#42"));
546    }
547}