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}
179
180/// Tool outcome status independent of how the result is rendered.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ToolStatus {
184    Success,
185    Error,
186    Cancelled,
187}
188
189/// Typed metadata produced by a specific tool implementation.
190#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
191#[serde(tag = "kind", rename_all = "snake_case")]
192pub enum ToolMetadata {
193    #[default]
194    None,
195    ReadFile {
196        paths: Vec<String>,
197        line_count: usize,
198        byte_count: usize,
199        truncated: bool,
200    },
201    WriteFile {
202        path: String,
203        line_count: usize,
204        byte_count: usize,
205        created: Option<bool>,
206    },
207    ApplyPatch {
208        added: Vec<String>,
209        modified: Vec<String>,
210        deleted: Vec<String>,
211        renamed: Vec<(String, String)>,
212        fuzzy: bool,
213    },
214    DeleteFile {
215        path: String,
216    },
217    CreateDirectory {
218        path: String,
219    },
220    WebSearch {
221        queries: Vec<String>,
222        requested_count: usize,
223        result_count: usize,
224        sources: Vec<String>,
225    },
226    WebFetch {
227        url: String,
228        title: Option<String>,
229        line_count: usize,
230        byte_count: usize,
231    },
232    ExecuteCommand {
233        command: String,
234        working_dir: Option<String>,
235        exit_code: Option<i32>,
236        timed_out: bool,
237        background: bool,
238        stdout_lines: usize,
239        stderr_lines: usize,
240        detected_urls: Vec<String>,
241        pid: Option<u32>,
242        log_path: Option<String>,
243        /// The command was terminated by the OS sandbox (e.g. it tried to
244        /// reach the network under `--no-network`). Additive; `#[serde(default)]`
245        /// keeps older recordings/rows deserializable.
246        #[serde(default)]
247        denied_by_sandbox: bool,
248    },
249    ComputerUse {
250        action: String,
251        params: Value,
252    },
253    Mcp {
254        server: String,
255        tool: String,
256    },
257    Subagent {
258        model_id: String,
259        /// Continuation handle: pass back via the `agent` tool's `agent_id`
260        /// arg to send a follow-up prompt to this child with its context
261        /// intact. Empty on recordings from before continuations existed.
262        #[serde(default)]
263        agent_id: String,
264    },
265    /// The task checklist tools (`task_create` / `task_update` / `task_list`).
266    /// `action` is the wire tool suffix ("create" / "update" / "list");
267    /// counts are over visible (non-deleted) tasks after the call.
268    Tasks {
269        action: String,
270        completed: u32,
271        total: u32,
272    },
273    /// `ask_user_question` resolved with answers. Kept structured so the
274    /// transcript can replay each question → answer pair rather than a bare
275    /// duration.
276    Questions {
277        answers: Vec<super::question::QuestionAnswer>,
278        /// The answers came from remembered cross-session preferences
279        /// (`memoryKey`) rather than a live prompt.
280        #[serde(default)]
281        remembered: bool,
282    },
283    /// `exit_plan_mode` resolved with an APPROVED plan: the transcript
284    /// renders the plan body as a markdown block, and `handle_tool_finished`
285    /// keys the post-approval mechanics (clear `session.plan`, seed the
286    /// checklist, optionally auto-submit) on this variant. A
287    /// request-for-changes outcome carries no metadata.
288    Plan {
289        /// Plan-file path as shown to the user (project-relative).
290        path: String,
291        /// The approved plan text, re-read from disk at approval time.
292        body: String,
293        /// True when the user chose to start implementing immediately.
294        #[serde(default)]
295        start: bool,
296        /// Execution begins in a FRESH conversation seeded with the handoff
297        /// preamble + plan (clear-context execute, or a fresh-session
298        /// handoff). The exploration context is left behind on disk.
299        #[serde(default)]
300        fresh: bool,
301        /// Handoff variant that copies the transcript into a new
302        /// conversation before starting (mutually exclusive with `fresh`).
303        #[serde(default)]
304        fork: bool,
305        /// Handoff: switch the session to this model for execution.
306        #[serde(default)]
307        model: Option<String>,
308    },
309    Custom {
310        name: String,
311        data: Value,
312    },
313}
314
315/// Non-text artifact produced by a tool. Images are base64 strings to
316/// match the existing chat-message storage format.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(tag = "kind", rename_all = "snake_case")]
319pub enum ToolArtifact {
320    Image { data: String },
321    File { path: String },
322    Log { path: String },
323}
324
325/// The resolved Ollama context window for the active model, reported by the
326/// effect runner after the first turn. Drives the `/context` display and the
327/// truncation quick-fix. `model_max` is the probed architectural window;
328/// `effective` is the `num_ctx` we actually send.
329#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
330pub struct OllamaContextInfo {
331    pub model_max: Option<usize>,
332    pub effective: Option<usize>,
333    pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
334}
335
336/// Post-turn memory placement of the loaded Ollama model, from `/api/ps`.
337/// `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the part
338/// resident in VRAM. Volatile (changes when the model reloads), so it lives
339/// outside the quasi-static [`OllamaContextInfo`].
340#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
341pub struct OllamaPlacement {
342    pub size_vram_bytes: u64,
343    pub total_bytes: u64,
344}
345
346impl OllamaPlacement {
347    /// True when the model didn't fully fit VRAM and spilled to CPU/RAM (slow).
348    pub fn offloaded(&self) -> bool {
349        self.size_vram_bytes < self.total_bytes
350    }
351
352    /// Rough percentage of the model running on CPU/RAM (0–100). Integer math;
353    /// `0` when the footprint is unknown or fully resident.
354    pub fn percent_on_cpu(&self) -> u8 {
355        if self.total_bytes == 0 {
356            return 0;
357        }
358        let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
359        (on_cpu.saturating_mul(100) / self.total_bytes) as u8
360    }
361}
362
363/// A subagent detached from its turn via Ctrl+B: still running in a
364/// spawned task, no longer blocking the parent. Rows render in the live
365/// agent panel until `Msg::BackgroundAgentFinished` removes them (and the
366/// child's report arrives as a queued message).
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct BackgroundAgent {
369    pub agent_id: String,
370    pub description: String,
371    pub started: std::time::SystemTime,
372    #[serde(default)]
373    pub activity: String,
374    #[serde(default)]
375    pub tokens: usize,
376}
377
378/// Runtime state that is not part of the chat transcript sent to a
379/// model, but is useful for UI, slash commands, and debugging.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct RuntimeState {
382    pub provider_capabilities: ProviderCapabilitySnapshot,
383    #[serde(default)]
384    pub processes: Vec<ManagedProcess>,
385    /// Subagents detached from their turn via Ctrl+B, newest last.
386    #[serde(default)]
387    pub background_agents: Vec<BackgroundAgent>,
388    #[serde(default)]
389    pub timeline: Vec<RuntimeTimelineEvent>,
390    /// Estimated token cost of the built-in tool schemas the effect runner
391    /// appends to every model request during dispatch. The reducer's
392    /// `/context` preview builds an MCP-only request and can't see these, so
393    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
394    /// `/context` folds it in to match what dispatch actually decides.
395    #[serde(default)]
396    pub builtin_tool_schema_tokens: usize,
397    /// Resolved Ollama context window for the active model (`None` until the
398    /// first turn probes it, or for non-Ollama providers).
399    #[serde(default)]
400    pub ollama_context: Option<OllamaContextInfo>,
401    /// Post-turn `/api/ps` memory placement for the active model (`None` until a
402    /// turn probes it). Volatile, so it's tracked separately from the window.
403    #[serde(default)]
404    pub ollama_placement: Option<OllamaPlacement>,
405    /// Models we've already shown the proactive auto-fit hint for this session.
406    /// Session-only (not persisted) so the gentle reminder reappears each launch.
407    #[serde(skip)]
408    pub hinted_models: HashSet<String>,
409    /// Models we've already warned about VRAM offload this session. Session-only,
410    /// so the once-per-session warning behaves like the auto-fit hint.
411    #[serde(skip)]
412    pub offload_warned: HashSet<String>,
413    /// Model-call cycles since the task checklist last changed, while a task
414    /// sits in_progress. Drives the staleness nudge (see `push_call_model`);
415    /// session-only, reset by every `Msg::TasksUpdated`.
416    #[serde(skip)]
417    pub calls_since_task_update: u32,
418    /// Models we've already shown the no-vision-model notice for this session.
419    /// Session-only (not persisted), so the one-shot warning behaves like the
420    /// auto-fit hint and offload warning.
421    #[serde(skip)]
422    pub vision_warned: HashSet<String>,
423    /// Auto-converge: per-model `num_ctx` that the post-turn `/api/ps` check
424    /// found fits VRAM, keyed by model id. Session-only (not persisted) because
425    /// it depends on whatever else is using VRAM right now; re-derived each
426    /// session. Read by `build_chat_request` below a user override.
427    #[serde(skip)]
428    pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
429    /// When the current user interaction began. One "turn" in Mermaid is a single
430    /// model call + its tools; an agentic run spans many such turns (each tool
431    /// follow-up mints a fresh `TurnId`). This anchors the spinner's elapsed timer
432    /// to the *whole* run so it doesn't reset to 0 at every tool step. Set on
433    /// submit, read only while generating/executing tools. Session-only.
434    #[serde(skip)]
435    pub run_started: Option<std::time::SystemTime>,
436    /// Output tokens committed in *completed* phases of the current run
437    /// (parent turns, subagents, mid-run compactions), so the spinner's token
438    /// counter accumulates across tool steps instead of resetting each model
439    /// call. The live phase's char-based estimate is added on top at render
440    /// time.
441    #[serde(skip)]
442    pub run_tokens: RunTokenCounter,
443    /// Lines added/removed by file-mutating tools (write_file, apply_patch)
444    /// across the whole run, summed from each outcome's exact metadata counts
445    /// so the end-of-run summary can show `+N/-M` without the user totting up
446    /// per-call diffs. Reset on submit alongside `run_tokens`. Session-only.
447    #[serde(skip)]
448    pub run_line_changes: RunLineChanges,
449    /// Consecutive auto-compact-and-continue recoveries in the current run after a
450    /// context-window truncation. Bounded by `settings.compaction.max_truncation_recoveries`
451    /// (0 = uncapped) and reset whenever the run makes progress, so it caps only
452    /// no-progress thrashing on a too-small window. Session-only.
453    #[serde(skip)]
454    pub truncation_recoveries: u32,
455    /// Consecutive turns in the current run that produced no visible output (no
456    /// assistant text and no tool calls) — even if the model spent the turn on
457    /// hidden reasoning. Under `MAX_EMPTY_CONTINUATIONS` the run auto-retries the
458    /// model call so a stalled turn isn't left silent; at the cap it stops with a
459    /// hint. Reset on a fresh run and whenever a turn makes progress. Session-only.
460    #[serde(skip)]
461    pub empty_continuations: u32,
462    /// Consecutive auto-continuations in the current run after a response hit
463    /// the provider's per-response OUTPUT cap with window room to spare
464    /// (compaction can't help those — the reply is continued in a fresh turn
465    /// instead). Bounded by `MAX_OUTPUT_CONTINUATIONS`; reset whenever a turn
466    /// ends any other way. Session-only.
467    #[serde(skip)]
468    pub continue_recoveries: u32,
469    /// Auto-threshold compaction failed, so it is paused until a compaction
470    /// succeeds, the user runs `/compact`, or the conversation is switched.
471    /// Without this, a summarizer that keeps failing (e.g. a model that can't
472    /// produce the required checkpoint structure) silently retries — and pays
473    /// for — a draft + review model call on every subsequent turn. Session-only.
474    #[serde(skip)]
475    pub auto_compact_suppressed: bool,
476}
477
478/// Output tokens generated by the current run, with provenance. Real
479/// provider counts (completion + reasoning) are the norm; a phase whose
480/// provider reported no usage falls back to a chars/4 estimate and taints
481/// the whole counter, so the run summary can mark the number `~`.
482#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
483pub struct RunTokenCounter {
484    pub output_tokens: usize,
485    pub contains_estimate: bool,
486}
487
488impl RunTokenCounter {
489    pub fn add_provider(&mut self, tokens: usize) {
490        self.output_tokens = self.output_tokens.saturating_add(tokens);
491    }
492
493    pub fn add_estimate(&mut self, tokens: usize) {
494        self.output_tokens = self.output_tokens.saturating_add(tokens);
495        self.contains_estimate = true;
496    }
497}
498
499/// Lines added/removed by file mutations in the current run.
500#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
501pub struct RunLineChanges {
502    pub added: usize,
503    pub removed: usize,
504}
505
506impl RunLineChanges {
507    pub fn add(&mut self, added: usize, removed: usize) {
508        self.added = self.added.saturating_add(added);
509        self.removed = self.removed.saturating_add(removed);
510    }
511
512    pub fn is_empty(&self) -> bool {
513        self.added == 0 && self.removed == 0
514    }
515}
516
517impl RuntimeState {
518    pub fn new(model_id: &str) -> Self {
519        Self {
520            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
521            processes: Vec::new(),
522            background_agents: Vec::new(),
523            timeline: Vec::new(),
524            builtin_tool_schema_tokens: 0,
525            ollama_context: None,
526            ollama_placement: None,
527            hinted_models: HashSet::new(),
528            offload_warned: HashSet::new(),
529            calls_since_task_update: 0,
530            vision_warned: HashSet::new(),
531            ollama_converged_num_ctx: std::collections::HashMap::new(),
532            run_started: None,
533            run_tokens: RunTokenCounter::default(),
534            run_line_changes: RunLineChanges::default(),
535            truncation_recoveries: 0,
536            empty_continuations: 0,
537            continue_recoveries: 0,
538            auto_compact_suppressed: false,
539        }
540    }
541
542    /// Cap on `timeline` length. It's a recent-activity log for `/runtime` and
543    /// the serialized snapshot, not an audit trail, so the oldest events are
544    /// trimmed — otherwise it grows monotonically for the session's life (and
545    /// it's `#[serde(default)]`, so it would also bloat every saved snapshot).
546    const MAX_TIMELINE_EVENTS: usize = 200;
547
548    /// Append a timeline event, trimming the oldest so the log stays bounded.
549    fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
550        self.timeline.push(RuntimeTimelineEvent { kind, message });
551        let len = self.timeline.len();
552        if len > Self::MAX_TIMELINE_EVENTS {
553            self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
554        }
555    }
556
557    pub fn set_model(&mut self, model_id: &str) {
558        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
559        // New model → the resolved window + placement no longer apply; re-probed
560        // next turn.
561        self.ollama_context = None;
562        self.ollama_placement = None;
563        // The pause is model-scoped: a summarizer that couldn't produce the
564        // checkpoint structure says nothing about the newly selected model,
565        // and switching models is the natural user reaction to the failure.
566        self.auto_compact_suppressed = false;
567        self.push_timeline(
568            RuntimeTimelineKind::Provider,
569            format!("model set to {}", model_id),
570        );
571    }
572
573    pub fn record_signal(&mut self, signal: RuntimeSignal) {
574        self.push_timeline(
575            RuntimeTimelineKind::Signal,
576            format!("received {}", signal.as_str()),
577        );
578    }
579
580    pub fn register_process(&mut self, process: ManagedProcess) {
581        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
582            *existing = process.clone();
583        } else {
584            self.processes.push(process.clone());
585        }
586        self.push_timeline(
587            RuntimeTimelineKind::Process,
588            format!("registered process {} ({})", process.pid, process.command),
589        );
590    }
591}
592
593impl Default for RuntimeState {
594    fn default() -> Self {
595        Self::new("ollama/unknown")
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn static_context_windows_pin_the_known_matrix() {
605        // ONLY the OpenAI gpt rows and Meta's muse-spark keep static windows
606        // (their /v1/models expose no limits). Everything else is None —
607        // unknown until live discovery via `resolve_context_window` fills it.
608        for (id, want) in [
609            ("openai/gpt-4.1", Some(400_000)),
610            ("openai/gpt-5-mini", Some(400_000)),
611            ("openai/gpt-5.6", Some(1_500_000)),
612            (
613                "meta/muse-spark-1.1",
614                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
615            ),
616            (
617                "meta/muse-spark-1.2",
618                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
619            ),
620            ("anthropic/claude-sonnet-4-6", None),
621            ("gemini/gemini-2.5-pro", None),
622            ("openrouter/anthropic/claude-sonnet-4.5", None),
623            ("openai/gpt-4o", None),
624            ("ollama/qwen3-coder:30b", None),
625            ("anthropic/claude-future-99", None),
626            ("anthropic/nova-experimental", None),
627        ] {
628            assert_eq!(
629                infer_static_context_window_for_model_id(id),
630                want,
631                "window for {id}"
632            );
633        }
634    }
635
636    #[test]
637    fn snapshot_limits_start_unknown_before_discovery() {
638        // Pre-discovery snapshots carry no window/ceiling for providers
639        // with a limits endpoint — `ProviderContextResolved` refreshes them
640        // on the first turn. No static pins to rot.
641        let snap = ProviderCapabilitySnapshot::from_model_id("anthropic/claude-fable-5");
642        assert_eq!(snap.max_context_tokens, None);
643        assert_eq!(snap.max_output_tokens, None);
644        let snap = ProviderCapabilitySnapshot::from_model_id("gemini/gemini-2.5-pro");
645        assert_eq!(snap.max_context_tokens, None);
646        assert_eq!(snap.max_output_tokens, None);
647        let snap = ProviderCapabilitySnapshot::from_model_id("openai/gpt-4o");
648        assert_eq!(snap.max_output_tokens, None);
649    }
650
651    #[test]
652    fn timeline_is_bounded_and_keeps_most_recent() {
653        let mut rt = RuntimeState::new("ollama/test");
654        // `new` may seed an initial event; push well past the cap and confirm
655        // the log is trimmed to the most recent window rather than growing.
656        for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
657            rt.record_signal(RuntimeSignal::Interrupt);
658        }
659        assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
660        // The newest event is retained (front-trim keeps the tail).
661        assert_eq!(
662            rt.timeline.last().map(|e| e.message.as_str()),
663            Some("received interrupt")
664        );
665    }
666}