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}