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}