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 /// List saved memories; emits `Msg::RuntimeText` with the rendered list.
135 ListMemory,
136 /// Save free-text to private memory; emits `Msg::MemoryChanged` + status.
137 RememberMemory { text: String },
138 /// Delete a memory by name/id; emits `Msg::MemoryChanged` + status.
139 ForgetMemory { id: String },
140 /// Model-assisted prune of duplicate/obsolete memories, reversible via a
141 /// checkpoint. Emits `Msg::RuntimeText` (the report) + `Msg::MemoryChanged`.
142 ConsolidateMemory { model_id: String },
143 /// Load a specific conversation by ID and emit
144 /// `Msg::ConversationLoaded`. Reducer consumes that event to
145 /// replace the current session.
146 LoadConversation(String),
147 /// Scan the conversations directory for the /load picker. Emits
148 /// `Msg::ConversationsListed` with one `ConversationSummary` per
149 /// saved session (newest first). The reducer transitions to
150 /// `UiMode::ConversationList` and the render shows the picker.
151 ListConversations,
152 /// List durable daemon/runtime tasks.
153 ListRuntimeTasks { limit: usize },
154 /// Load one durable daemon/runtime task and its timeline.
155 LoadRuntimeTask { id: String },
156 /// List durable daemon/runtime background processes.
157 ListRuntimeProcesses { limit: usize },
158 /// Print one durable process log into the conversation.
159 ShowRuntimeProcessLogs { id: String },
160 /// Stop a durable process by pid.
161 StopRuntimeProcess { id: String },
162 /// Restart a durable process using its recorded command/cwd.
163 RestartRuntimeProcess { id: String },
164 /// Open a URL, path, or process target.
165 OpenRuntimeTarget { target: String },
166 /// Show listening ports.
167 ShowRuntimePorts,
168 /// List pending approval records.
169 ListRuntimeApprovals,
170 /// Mark one approval as approved or denied.
171 DecideRuntimeApproval { id: String, decision: String },
172 /// List restore checkpoints.
173 ListRuntimeCheckpoints { limit: usize },
174 /// List installed plugins.
175 ListRuntimePlugins,
176 /// Update one durable task's status.
177 UpdateRuntimeTaskStatus {
178 id: String,
179 status: TaskStatus,
180 final_report: Option<String>,
181 },
182 /// Create a shadow checkpoint for explicit paths.
183 CreateRuntimeCheckpoint { paths: Vec<PathBuf> },
184 /// Restore files from a shadow checkpoint.
185 RestoreRuntimeCheckpoint { id: String },
186 /// Show provider/model capability information.
187 ShowRuntimeModelInfo { model: String },
188
189 // ── MCP lifecycle ───────────────────────────────────────────────
190 /// Start every configured MCP server; each one emits
191 /// `Msg::McpServerReady` or `Msg::McpServerErrored` as it comes up.
192 InitMcpServers(HashMap<String, McpServerConfig>),
193 /// Stop a running server (e.g. config was removed, or app quit).
194 StopMcpServer { name: String },
195
196 // ── Ollama helpers ──────────────────────────────────────────────
197 /// `ollama pull <model>` with progress → `Msg::ModelPullFinished`.
198 PullOllamaModel { model: String },
199
200 // ── UI side-effects (cross-process) ─────────────────────────────
201 /// `xdg-open` / `open` / `start` on a file path. Used by the
202 /// image-paste preview and the "open in editor" affordance.
203 OpenInSystem(PathBuf),
204
205 // ── Attachments ─────────────────────────────────────────────────
206 /// Persist a pasted image to a temp file so the TUI can open it
207 /// via `OpenInSystem`. Emits no follow-up Msg on success; failure
208 /// is a log-and-drop.
209 WriteImageToTemp {
210 path: PathBuf,
211 bytes: Vec<u8>,
212 format: String,
213 },
214
215 /// Read the system clipboard on a blocking task. The per-platform
216 /// dispatch (xclip / wl-paste / pngpaste / PowerShell) can block
217 /// for hundreds of ms on macOS via osascript, so it never runs on
218 /// the reducer thread. Emits `Msg::Paste(Paste::Image|Text)` on
219 /// success; `Msg::TransientStatus` when the clipboard is empty or
220 /// the read fails.
221 ReadClipboard,
222
223 /// Write text to the system clipboard on a blocking task (mirrors
224 /// `ReadClipboard`'s per-platform dispatch). Used by in-app drag-select
225 /// copy. Emits a `Msg::TransientStatus` ("Copied N chars" / failure).
226 CopyToClipboard(String),
227
228 // ── Terminal lifecycle ──────────────────────────────────────────
229 /// Exit the main loop. No reply message — the loop observes
230 /// `state.should_exit` after the reducer returns and breaks out.
231 Exit,
232 /// Write the OSC 2 terminal-title sequence. Reducer diffs
233 /// against `ui.last_title_dispatched` so this only fires on
234 /// actual title changes, not every frame.
235 SetTerminalTitle(String),
236}
237
238/// Inputs a model needs to generate a turn. Built by the reducer from
239/// `Session` + `Settings` + current `MERMAID.md` context. Pure data —
240/// no provider-specific knowledge here (that's in
241/// `providers::model::*::chat`).
242#[derive(Debug, Clone)]
243pub struct ChatRequest {
244 pub model_id: String,
245 pub messages: Vec<ChatMessage>,
246 pub system_prompt: String,
247 /// `MERMAID.md` content to suffix onto the system prompt. `None` if
248 /// no file was loaded for this project.
249 pub instructions: Option<String>,
250 pub reasoning: ReasoningLevel,
251 pub temperature: f32,
252 pub max_tokens: usize,
253 /// Tool definitions advertised to the model. Combination of the
254 /// built-in tool set + any advertised MCP tools from `McpState`.
255 pub tools: Vec<ToolDefinition>,
256 /// Per-model Ollama `num_ctx` override (`/context <n>`/`max`), or `None` to
257 /// auto-fit. The one provider-specific knob that rides on the request, the
258 /// same way `reasoning` does — so a live `/context` change applies on the
259 /// next turn without rebuilding the cached provider. Ignored by non-Ollama
260 /// providers.
261 pub ollama_num_ctx: Option<u32>,
262 /// Live Ollama RAM-offload toggle (`/context offload on|off`). Rides on the
263 /// request like `ollama_num_ctx` so a toggle applies on the next turn without
264 /// rebuilding the cached provider (whose `config` is frozen at startup).
265 /// `None` falls back to the persisted `[ollama] allow_ram_offload`. Ignored
266 /// by non-Ollama providers.
267 pub ollama_allow_ram_offload: Option<bool>,
268}
269
270/// Provider-agnostic tool definition sent in the request. Concrete
271/// adapters (`providers::model::ollama`, etc.) translate this into
272/// whatever wire shape their API expects.
273#[derive(Debug, Clone)]
274pub struct ToolDefinition {
275 pub name: String,
276 pub description: String,
277 pub input_schema: serde_json::Value,
278}
279
280impl ToolDefinition {
281 /// Wire shape: `{type: "function", function: {name, description,
282 /// parameters}}`. This is the OpenAI / Ollama Chat Completions
283 /// format; Anthropic and Gemini adapters translate further from
284 /// here. Single-canonical-shape keeps adapters from drifting.
285 pub fn to_openai_json(&self) -> serde_json::Value {
286 serde_json::json!({
287 "type": "function",
288 "function": {
289 "name": self.name,
290 "description": self.description,
291 "parameters": self.input_schema,
292 }
293 })
294 }
295}
296
297impl Cmd {
298 /// Human-readable tag, for tracing + replay logs. Stable across
299 /// refactors (tests assert against it).
300 pub fn tag(&self) -> &'static str {
301 match self {
302 Cmd::CallModel { .. } => "call_model",
303 Cmd::CompactConversation { .. } => "compact_conversation",
304 Cmd::ExecuteTool { .. } => "execute_tool",
305 Cmd::CancelScope(_) => "cancel_scope",
306 Cmd::BackgroundScope(_) => "background_scope",
307 Cmd::ResolveApproval { .. } => "resolve_approval",
308 Cmd::SaveConversation(_) => "save_conversation",
309 Cmd::SaveCompactionArchive { .. } => "save_compaction_archive",
310 Cmd::SaveProcess(_) => "save_process",
311 Cmd::PersistLastModel(_) => "persist_last_model",
312 Cmd::PersistReasoningFor { .. } => "persist_reasoning_for",
313 Cmd::PersistOllamaNumCtxFor { .. } => "persist_ollama_num_ctx_for",
314 Cmd::PersistOllamaOffload(_) => "persist_ollama_offload",
315 Cmd::ListMemory => "list_memory",
316 Cmd::RememberMemory { .. } => "remember_memory",
317 Cmd::ForgetMemory { .. } => "forget_memory",
318 Cmd::ConsolidateMemory { .. } => "consolidate_memory",
319 Cmd::LoadConversation(_) => "load_conversation",
320 Cmd::ListConversations => "list_conversations",
321 Cmd::ListRuntimeTasks { .. } => "list_runtime_tasks",
322 Cmd::LoadRuntimeTask { .. } => "load_runtime_task",
323 Cmd::ListRuntimeProcesses { .. } => "list_runtime_processes",
324 Cmd::ShowRuntimeProcessLogs { .. } => "show_runtime_process_logs",
325 Cmd::StopRuntimeProcess { .. } => "stop_runtime_process",
326 Cmd::RestartRuntimeProcess { .. } => "restart_runtime_process",
327 Cmd::OpenRuntimeTarget { .. } => "open_runtime_target",
328 Cmd::ShowRuntimePorts => "show_runtime_ports",
329 Cmd::ListRuntimeApprovals => "list_runtime_approvals",
330 Cmd::DecideRuntimeApproval { .. } => "decide_runtime_approval",
331 Cmd::ListRuntimeCheckpoints { .. } => "list_runtime_checkpoints",
332 Cmd::ListRuntimePlugins => "list_runtime_plugins",
333 Cmd::UpdateRuntimeTaskStatus { .. } => "update_runtime_task_status",
334 Cmd::CreateRuntimeCheckpoint { .. } => "create_runtime_checkpoint",
335 Cmd::RestoreRuntimeCheckpoint { .. } => "restore_runtime_checkpoint",
336 Cmd::ShowRuntimeModelInfo { .. } => "show_runtime_model_info",
337 Cmd::InitMcpServers(_) => "init_mcp_servers",
338 Cmd::StopMcpServer { .. } => "stop_mcp_server",
339 Cmd::PullOllamaModel { .. } => "pull_ollama_model",
340 Cmd::OpenInSystem(_) => "open_in_system",
341 Cmd::WriteImageToTemp { .. } => "write_image_to_temp",
342 Cmd::ReadClipboard => "read_clipboard",
343 Cmd::CopyToClipboard(_) => "copy_to_clipboard",
344 Cmd::Exit => "exit",
345 Cmd::SetTerminalTitle(_) => "set_terminal_title",
346 }
347 }
348
349 /// True iff this command needs to run inside a `TurnScope` so it
350 /// can be cancelled by `Cmd::CancelScope`. The effect runner uses
351 /// this to decide between "spawn into `JoinSet`" and "spawn detached".
352 pub fn is_turn_scoped(&self) -> bool {
353 matches!(
354 self,
355 Cmd::CallModel { .. } | Cmd::CompactConversation { .. } | Cmd::ExecuteTool { .. }
356 )
357 }
358
359 /// The `TurnId` of the scope this command would spawn fresh work
360 /// *into*. Only the scope-spawning variants return `Some` (the same
361 /// set as `is_turn_scoped`); the scope-control variants
362 /// (`CancelScope` / `BackgroundScope`) act on an existing scope
363 /// rather than populating one with new work, so they return `None`.
364 ///
365 /// The effect runner uses this to refuse spawning fresh work for a
366 /// turn it has already cancelled (tombstoned) — a stray post-cancel
367 /// `CallModel`/`ExecuteTool`/`CompactConversation` would otherwise
368 /// resurrect an un-cancelled scope via `scope_mut`'s `or_insert_with`
369 /// (F38). `CancelScope` must keep working on a tombstoned turn, which
370 /// is exactly why it is excluded here.
371 pub fn scope_turn(&self) -> Option<TurnId> {
372 match self {
373 Cmd::CallModel { turn, .. }
374 | Cmd::CompactConversation { turn, .. }
375 | Cmd::ExecuteTool { turn, .. } => Some(*turn),
376 _ => None,
377 }
378 }
379
380 /// For traces + the `--record` file — some `Cmd` payloads are huge
381 /// (think `ChatRequest::messages`). This returns a compact
382 /// identifier that doesn't dump the full payload.
383 pub fn summary(&self) -> String {
384 match self {
385 Cmd::CallModel { turn, request } => format!(
386 "call_model(turn={}, model={}, msgs={})",
387 turn,
388 request.model_id,
389 request.messages.len()
390 ),
391 Cmd::CompactConversation { turn, request } => format!(
392 "compact_conversation(turn={}, model={}, trigger={}, msgs={})",
393 turn,
394 request.chat.model_id,
395 request.trigger.as_str(),
396 request.chat.messages.len()
397 ),
398 Cmd::ExecuteTool {
399 turn,
400 call_id,
401 source,
402 ..
403 } => format!(
404 "execute_tool(turn={}, call={}, fn={})",
405 turn, call_id, source.function.name
406 ),
407 Cmd::CancelScope(turn) => format!("cancel_scope(turn={})", turn),
408 Cmd::BackgroundScope(turn) => format!("background_scope(turn={})", turn),
409 Cmd::ResolveApproval { call_id, decision } => {
410 format!("resolve_approval(call={}, {:?})", call_id, decision)
411 },
412 Cmd::SaveConversation(c) => format!("save_conversation(id={})", c.id),
413 Cmd::SaveCompactionArchive {
414 archive, record, ..
415 } => format!(
416 "save_compaction_archive(conversation={}, id={})",
417 archive.conversation_id, record.id
418 ),
419 Cmd::SaveProcess(p) => format!("save_process(id={}, pid={})", p.id, p.pid),
420 Cmd::PersistLastModel(m) => format!("persist_last_model({})", m),
421 Cmd::PersistReasoningFor { model_id, level } => {
422 format!("persist_reasoning_for({}, {:?})", model_id, level)
423 },
424 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
425 format!("persist_ollama_num_ctx_for({}, {:?})", model_id, num_ctx)
426 },
427 Cmd::PersistOllamaOffload(enabled) => {
428 format!("persist_ollama_offload({})", enabled)
429 },
430 Cmd::ListMemory => "list_memory".to_string(),
431 Cmd::RememberMemory { .. } => "remember_memory".to_string(),
432 Cmd::ForgetMemory { .. } => "forget_memory".to_string(),
433 Cmd::ConsolidateMemory { .. } => "consolidate_memory".to_string(),
434 Cmd::LoadConversation(id) => format!("load_conversation({})", id),
435 Cmd::ListConversations => "list_conversations".to_string(),
436 Cmd::ListRuntimeTasks { limit } => format!("list_runtime_tasks(limit={})", limit),
437 Cmd::LoadRuntimeTask { id } => format!("load_runtime_task({})", id),
438 Cmd::ListRuntimeProcesses { limit } => {
439 format!("list_runtime_processes(limit={})", limit)
440 },
441 Cmd::ShowRuntimeProcessLogs { id } => format!("show_runtime_process_logs({})", id),
442 Cmd::StopRuntimeProcess { id } => format!("stop_runtime_process({})", id),
443 Cmd::RestartRuntimeProcess { id } => format!("restart_runtime_process({})", id),
444 Cmd::OpenRuntimeTarget { target } => format!("open_runtime_target({})", target),
445 Cmd::ShowRuntimePorts => "show_runtime_ports".to_string(),
446 Cmd::ListRuntimeApprovals => "list_runtime_approvals".to_string(),
447 Cmd::DecideRuntimeApproval { id, decision } => {
448 format!("decide_runtime_approval({}, {})", id, decision)
449 },
450 Cmd::ListRuntimeCheckpoints { limit } => {
451 format!("list_runtime_checkpoints(limit={})", limit)
452 },
453 Cmd::ListRuntimePlugins => "list_runtime_plugins".to_string(),
454 Cmd::UpdateRuntimeTaskStatus { id, status, .. } => {
455 format!("update_runtime_task_status({}, {})", id, status)
456 },
457 Cmd::CreateRuntimeCheckpoint { paths } => {
458 format!("create_runtime_checkpoint(n={})", paths.len())
459 },
460 Cmd::RestoreRuntimeCheckpoint { id } => format!("restore_runtime_checkpoint({})", id),
461 Cmd::ShowRuntimeModelInfo { model } => format!("show_runtime_model_info({})", model),
462 Cmd::InitMcpServers(m) => format!("init_mcp_servers(n={})", m.len()),
463 Cmd::StopMcpServer { name } => format!("stop_mcp_server({})", name),
464 Cmd::PullOllamaModel { model } => format!("pull_ollama_model({})", model),
465 Cmd::OpenInSystem(p) => format!("open_in_system({})", p.display()),
466 Cmd::WriteImageToTemp {
467 path,
468 format,
469 bytes,
470 } => format!(
471 "write_image_to_temp(path={}, fmt={}, n={})",
472 path.display(),
473 format,
474 bytes.len()
475 ),
476 Cmd::ReadClipboard => "read_clipboard".to_string(),
477 Cmd::CopyToClipboard(t) => format!("copy_to_clipboard(n={})", t.chars().count()),
478 Cmd::Exit => "exit".to_string(),
479 Cmd::SetTerminalTitle(t) => format!("set_terminal_title({})", t),
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn turn_scoped_variants_marked_correctly() {
490 let request = ChatRequest {
491 model_id: "m".to_string(),
492 messages: vec![],
493 system_prompt: String::new(),
494 instructions: None,
495 reasoning: ReasoningLevel::Medium,
496 temperature: 0.7,
497 max_tokens: 4096,
498 tools: vec![],
499
500 ollama_num_ctx: None,
501 ollama_allow_ram_offload: None,
502 };
503 assert!(
504 Cmd::CallModel {
505 turn: TurnId(1),
506 request,
507 }
508 .is_turn_scoped()
509 );
510 assert!(
511 !Cmd::SaveConversation(ConversationHistory::new(
512 "/p".to_string(),
513 "m".to_string(),
514 chrono::Local::now()
515 ))
516 .is_turn_scoped()
517 );
518 assert!(!Cmd::Exit.is_turn_scoped());
519 }
520
521 #[test]
522 fn cmd_tags_are_stable() {
523 assert_eq!(Cmd::Exit.tag(), "exit");
524 assert_eq!(Cmd::CancelScope(TurnId(1)).tag(), "cancel_scope");
525 }
526
527 #[test]
528 fn cmd_summary_includes_identifying_fields() {
529 let c = Cmd::CancelScope(TurnId(42));
530 let s = c.summary();
531 assert!(s.contains("turn#42"));
532 }
533}