Skip to main content

mermaid_cli/domain/
runtime.rs

1//! Runtime metadata shared by the reducer, recorder, and renderer.
2//!
3//! These types deliberately carry facts rather than presentation
4//! strings. Tool output still contains the provider-facing text that
5//! goes back into the model, while this module holds the metadata the
6//! UI and future commands can consume without scraping that text.
7
8use std::collections::HashSet;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// External lifecycle signal observed by the app shell.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum RuntimeSignal {
17    Interrupt,
18    Terminate,
19    Hangup,
20}
21
22impl RuntimeSignal {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            RuntimeSignal::Interrupt => "interrupt",
26            RuntimeSignal::Terminate => "terminate",
27            RuntimeSignal::Hangup => "hangup",
28        }
29    }
30}
31
32/// Runtime event recorded in state for observability / replay tooling.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct RuntimeTimelineEvent {
35    pub kind: RuntimeTimelineKind,
36    pub message: String,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum RuntimeTimelineKind {
42    Signal,
43    Process,
44    Tool,
45    Provider,
46}
47
48/// Normalized provider capability snapshot exposed in app state.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProviderCapabilitySnapshot {
51    pub provider: String,
52    pub model: String,
53    pub supports_tools: bool,
54    pub supports_vision: bool,
55    pub reasoning: String,
56    pub max_context_tokens: Option<usize>,
57    /// Per-response output ceiling, when known (static table at model-switch
58    /// time; refreshed live via `ProviderContextResolved`).
59    #[serde(default)]
60    pub max_output_tokens: Option<usize>,
61}
62
63impl ProviderCapabilitySnapshot {
64    /// Conservative static snapshot used before a provider has been
65    /// resolved. This is intentionally cheap and side-effect free so
66    /// the reducer can update it on `/model` without touching network
67    /// or credential state.
68    pub fn from_model_id(model_id: &str) -> Self {
69        let (provider, model) = match model_id.split_once('/') {
70            Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
71                (provider.to_ascii_lowercase(), model.to_string())
72            },
73            _ => ("ollama".to_string(), model_id.to_string()),
74        };
75
76        let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
77            "anthropic" => (true, true, "adaptive".to_string()),
78            "gemini" => (true, true, "thinking_level".to_string()),
79            "meta" => (true, true, "responses_effort".to_string()),
80            "ollama" => (true, false, "binary".to_string()),
81            _ => (true, false, "effort".to_string()),
82        };
83
84        // Meta's muse-spark rides the catalog like the gpt rows (its /v1/models
85        // exposes no limits) — no provider special-case here.
86        let max_context_tokens = infer_static_context_window(&model);
87        // Output ceilings start unknown everywhere and are refreshed live via
88        // `ProviderContextResolved` (for meta, from the provider's documented
89        // capabilities after the first resolve — same one-turn delay as
90        // anthropic/gemini).
91        let max_output_tokens = None;
92
93        Self {
94            provider,
95            model,
96            supports_tools,
97            supports_vision,
98            reasoning,
99            max_context_tokens,
100            max_output_tokens,
101        }
102    }
103}
104
105fn infer_static_context_window(model: &str) -> Option<usize> {
106    // Per-model documented windows from the capability catalog — ONLY for
107    // providers whose API exposes no limits (OpenAI's gpt rows). Providers
108    // with a models endpoint (Anthropic, Gemini, Ollama, most OpenAI-compat)
109    // resolve live via `resolve_context_window`; `None` here means "unknown
110    // until discovery", never a guessed fallback.
111    crate::models::catalog::lookup(model).context_window
112}
113
114pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
115    let model = match model_id.split_once('/') {
116        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => model,
117        _ => model_id,
118    };
119    infer_static_context_window(model)
120}
121
122/// Background process status tracked by Mermaid after launching a
123/// command in `execute_command(mode="background")`.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum ManagedProcessStatus {
127    Running,
128    Exited,
129    Unknown,
130}
131
132/// Registry record for a background process Mermaid started.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct ManagedProcess {
135    pub id: String,
136    pub pid: u32,
137    pub command: String,
138    pub cwd: Option<String>,
139    pub log_path: String,
140    pub detected_url: Option<String>,
141    pub status: ManagedProcessStatus,
142}
143
144/// Structured metadata extracted from a completed tool run.
145#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
146pub struct ToolRunMetadata {
147    #[serde(default)]
148    pub detail: ToolMetadata,
149    pub line_count: Option<usize>,
150    pub byte_count: Option<usize>,
151    pub result_count: Option<usize>,
152    pub duration_secs: Option<f64>,
153    pub process: Option<ManagedProcess>,
154    /// User-facing display diff for file mutations. This is captured
155    /// at tool execution time so whole-file writes can compare against
156    /// the pre-write contents even after the file has been overwritten.
157    #[serde(default)]
158    pub display_diff: Option<String>,
159    #[serde(default)]
160    pub diff_truncated: bool,
161    /// Exact line-change counts for file mutations. Carried separately from
162    /// `display_diff` because that string is capped at
163    /// `MAX_DISPLAY_DIFF_LINES` — recounting it would undercount large
164    /// writes. `handle_tool_finished` folds these into the per-run totals
165    /// behind the end-of-run `+N/-M` summary.
166    #[serde(default)]
167    pub lines_added: usize,
168    #[serde(default)]
169    pub lines_removed: usize,
170    #[serde(default)]
171    pub artifacts: Vec<ToolArtifact>,
172    /// Provider token usage the tool itself consumed (today: a subagent's
173    /// cumulative child-session usage). `handle_tool_finished` folds it into
174    /// the parent session's totals so the footer and the end-of-run summary
175    /// count the whole tree, not just the parent's own model calls.
176    #[serde(default)]
177    pub token_usage: Option<crate::models::TokenUsage>,
178    /// This call wrote the plan file while planning — the FACT the doom-loop
179    /// breaker disarms on.
180    ///
181    /// Recorded at the boundary that actually knows it (the policy gate
182    /// approved the write, or the file mutator targeted the plan path) rather
183    /// than inferred from the tool name. Inferring it missed the shell
184    /// spelling entirely: the escalated corrective tells the model "a shell
185    /// redirect writing ONLY that file works too", and when the model complied
186    /// the breaker stayed armed and kept re-injecting "the plan file does not
187    /// exist until you write it" at a model that had just written it.
188    #[serde(default)]
189    pub plan_file_written: bool,
190}
191
192/// Tool outcome status independent of how the result is rendered.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum ToolStatus {
196    Success,
197    Error,
198    Cancelled,
199}
200
201/// Typed metadata produced by a specific tool implementation.
202#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum ToolMetadata {
205    #[default]
206    None,
207    ReadFile {
208        paths: Vec<String>,
209        line_count: usize,
210        byte_count: usize,
211        truncated: bool,
212    },
213    WriteFile {
214        path: String,
215        line_count: usize,
216        byte_count: usize,
217        created: Option<bool>,
218    },
219    ApplyPatch {
220        added: Vec<String>,
221        modified: Vec<String>,
222        deleted: Vec<String>,
223        renamed: Vec<(String, String)>,
224        fuzzy: bool,
225    },
226    DeleteFile {
227        path: String,
228    },
229    CreateDirectory {
230        path: String,
231    },
232    WebSearch {
233        queries: Vec<String>,
234        requested_count: usize,
235        result_count: usize,
236        sources: Vec<String>,
237        #[serde(default)]
238        backend: String,
239        #[serde(default)]
240        succeeded_queries: usize,
241        #[serde(default)]
242        failed_queries: usize,
243        #[serde(default)]
244        partial: bool,
245        #[serde(default)]
246        truncated: bool,
247        #[serde(default, skip_serializing_if = "Vec::is_empty")]
248        failures: Vec<WebSearchFailure>,
249    },
250    WebFetch {
251        /// Sanitized originally requested URL.
252        url: String,
253        #[serde(default)]
254        final_url: Option<String>,
255        #[serde(default)]
256        status: Option<u16>,
257        #[serde(default)]
258        error_kind: Option<String>,
259        #[serde(default)]
260        media_type: Option<String>,
261        #[serde(default)]
262        charset: Option<String>,
263        #[serde(default)]
264        backend: String,
265        #[serde(default)]
266        extraction: String,
267        title: Option<String>,
268        line_count: usize,
269        byte_count: usize,
270        #[serde(default)]
271        source_byte_count: usize,
272        /// Bytes in the extracted page before the 30 KiB rendered envelope is
273        /// applied. This may exceed the bytes retained in a bounded snapshot;
274        /// `truncated` records that distinction.
275        #[serde(default)]
276        output_byte_count: usize,
277        #[serde(default)]
278        truncated: bool,
279        #[serde(default)]
280        pattern: Option<String>,
281        #[serde(default)]
282        context_lines: Option<usize>,
283        #[serde(default)]
284        match_count: Option<usize>,
285        #[serde(default)]
286        snapshot_id: Option<String>,
287    },
288    ExecuteCommand {
289        command: String,
290        working_dir: Option<String>,
291        exit_code: Option<i32>,
292        timed_out: bool,
293        background: bool,
294        stdout_lines: usize,
295        stderr_lines: usize,
296        detected_urls: Vec<String>,
297        pid: Option<u32>,
298        log_path: Option<String>,
299        /// The command was terminated by the OS sandbox (e.g. it tried to
300        /// reach the network under `--no-network`). Additive; `#[serde(default)]`
301        /// keeps older recordings/rows deserializable.
302        #[serde(default)]
303        denied_by_sandbox: bool,
304    },
305    ComputerUse {
306        action: String,
307        params: Value,
308    },
309    Mcp {
310        server: String,
311        tool: String,
312    },
313    Subagent {
314        model_id: String,
315        /// Continuation handle: pass back via the `agent` tool's `agent_id`
316        /// arg to send a follow-up prompt to this child with its context
317        /// intact. Empty on recordings from before continuations existed.
318        #[serde(default)]
319        agent_id: String,
320    },
321    /// The task checklist tools (`task_create` / `task_update` / `task_list`).
322    /// `action` is the wire tool suffix ("create" / "update" / "list");
323    /// counts are over visible (non-deleted) tasks after the call.
324    Tasks {
325        action: String,
326        completed: u32,
327        total: u32,
328    },
329    /// `ask_user_question` resolved with answers. Kept structured so the
330    /// transcript can replay each question → answer pair rather than a bare
331    /// duration.
332    Questions {
333        answers: Vec<super::question::QuestionAnswer>,
334        /// The answers came from remembered cross-session preferences
335        /// (`memoryKey`) rather than a live prompt.
336        #[serde(default)]
337        remembered: bool,
338    },
339    /// `exit_plan_mode` resolved with an APPROVED plan: the transcript
340    /// renders the plan body as a markdown block, and `handle_tool_finished`
341    /// keys the post-approval mechanics (clear `session.plan`, seed the
342    /// checklist, optionally auto-submit) on this variant. A
343    /// request-for-changes outcome carries no metadata.
344    Plan {
345        /// Plan-file path as shown to the user (project-relative).
346        path: String,
347        /// The approved plan text, re-read from disk at approval time.
348        body: String,
349        /// True when the user chose to start implementing immediately.
350        #[serde(default)]
351        start: bool,
352        /// Execution begins in a FRESH conversation seeded with the handoff
353        /// preamble + plan (clear-context execute, or a fresh-session
354        /// handoff). The exploration context is left behind on disk.
355        #[serde(default)]
356        fresh: bool,
357        /// Handoff variant that copies the transcript into a new
358        /// conversation before starting (mutually exclusive with `fresh`).
359        #[serde(default)]
360        fork: bool,
361        /// Handoff: switch the session to this model for execution.
362        #[serde(default)]
363        model: Option<String>,
364    },
365    Custom {
366        name: String,
367        data: Value,
368    },
369}
370
371/// One failed item from an ordered web-search batch. The index preserves its
372/// relationship to the input without copying potentially sensitive query text
373/// into telemetry; `error` is redacted before construction.
374#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
375pub struct WebSearchFailure {
376    /// Zero-based index into the original `queries` array.
377    pub query_index: usize,
378    /// Secret-redacted, byte-bounded backend failure detail.
379    pub error: String,
380}
381
382/// Non-text artifact produced by a tool. Images are base64 strings to
383/// match the existing chat-message storage format.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(tag = "kind", rename_all = "snake_case")]
386pub enum ToolArtifact {
387    Image { data: String },
388    File { path: String },
389    Log { path: String },
390}
391
392/// The resolved Ollama context window for the active model, reported by the
393/// effect runner after the first turn. Drives the `/context` display and the
394/// truncation quick-fix. `model_max` is the probed architectural window;
395/// `effective` is the `num_ctx` we actually send.
396#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
397pub struct OllamaContextInfo {
398    pub model_max: Option<usize>,
399    pub effective: Option<usize>,
400    pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
401}
402
403/// Post-turn memory placement of the loaded Ollama model, from `/api/ps`.
404/// `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the part
405/// resident in VRAM. Volatile (changes when the model reloads), so it lives
406/// outside the quasi-static [`OllamaContextInfo`].
407#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
408pub struct OllamaPlacement {
409    pub size_vram_bytes: u64,
410    pub total_bytes: u64,
411}
412
413impl OllamaPlacement {
414    /// True when the model didn't fully fit VRAM and spilled to CPU/RAM (slow).
415    pub fn offloaded(&self) -> bool {
416        self.size_vram_bytes < self.total_bytes
417    }
418
419    /// Rough percentage of the model running on CPU/RAM (0–100). Integer math;
420    /// `0` when the footprint is unknown or fully resident.
421    pub fn percent_on_cpu(&self) -> u8 {
422        if self.total_bytes == 0 {
423            return 0;
424        }
425        let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
426        (on_cpu.saturating_mul(100) / self.total_bytes) as u8
427    }
428}
429
430/// A subagent detached from its turn via Ctrl+B: still running in a
431/// spawned task, no longer blocking the parent. Rows render in the live
432/// agent panel until `Msg::BackgroundAgentFinished` removes them (and the
433/// child's report arrives as a queued message).
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct BackgroundAgent {
436    pub agent_id: String,
437    pub description: String,
438    pub started: std::time::SystemTime,
439    #[serde(default)]
440    pub activity: String,
441    #[serde(default)]
442    pub tokens: usize,
443}
444
445/// Runtime state that is not part of the chat transcript sent to a
446/// model, but is useful for UI, slash commands, and debugging.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct RuntimeState {
449    pub provider_capabilities: ProviderCapabilitySnapshot,
450    #[serde(default)]
451    pub processes: Vec<ManagedProcess>,
452    /// Subagents detached from their turn via Ctrl+B, newest last.
453    #[serde(default)]
454    pub background_agents: Vec<BackgroundAgent>,
455    #[serde(default)]
456    pub timeline: Vec<RuntimeTimelineEvent>,
457    /// Estimated token cost of the built-in tool schemas the effect runner
458    /// appends to every model request during dispatch. The reducer's
459    /// `/context` preview builds an MCP-only request and can't see these, so
460    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
461    /// `/context` folds it in to match what dispatch actually decides.
462    #[serde(default)]
463    pub builtin_tool_schema_tokens: usize,
464    /// Resolved Ollama context window for the active model (`None` until the
465    /// first turn probes it, or for non-Ollama providers).
466    #[serde(default)]
467    pub ollama_context: Option<OllamaContextInfo>,
468    /// Post-turn `/api/ps` memory placement for the active model (`None` until a
469    /// turn probes it). Volatile, so it's tracked separately from the window.
470    #[serde(default)]
471    pub ollama_placement: Option<OllamaPlacement>,
472    /// Models we've already shown the proactive auto-fit hint for this session.
473    /// Session-only (not persisted) so the gentle reminder reappears each launch.
474    #[serde(skip)]
475    pub hinted_models: HashSet<String>,
476    /// Models we've already warned about VRAM offload this session. Session-only,
477    /// so the once-per-session warning behaves like the auto-fit hint.
478    #[serde(skip)]
479    pub offload_warned: HashSet<String>,
480    /// Model-call cycles since the task checklist last changed, while a task
481    /// sits in_progress. Drives the staleness nudge (see `push_call_model`);
482    /// session-only, reset by every `Msg::TasksUpdated`.
483    #[serde(skip)]
484    pub calls_since_task_update: u32,
485    /// Plan-mode doom-loop breaker, armed by the FIRST plan-policy denial of
486    /// the current stretch (a read-heavy Ground phase alone must never trip
487    /// it). While armed, `push_plan_reminder` counts model calls; at the
488    /// threshold the tail reminder escalates to a corrective. Disarmed by a
489    /// successful plan write (`write_file`/`apply_patch` — the only Edit that
490    /// can succeed under the plan floor) and cleared on plan entry/exit.
491    /// Session-only.
492    #[serde(skip)]
493    pub plan_thrash_armed: bool,
494    /// Model calls since the arming denial (see `plan_thrash_armed`).
495    #[serde(skip)]
496    pub plan_calls_since_denial: u32,
497    /// Models we've already shown the no-vision-model notice for this session.
498    /// Session-only (not persisted), so the one-shot warning behaves like the
499    /// auto-fit hint and offload warning.
500    #[serde(skip)]
501    pub vision_warned: HashSet<String>,
502    /// Auto-converge: per-model `num_ctx` that the post-turn `/api/ps` check
503    /// found fits VRAM, keyed by model id. Session-only (not persisted) because
504    /// it depends on whatever else is using VRAM right now; re-derived each
505    /// session. Read by `build_chat_request` below a user override.
506    #[serde(skip)]
507    pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
508    /// When the current user interaction began. One "turn" in Mermaid is a single
509    /// model call + its tools; an agentic run spans many such turns (each tool
510    /// follow-up mints a fresh `TurnId`). This anchors the spinner's elapsed timer
511    /// to the *whole* run so it doesn't reset to 0 at every tool step. Set on
512    /// submit, read only while generating/executing tools. Session-only.
513    #[serde(skip)]
514    pub run_started: Option<std::time::SystemTime>,
515    /// Output tokens committed in *completed* phases of the current run
516    /// (parent turns, subagents, mid-run compactions), so the spinner's token
517    /// counter accumulates across tool steps instead of resetting each model
518    /// call. The live phase's char-based estimate is added on top at render
519    /// time.
520    #[serde(skip)]
521    pub run_tokens: RunTokenCounter,
522    /// Lines added/removed by file-mutating tools (write_file, apply_patch)
523    /// across the whole run, summed from each outcome's exact metadata counts
524    /// so the end-of-run summary can show `+N/-M` without the user totting up
525    /// per-call diffs. Reset on submit alongside `run_tokens`. Session-only.
526    #[serde(skip)]
527    pub run_line_changes: RunLineChanges,
528    /// Consecutive auto-compact-and-continue recoveries in the current run after a
529    /// context-window truncation. Bounded by `settings.compaction.max_truncation_recoveries`
530    /// (0 = uncapped) and reset whenever the run makes progress, so it caps only
531    /// no-progress thrashing on a too-small window. Session-only.
532    #[serde(skip)]
533    pub truncation_recoveries: u32,
534    /// Consecutive turns in the current run that produced no visible output (no
535    /// assistant text and no tool calls) — even if the model spent the turn on
536    /// hidden reasoning. Under `MAX_EMPTY_CONTINUATIONS` the run auto-retries the
537    /// model call so a stalled turn isn't left silent; at the cap it stops with a
538    /// hint. Reset on a fresh run and whenever a turn makes progress. Session-only.
539    #[serde(skip)]
540    pub empty_continuations: u32,
541    /// Consecutive auto-continuations in the current run after a response hit
542    /// the provider's per-response OUTPUT cap with window room to spare
543    /// (compaction can't help those — the reply is continued in a fresh turn
544    /// instead). Bounded by `MAX_OUTPUT_CONTINUATIONS`; reset whenever a turn
545    /// ends any other way. Session-only.
546    #[serde(skip)]
547    pub continue_recoveries: u32,
548    /// Auto-threshold compaction failed, so it is paused until a compaction
549    /// succeeds, the user runs `/compact`, or the conversation is switched.
550    /// Without this, a summarizer that keeps failing (e.g. a model that can't
551    /// produce the required checkpoint structure) silently retries — and pays
552    /// for — a draft + review model call on every subsequent turn. Session-only.
553    #[serde(skip)]
554    pub auto_compact_suppressed: bool,
555}
556
557/// Output tokens generated by the current run, with provenance. Real
558/// provider counts (completion + reasoning) are the norm; a phase whose
559/// provider reported no usage falls back to a chars/4 estimate and taints
560/// the whole counter, so the run summary can mark the number `~`.
561#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
562pub struct RunTokenCounter {
563    pub output_tokens: usize,
564    pub contains_estimate: bool,
565}
566
567impl RunTokenCounter {
568    pub fn add_provider(&mut self, tokens: usize) {
569        self.output_tokens = self.output_tokens.saturating_add(tokens);
570    }
571
572    pub fn add_estimate(&mut self, tokens: usize) {
573        self.output_tokens = self.output_tokens.saturating_add(tokens);
574        self.contains_estimate = true;
575    }
576}
577
578/// Lines added/removed by file mutations in the current run.
579#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
580pub struct RunLineChanges {
581    pub added: usize,
582    pub removed: usize,
583}
584
585impl RunLineChanges {
586    pub fn add(&mut self, added: usize, removed: usize) {
587        self.added = self.added.saturating_add(added);
588        self.removed = self.removed.saturating_add(removed);
589    }
590
591    pub fn is_empty(&self) -> bool {
592        self.added == 0 && self.removed == 0
593    }
594}
595
596impl RuntimeState {
597    pub fn new(model_id: &str) -> Self {
598        Self {
599            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
600            processes: Vec::new(),
601            background_agents: Vec::new(),
602            timeline: Vec::new(),
603            builtin_tool_schema_tokens: 0,
604            ollama_context: None,
605            ollama_placement: None,
606            hinted_models: HashSet::new(),
607            offload_warned: HashSet::new(),
608            calls_since_task_update: 0,
609            plan_thrash_armed: false,
610            plan_calls_since_denial: 0,
611            vision_warned: HashSet::new(),
612            ollama_converged_num_ctx: std::collections::HashMap::new(),
613            run_started: None,
614            run_tokens: RunTokenCounter::default(),
615            run_line_changes: RunLineChanges::default(),
616            truncation_recoveries: 0,
617            empty_continuations: 0,
618            continue_recoveries: 0,
619            auto_compact_suppressed: false,
620        }
621    }
622
623    /// Cap on `timeline` length. It's a recent-activity log for `/runtime` and
624    /// the serialized snapshot, not an audit trail, so the oldest events are
625    /// trimmed — otherwise it grows monotonically for the session's life (and
626    /// it's `#[serde(default)]`, so it would also bloat every saved snapshot).
627    const MAX_TIMELINE_EVENTS: usize = 200;
628
629    /// Append a timeline event, trimming the oldest so the log stays bounded.
630    fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
631        self.timeline.push(RuntimeTimelineEvent { kind, message });
632        let len = self.timeline.len();
633        if len > Self::MAX_TIMELINE_EVENTS {
634            self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
635        }
636    }
637
638    pub fn set_model(&mut self, model_id: &str) {
639        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
640        // New model → the resolved window + placement no longer apply; re-probed
641        // next turn.
642        self.ollama_context = None;
643        self.ollama_placement = None;
644        // The pause is model-scoped: a summarizer that couldn't produce the
645        // checkpoint structure says nothing about the newly selected model,
646        // and switching models is the natural user reaction to the failure.
647        self.auto_compact_suppressed = false;
648        self.push_timeline(
649            RuntimeTimelineKind::Provider,
650            format!("model set to {}", model_id),
651        );
652    }
653
654    pub fn record_signal(&mut self, signal: RuntimeSignal) {
655        self.push_timeline(
656            RuntimeTimelineKind::Signal,
657            format!("received {}", signal.as_str()),
658        );
659    }
660
661    pub fn register_process(&mut self, process: ManagedProcess) {
662        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
663            *existing = process.clone();
664        } else {
665            self.processes.push(process.clone());
666        }
667        self.push_timeline(
668            RuntimeTimelineKind::Process,
669            format!("registered process {} ({})", process.pid, process.command),
670        );
671    }
672}
673
674impl Default for RuntimeState {
675    fn default() -> Self {
676        Self::new("ollama/unknown")
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn static_context_windows_pin_the_known_matrix() {
686        // ONLY the OpenAI gpt rows and Meta's muse-spark keep static windows
687        // (their /v1/models expose no limits). Everything else is None —
688        // unknown until live discovery via `resolve_context_window` fills it.
689        for (id, want) in [
690            ("openai/gpt-4.1", Some(400_000)),
691            ("openai/gpt-5-mini", Some(400_000)),
692            ("openai/gpt-5.6", Some(1_500_000)),
693            (
694                "meta/muse-spark-1.1",
695                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
696            ),
697            (
698                "meta/muse-spark-1.2",
699                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
700            ),
701            ("anthropic/claude-sonnet-4-6", None),
702            ("gemini/gemini-2.5-pro", None),
703            ("openrouter/anthropic/claude-sonnet-4.5", None),
704            ("openai/gpt-4o", None),
705            ("ollama/qwen3-coder:30b", None),
706            ("anthropic/claude-future-99", None),
707            ("anthropic/nova-experimental", None),
708        ] {
709            assert_eq!(
710                infer_static_context_window_for_model_id(id),
711                want,
712                "window for {id}"
713            );
714        }
715    }
716
717    #[test]
718    fn snapshot_limits_start_unknown_before_discovery() {
719        // Pre-discovery snapshots carry no window/ceiling for providers
720        // with a limits endpoint — `ProviderContextResolved` refreshes them
721        // on the first turn. No static pins to rot.
722        let snap = ProviderCapabilitySnapshot::from_model_id("anthropic/claude-fable-5");
723        assert_eq!(snap.max_context_tokens, None);
724        assert_eq!(snap.max_output_tokens, None);
725        let snap = ProviderCapabilitySnapshot::from_model_id("gemini/gemini-2.5-pro");
726        assert_eq!(snap.max_context_tokens, None);
727        assert_eq!(snap.max_output_tokens, None);
728        let snap = ProviderCapabilitySnapshot::from_model_id("openai/gpt-4o");
729        assert_eq!(snap.max_output_tokens, None);
730    }
731
732    #[test]
733    fn timeline_is_bounded_and_keeps_most_recent() {
734        let mut rt = RuntimeState::new("ollama/test");
735        // `new` may seed an initial event; push well past the cap and confirm
736        // the log is trimmed to the most recent window rather than growing.
737        for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
738            rt.record_signal(RuntimeSignal::Interrupt);
739        }
740        assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
741        // The newest event is retained (front-trim keeps the tail).
742        assert_eq!(
743            rt.timeline.last().map(|e| e.message.as_str()),
744            Some("received interrupt")
745        );
746    }
747}