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 /// Walk the project for the @-mention file picker (gitignore-aware,
225 /// capped, sorted). Emits `Msg::ProjectFilesListed` with relative paths
226 /// (directories carry a trailing `/`).
227 ListProjectFiles,
228 /// List durable daemon/runtime tasks.
229 ListRuntimeTasks { limit: usize },
230 /// Load one durable daemon/runtime task and its timeline.
231 LoadRuntimeTask { id: String },
232 /// List durable daemon/runtime background processes.
233 ListRuntimeProcesses { limit: usize },
234 /// Print one durable process log into the conversation.
235 ShowRuntimeProcessLogs { id: String },
236 /// Stop a durable process by pid.
237 StopRuntimeProcess { id: String },
238 /// Cancel a detached background subagent (`None` = all of them). The
239 /// effect layer fires the kill token held by the `SubagentSpawner`; the
240 /// dying child reports back via `Msg::BackgroundAgentFinished`.
241 KillBackgroundAgent { agent_id: Option<String> },
242 /// Restart a durable process using its recorded command/cwd.
243 RestartRuntimeProcess { id: String },
244 /// Open a URL, path, or process target.
245 OpenRuntimeTarget { target: String },
246 /// Show listening ports.
247 ShowRuntimePorts,
248 /// List pending approval records.
249 ListRuntimeApprovals,
250 /// Mark one approval as approved or denied.
251 DecideRuntimeApproval { id: String, decision: String },
252 /// List restore checkpoints.
253 ListRuntimeCheckpoints { limit: usize },
254 /// Query checkpoints of `session_id` anchored strictly past
255 /// `message_index` — fired by rewind/fork so the reducer can tell the
256 /// user which file checkpoints the discarded timeline left behind.
257 /// Replies with `Msg::ForkCheckpointsFound`.
258 ListForkCheckpoints {
259 session_id: String,
260 message_index: usize,
261 },
262 /// List installed plugins.
263 ListRuntimePlugins,
264 /// Update one durable task's status.
265 UpdateRuntimeTaskStatus {
266 id: String,
267 status: TaskStatus,
268 final_report: Option<String>,
269 },
270 /// Create a shadow checkpoint for explicit paths.
271 CreateRuntimeCheckpoint { paths: Vec<PathBuf> },
272 /// Restore files from a shadow checkpoint.
273 RestoreRuntimeCheckpoint { id: String },
274 /// Show provider/model capability information.
275 ShowRuntimeModelInfo { model: String },
276
277 // ── MCP lifecycle ───────────────────────────────────────────────
278 /// Start every configured MCP server; each one emits
279 /// `Msg::McpServerReady` or `Msg::McpServerErrored` as it comes up.
280 InitMcpServers(HashMap<String, McpServerConfig>),
281 /// Stop a running server (e.g. config was removed, or app quit).
282 StopMcpServer { name: String },
283
284 // ── Ollama helpers ──────────────────────────────────────────────
285 /// `ollama pull <model>` with progress → `Msg::ModelPullFinished`.
286 PullOllamaModel { model: String },
287 /// Probe whether `model_id` advertises the `vision` capability (Ollama
288 /// `/api/show`) → `Msg::ProviderVisionResolved`. `warn` rides through so the
289 /// reducer nags only when an image is actually in play (a paste, or a
290 /// `/model` switch with an image already staged); the probe always refreshes
291 /// the capability snapshot regardless.
292 ProbeVision { model_id: String, warn: bool },
293
294 // ── UI side-effects (cross-process) ─────────────────────────────
295 /// `xdg-open` / `open` / `start` on a file path. Used by the
296 /// image-paste preview and the "open in editor" affordance.
297 OpenInSystem(PathBuf),
298
299 // ── Attachments ─────────────────────────────────────────────────
300 /// Persist a pasted image to a temp file so the TUI can open it
301 /// via `OpenInSystem`. Emits no follow-up Msg on success; failure
302 /// is a log-and-drop.
303 WriteImageToTemp {
304 path: PathBuf,
305 bytes: Vec<u8>,
306 format: String,
307 },
308
309 /// Read the system clipboard on a blocking task. The per-platform
310 /// dispatch (xclip / wl-paste / pngpaste / PowerShell) can block
311 /// for hundreds of ms on macOS via osascript, so it never runs on
312 /// the reducer thread. Always emits `Msg::ClipboardRead(..)` —
313 /// `Image`/`Text` on success, `Empty`/`Error` otherwise — so the
314 /// paste-race guard sees every read resolve.
315 ReadClipboard,
316
317 /// Write text to the system clipboard on a blocking task (mirrors
318 /// `ReadClipboard`'s per-platform dispatch). Used by in-app drag-select
319 /// copy. Emits a `Msg::TransientStatus` ("Copied N chars" / failure).
320 CopyToClipboard(String),
321
322 // ── Terminal lifecycle ──────────────────────────────────────────
323 /// Suspend the TUI and open `$VISUAL`/`$EDITOR` on the input draft
324 /// (Ctrl+O / `/editor`). Intercepted by the interactive run loop — it
325 /// owns the terminal and event stream — and never reaches the effect
326 /// runner there; headless drivers log-and-drop it. The round-trip
327 /// resolves as `Msg::EditorReturned`.
328 ComposeInEditor { text: String },
329 /// Exit the main loop. No reply message — the loop observes
330 /// `state.should_exit` after the reducer returns and breaks out.
331 Exit,
332 /// Write the OSC 2 terminal-title sequence. Reducer diffs
333 /// against `ui.last_title_dispatched` so this only fires on
334 /// actual title changes, not every frame.
335 SetTerminalTitle(String),
336 /// Ring the terminal bell (BEL) to draw the user's attention — emitted on
337 /// run completion / a pending approval only while the terminal is
338 /// unfocused. Suppressed in headless mode (same gate as the title).
339 AlertUser,
340}
341
342/// Inputs a model needs to generate a turn. Built by the reducer from
343/// `Session` + `Settings` + current `MERMAID.md` context. Pure data —
344/// no provider-specific knowledge here (that's in
345/// `providers::model::*::chat`).
346/// `Default` exists so adding a field costs one line here instead of a
347/// mechanical edit in every construction site across providers, the CLI and
348/// the tests (adding `suppressed_builtin_tools` took 15). Use
349/// `..ChatRequest::default()` for the fields a caller does not care about.
350#[derive(Debug, Clone, Default)]
351pub struct ChatRequest {
352 pub model_id: String,
353 pub messages: Vec<ChatMessage>,
354 pub system_prompt: String,
355 /// `MERMAID.md` content to suffix onto the system prompt. `None` if
356 /// no file was loaded for this project.
357 pub instructions: Option<String>,
358 pub reasoning: ReasoningLevel,
359 pub temperature: f32,
360 pub max_tokens: usize,
361 /// Tool definitions advertised to the model. Combination of the
362 /// built-in tool set + any advertised MCP tools from `McpState`.
363 pub tools: Vec<ToolDefinition>,
364 /// Per-model Ollama `num_ctx` override (`/context <n>`/`max`), or `None` to
365 /// auto-fit. The one provider-specific knob that rides on the request, the
366 /// same way `reasoning` does — so a live `/context` change applies on the
367 /// next turn without rebuilding the cached provider. Ignored by non-Ollama
368 /// providers.
369 pub ollama_num_ctx: Option<u32>,
370 /// Live Ollama RAM-offload toggle (`/context offload on|off`). Rides on the
371 /// request like `ollama_num_ctx` so a toggle applies on the next turn without
372 /// rebuilding the cached provider (whose `config` is frozen at startup).
373 /// `None` falls back to the persisted `[ollama] allow_ram_offload`. Ignored
374 /// by non-Ollama providers.
375 pub ollama_allow_ram_offload: Option<bool>,
376 /// The model's real context window, filled by the effect layer from
377 /// cache-first live discovery (`resolve_context_window`) just before
378 /// dispatch. The reducer always initializes it `None`; `None` at the
379 /// adapter means "unknown" (no window clamp).
380 pub resolved_context_window: Option<usize>,
381 /// The model's real per-response output ceiling, filled alongside
382 /// `resolved_context_window`. `None` means unknown — adapters that
383 /// require a concrete `max_tokens` (Anthropic) fall back to a floor.
384 pub resolved_max_output: Option<usize>,
385 /// `mermaid run --output-schema`: the JSON Schema the FORMATTING turn
386 /// must conform to. Set only on that dedicated turn (never during the
387 /// agentic loop — Gemini rejects tools+schema, Ollama's `format` would
388 /// degrade tool calling). When `Some`, the request carries no tools:
389 /// the reducer sends none and the effect runner skips the built-ins.
390 pub output_schema: Option<serde_json::Value>,
391 /// Pause automatic threshold compaction for this turn. Set from
392 /// `RuntimeState::auto_compact_suppressed` after an auto-compaction
393 /// failure, so a chronically failing summarizer doesn't burn a model call
394 /// every turn. Rides on the request like `ollama_num_ctx` because the
395 /// effect preflight sees only the request, never `RuntimeState`. Cleared
396 /// by a successful compaction, a manual `/compact`, or a conversation
397 /// switch.
398 pub suppress_auto_compact: bool,
399 /// Built-in tool names the effect layer must NOT advertise on this
400 /// request. Mirrors the `output_schema` empty-tools gate: the reducer
401 /// (which knows the mode) decides, the effect layer (which owns the
402 /// registry) enforces. Plan mode hides the checklist WRITERS here —
403 /// advertising a tool whose description says "create the full initial
404 /// plan" while the gate hard-errors it invites exactly that call.
405 pub suppressed_builtin_tools: Vec<&'static str>,
406}
407
408/// Provider-agnostic tool definition sent in the request. Concrete
409/// adapters (`providers::model::ollama`, etc.) translate this into
410/// whatever wire shape their API expects.
411#[derive(Debug, Clone)]
412pub struct ToolDefinition {
413 pub name: String,
414 pub description: String,
415 pub input_schema: serde_json::Value,
416}
417
418impl ToolDefinition {
419 /// Wire shape: `{type: "function", function: {name, description,
420 /// parameters}}`. This is the OpenAI / Ollama Chat Completions
421 /// format; Anthropic and Gemini adapters translate further from
422 /// here. Single-canonical-shape keeps adapters from drifting.
423 pub fn to_openai_json(&self) -> serde_json::Value {
424 serde_json::json!({
425 "type": "function",
426 "function": {
427 "name": self.name,
428 "description": self.description,
429 "parameters": self.input_schema,
430 }
431 })
432 }
433}
434
435impl Cmd {
436 /// Human-readable tag, for tracing + replay logs. Stable across
437 /// refactors (tests assert against it).
438 pub fn tag(&self) -> &'static str {
439 match self {
440 Cmd::CallModel { .. } => "call_model",
441 Cmd::CompactConversation { .. } => "compact_conversation",
442 Cmd::ExecuteTool { .. } => "execute_tool",
443 Cmd::CancelScope(_) => "cancel_scope",
444 Cmd::BackgroundScope(_) => "background_scope",
445 Cmd::ResolveApproval { .. } => "resolve_approval",
446 Cmd::ResolveQuestion { .. } => "resolve_question",
447 Cmd::SyncTaskStore(_) => "sync_task_store",
448 Cmd::PersistPlanConfig(_) => "persist_plan_config",
449 Cmd::UserTaskEdit(_) => "user_task_edit",
450 Cmd::NotifyTaskCompleted { .. } => "notify_task_completed",
451 Cmd::EnsureScratchpad { .. } => "ensure_scratchpad",
452 Cmd::ListScratchpad { .. } => "list_scratchpad",
453 Cmd::SaveConversation(_) => "save_conversation",
454 Cmd::SaveCompactionArchive { .. } => "save_compaction_archive",
455 Cmd::SaveProcess(_) => "save_process",
456 Cmd::PersistLastModel(_) => "persist_last_model",
457 Cmd::PersistReasoningFor { .. } => "persist_reasoning_for",
458 Cmd::PersistOllamaNumCtxFor { .. } => "persist_ollama_num_ctx_for",
459 Cmd::PersistOllamaOffload(_) => "persist_ollama_offload",
460 Cmd::PersistUiTheme(_) => "persist_ui_theme",
461 Cmd::ListMemory => "list_memory",
462 Cmd::RememberMemory { .. } => "remember_memory",
463 Cmd::ForgetMemory { .. } => "forget_memory",
464 Cmd::ConsolidateMemory { .. } => "consolidate_memory",
465 Cmd::LoadConversation(_) => "load_conversation",
466 Cmd::ListConversations => "list_conversations",
467 Cmd::ListProjectFiles => "list_project_files",
468 Cmd::ListRuntimeTasks { .. } => "list_runtime_tasks",
469 Cmd::LoadRuntimeTask { .. } => "load_runtime_task",
470 Cmd::ListRuntimeProcesses { .. } => "list_runtime_processes",
471 Cmd::ShowRuntimeProcessLogs { .. } => "show_runtime_process_logs",
472 Cmd::StopRuntimeProcess { .. } => "stop_runtime_process",
473 Cmd::KillBackgroundAgent { .. } => "kill_background_agent",
474 Cmd::RestartRuntimeProcess { .. } => "restart_runtime_process",
475 Cmd::OpenRuntimeTarget { .. } => "open_runtime_target",
476 Cmd::ShowRuntimePorts => "show_runtime_ports",
477 Cmd::ListRuntimeApprovals => "list_runtime_approvals",
478 Cmd::DecideRuntimeApproval { .. } => "decide_runtime_approval",
479 Cmd::ListRuntimeCheckpoints { .. } => "list_runtime_checkpoints",
480 Cmd::ListForkCheckpoints { .. } => "list_fork_checkpoints",
481 Cmd::ListRuntimePlugins => "list_runtime_plugins",
482 Cmd::UpdateRuntimeTaskStatus { .. } => "update_runtime_task_status",
483 Cmd::CreateRuntimeCheckpoint { .. } => "create_runtime_checkpoint",
484 Cmd::RestoreRuntimeCheckpoint { .. } => "restore_runtime_checkpoint",
485 Cmd::ShowRuntimeModelInfo { .. } => "show_runtime_model_info",
486 Cmd::InitMcpServers(_) => "init_mcp_servers",
487 Cmd::StopMcpServer { .. } => "stop_mcp_server",
488 Cmd::PullOllamaModel { .. } => "pull_ollama_model",
489 Cmd::ProbeVision { .. } => "probe_vision",
490 Cmd::OpenInSystem(_) => "open_in_system",
491 Cmd::WriteImageToTemp { .. } => "write_image_to_temp",
492 Cmd::ReadClipboard => "read_clipboard",
493 Cmd::CopyToClipboard(_) => "copy_to_clipboard",
494 Cmd::ComposeInEditor { .. } => "compose_in_editor",
495 Cmd::Exit => "exit",
496 Cmd::SetTerminalTitle(_) => "set_terminal_title",
497 Cmd::AlertUser => "alert_user",
498 }
499 }
500
501 /// True iff this command needs to run inside a `TurnScope` so it
502 /// can be cancelled by `Cmd::CancelScope`. The effect runner uses
503 /// this to decide between "spawn into `JoinSet`" and "spawn detached".
504 pub fn is_turn_scoped(&self) -> bool {
505 matches!(
506 self,
507 Cmd::CallModel { .. } | Cmd::CompactConversation { .. } | Cmd::ExecuteTool { .. }
508 )
509 }
510
511 /// The `TurnId` of the scope this command would spawn fresh work
512 /// *into*. Only the scope-spawning variants return `Some` (the same
513 /// set as `is_turn_scoped`); the scope-control variants
514 /// (`CancelScope` / `BackgroundScope`) act on an existing scope
515 /// rather than populating one with new work, so they return `None`.
516 ///
517 /// The effect runner uses this to refuse spawning fresh work for a
518 /// turn it has already cancelled (tombstoned) — a stray post-cancel
519 /// `CallModel`/`ExecuteTool`/`CompactConversation` would otherwise
520 /// resurrect an un-cancelled scope via `scope_mut`'s `or_insert_with`
521 /// (F38). `CancelScope` must keep working on a tombstoned turn, which
522 /// is exactly why it is excluded here.
523 pub fn scope_turn(&self) -> Option<TurnId> {
524 match self {
525 Cmd::CallModel { turn, .. }
526 | Cmd::CompactConversation { turn, .. }
527 | Cmd::ExecuteTool { turn, .. } => Some(*turn),
528 _ => None,
529 }
530 }
531
532 /// For traces + the `--record` file — some `Cmd` payloads are huge
533 /// (think `ChatRequest::messages`). This returns a compact
534 /// identifier that doesn't dump the full payload.
535 pub fn summary(&self) -> String {
536 match self {
537 Cmd::CallModel { turn, request } => format!(
538 "call_model(turn={}, model={}, msgs={})",
539 turn,
540 request.model_id,
541 request.messages.len()
542 ),
543 Cmd::CompactConversation { turn, request } => format!(
544 "compact_conversation(turn={}, model={}, trigger={}, msgs={})",
545 turn,
546 request.chat.model_id,
547 request.trigger.as_str(),
548 request.chat.messages.len()
549 ),
550 Cmd::ExecuteTool {
551 turn,
552 call_id,
553 source,
554 ..
555 } => format!(
556 "execute_tool(turn={}, call={}, fn={})",
557 turn, call_id, source.function.name
558 ),
559 Cmd::CancelScope(turn) => format!("cancel_scope(turn={})", turn),
560 Cmd::BackgroundScope(turn) => format!("background_scope(turn={})", turn),
561 Cmd::ResolveApproval { call_id, decision } => {
562 format!("resolve_approval(call={}, {:?})", call_id, decision)
563 },
564 Cmd::ResolveQuestion {
565 call_id,
566 resolution,
567 } => {
568 let kind = match resolution {
569 QuestionResolution::Answered { answers, .. } => {
570 format!("answered({})", answers.len())
571 },
572 QuestionResolution::Dismissed => "dismissed".to_string(),
573 QuestionResolution::Reformulate => "reformulate".to_string(),
574 };
575 format!("resolve_question(call={}, {})", call_id, kind)
576 },
577 Cmd::PersistPlanConfig(_) => "persist_plan_config".to_string(),
578 Cmd::SyncTaskStore(store) => {
579 format!("sync_task_store(tasks={})", store.tasks.len())
580 },
581 Cmd::UserTaskEdit(edit) => format!("user_task_edit({:?})", edit),
582 Cmd::NotifyTaskCompleted {
583 task,
584 completed,
585 total,
586 } => format!(
587 "notify_task_completed(id={}, {}/{})",
588 task.id, completed, total
589 ),
590 Cmd::EnsureScratchpad { session_id } => {
591 format!("ensure_scratchpad(session={})", session_id)
592 },
593 Cmd::ListScratchpad { path } => {
594 format!("list_scratchpad({})", path.display())
595 },
596 Cmd::SaveConversation(c) => format!("save_conversation(id={})", c.id),
597 Cmd::SaveCompactionArchive {
598 archive, record, ..
599 } => format!(
600 "save_compaction_archive(conversation={}, id={})",
601 archive.conversation_id, record.id
602 ),
603 Cmd::SaveProcess(p) => format!("save_process(id={}, pid={})", p.id, p.pid),
604 Cmd::PersistLastModel(m) => format!("persist_last_model({})", m),
605 Cmd::PersistReasoningFor { model_id, level } => {
606 format!("persist_reasoning_for({}, {:?})", model_id, level)
607 },
608 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
609 format!("persist_ollama_num_ctx_for({}, {:?})", model_id, num_ctx)
610 },
611 Cmd::PersistOllamaOffload(enabled) => {
612 format!("persist_ollama_offload({})", enabled)
613 },
614 Cmd::PersistUiTheme(theme) => format!("persist_ui_theme({})", theme.as_str()),
615 Cmd::ListMemory => "list_memory".to_string(),
616 Cmd::RememberMemory { .. } => "remember_memory".to_string(),
617 Cmd::ForgetMemory { .. } => "forget_memory".to_string(),
618 Cmd::ConsolidateMemory { .. } => "consolidate_memory".to_string(),
619 Cmd::LoadConversation(id) => format!("load_conversation({})", id),
620 Cmd::ListConversations => "list_conversations".to_string(),
621 Cmd::ListProjectFiles => "list_project_files".to_string(),
622 Cmd::ListRuntimeTasks { limit } => format!("list_runtime_tasks(limit={})", limit),
623 Cmd::LoadRuntimeTask { id } => format!("load_runtime_task({})", id),
624 Cmd::ListRuntimeProcesses { limit } => {
625 format!("list_runtime_processes(limit={})", limit)
626 },
627 Cmd::ShowRuntimeProcessLogs { id } => format!("show_runtime_process_logs({})", id),
628 Cmd::StopRuntimeProcess { id } => format!("stop_runtime_process({})", id),
629 Cmd::KillBackgroundAgent { agent_id } => format!(
630 "kill_background_agent({})",
631 agent_id.as_deref().unwrap_or("all")
632 ),
633 Cmd::RestartRuntimeProcess { id } => format!("restart_runtime_process({})", id),
634 Cmd::OpenRuntimeTarget { target } => format!("open_runtime_target({})", target),
635 Cmd::ShowRuntimePorts => "show_runtime_ports".to_string(),
636 Cmd::ListRuntimeApprovals => "list_runtime_approvals".to_string(),
637 Cmd::DecideRuntimeApproval { id, decision } => {
638 format!("decide_runtime_approval({}, {})", id, decision)
639 },
640 Cmd::ListForkCheckpoints {
641 session_id,
642 message_index,
643 } => {
644 format!("list_fork_checkpoints({session_id} > {message_index})")
645 },
646 Cmd::ListRuntimeCheckpoints { limit } => {
647 format!("list_runtime_checkpoints(limit={})", limit)
648 },
649 Cmd::ListRuntimePlugins => "list_runtime_plugins".to_string(),
650 Cmd::UpdateRuntimeTaskStatus { id, status, .. } => {
651 format!("update_runtime_task_status({}, {})", id, status)
652 },
653 Cmd::CreateRuntimeCheckpoint { paths } => {
654 format!("create_runtime_checkpoint(n={})", paths.len())
655 },
656 Cmd::RestoreRuntimeCheckpoint { id } => format!("restore_runtime_checkpoint({})", id),
657 Cmd::ShowRuntimeModelInfo { model } => format!("show_runtime_model_info({})", model),
658 Cmd::InitMcpServers(m) => format!("init_mcp_servers(n={})", m.len()),
659 Cmd::StopMcpServer { name } => format!("stop_mcp_server({})", name),
660 Cmd::PullOllamaModel { model } => format!("pull_ollama_model({})", model),
661 Cmd::ProbeVision { model_id, warn } => format!("probe_vision({model_id}, warn={warn})"),
662 Cmd::OpenInSystem(p) => format!("open_in_system({})", p.display()),
663 Cmd::WriteImageToTemp {
664 path,
665 format,
666 bytes,
667 } => format!(
668 "write_image_to_temp(path={}, fmt={}, n={})",
669 path.display(),
670 format,
671 bytes.len()
672 ),
673 Cmd::ReadClipboard => "read_clipboard".to_string(),
674 Cmd::CopyToClipboard(t) => format!("copy_to_clipboard(n={})", t.chars().count()),
675 Cmd::ComposeInEditor { text } => {
676 format!("compose_in_editor(n={})", text.chars().count())
677 },
678 Cmd::Exit => "exit".to_string(),
679 Cmd::SetTerminalTitle(t) => format!("set_terminal_title({})", t),
680 Cmd::AlertUser => "alert_user".to_string(),
681 }
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn turn_scoped_variants_marked_correctly() {
691 let request = ChatRequest {
692 model_id: "m".to_string(),
693 messages: vec![],
694 system_prompt: String::new(),
695 instructions: None,
696 reasoning: ReasoningLevel::Medium,
697 temperature: 0.7,
698 max_tokens: 4096,
699 tools: vec![],
700
701 ollama_num_ctx: None,
702 ollama_allow_ram_offload: None,
703 resolved_context_window: None,
704 resolved_max_output: None,
705 output_schema: None,
706 suppress_auto_compact: false,
707 suppressed_builtin_tools: Vec::new(),
708 };
709 assert!(
710 Cmd::CallModel {
711 turn: TurnId(1),
712 request,
713 }
714 .is_turn_scoped()
715 );
716 assert!(
717 !Cmd::SaveConversation(ConversationHistory::new(
718 "/p".to_string(),
719 "m".to_string(),
720 chrono::Local::now()
721 ))
722 .is_turn_scoped()
723 );
724 assert!(!Cmd::Exit.is_turn_scoped());
725 }
726
727 #[test]
728 fn cmd_tags_are_stable() {
729 assert_eq!(Cmd::Exit.tag(), "exit");
730 assert_eq!(Cmd::CancelScope(TurnId(1)).tag(), "cancel_scope");
731 }
732
733 #[test]
734 fn cmd_summary_includes_identifying_fields() {
735 let c = Cmd::CancelScope(TurnId(42));
736 let s = c.summary();
737 assert!(s.contains("turn#42"));
738 }
739}