Skip to main content

rab/agent/
agent_session.rs

1use crate::agent::branch_summary::{collect_entries_for_branch_summary, generate_branch_summary};
2use crate::agent::compaction::{
3    self, CompactionReason, CompactionResult, CompactionSettings, compact, prepare_compaction,
4};
5use crate::agent::extension::Extension;
6use crate::agent::session::SessionManager;
7use crate::agent::session::model::MessageCost;
8use crate::agent::types::{message_text, user_message};
9use std::sync::Arc;
10
11use crate::provider::ProviderRegistry;
12use yoagent::types::AgentMessage;
13use yoagent::types::Message;
14
15// ── Compaction lifecycle events ─────────────────────────────────────
16
17/// Events emitted during the compaction lifecycle.
18/// Matches pi's `compaction_start` / `compaction_end` event semantics.
19#[derive(Debug, Clone)]
20pub enum CompactionEvent {
21    /// Compaction has started with the given reason.
22    Start { reason: CompactionReason },
23    /// Compaction completed successfully.
24    End {
25        reason: CompactionReason,
26        result: CompactionResult,
27        aborted: bool,
28        will_retry: bool,
29        error_message: Option<String>,
30    },
31}
32
33/// Callback for compaction lifecycle events.
34pub type CompactionEventCallback = Box<dyn Fn(&CompactionEvent) + Send + Sync>;
35
36/// Bridges the agent loop events and session persistence.
37///
38/// Handles:
39/// - Event-driven message persistence (persist tool results as they arrive)
40/// - Automatic model/thinking/tool change detection and persistence
41pub struct AgentSession {
42    /// The session manager (owns Session + flush logic).
43    mgr: SessionManager,
44    /// Last known model for change detection.
45    last_model: Option<(String, String)>,
46    /// Last known thinking level for change detection.
47    last_thinking_level: String,
48    /// Last known active tool names for change detection.
49    last_active_tools: Option<Vec<String>>,
50    /// Compaction settings (default: enabled).
51    compaction_settings: CompactionSettings,
52    /// Model context window in tokens (for shouldCompact check).
53    context_window: u64,
54    /// Model name to use for compaction LLM calls.
55    model_name: String,
56    /// API key for compaction LLM calls.
57    compaction_api_key: Option<String>,
58    /// Model configuration for compaction LLM calls (base URL, compat flags, etc.).
59    model_config: Option<yoagent::provider::model::ModelConfig>,
60    /// Current thinking level from the session (for compaction summarization).
61    thinking_level: yoagent::types::ThinkingLevel,
62    /// Registered extensions (for compaction hooks).
63    extensions: Vec<Box<dyn Extension>>,
64    /// Lifecycle event listeners.
65    event_listeners: Vec<CompactionEventCallback>,
66    /// Whether overflow recovery has already been attempted (prevents loops).
67    overflow_recovery_attempted: bool,
68    /// Cancellation token for in-progress compaction (pi-compatible abort).
69    compaction_cancel: crate::agent::extension::Cancel,
70    /// Provider registry for resolving model cost configs per message (pi-style).
71    registry: Option<Arc<ProviderRegistry>>,
72}
73
74impl AgentSession {
75    /// Create a new AgentSession from a SessionManager (pi-compatible: keeps the manager).
76    pub fn new(mgr: SessionManager) -> Self {
77        // Snapshot current metadata from the session context for change detection.
78        let ctx = mgr.session().build_context();
79
80        // If the session has no thinking level change entries, set last_thinking_level
81        // to empty so the first on_thinking_level_change always detects a change.
82        let has_thinking_entries = !mgr
83            .session()
84            .find_entries("thinking_level_change")
85            .is_empty();
86        let last_thinking_level = if has_thinking_entries {
87            ctx.thinking_level
88        } else {
89            String::new()
90        };
91
92        Self {
93            mgr,
94            last_model: ctx.model,
95            last_thinking_level,
96            last_active_tools: ctx.active_tool_names,
97            compaction_settings: CompactionSettings::default(),
98            context_window: 200_000,
99            model_name: String::new(),
100            compaction_api_key: None,
101            model_config: None,
102            thinking_level: yoagent::types::ThinkingLevel::Off,
103            extensions: Vec::new(),
104            event_listeners: Vec::new(),
105            overflow_recovery_attempted: false,
106            compaction_cancel: crate::agent::extension::Cancel::new(),
107            registry: None,
108        }
109    }
110
111    // ── Static factory methods ─────────────────────────────────
112
113    /// Create a new persisted session.
114    pub fn create(cwd: &std::path::Path, session_dir: Option<&std::path::Path>) -> Self {
115        Self::new(SessionManager::create(cwd, session_dir))
116    }
117
118    /// Open a specific session file.
119    pub fn open(
120        path: &std::path::Path,
121        session_dir: Option<&std::path::Path>,
122        cwd_override: Option<&std::path::Path>,
123    ) -> Self {
124        Self::new(SessionManager::open(path, session_dir, cwd_override))
125    }
126
127    /// Create an in-memory session (no persistence).
128    pub fn in_memory(cwd: &std::path::Path) -> Self {
129        Self::new(SessionManager::in_memory(cwd))
130    }
131
132    /// Continue most recent session or create new.
133    pub fn continue_recent(cwd: &std::path::Path, session_dir: Option<&std::path::Path>) -> Self {
134        Self::new(SessionManager::continue_recent(cwd, session_dir))
135    }
136
137    /// Fork a session from another project directory.
138    pub fn fork_from(
139        source_path: &std::path::Path,
140        target_cwd: &std::path::Path,
141        session_dir: Option<&std::path::Path>,
142        options: Option<&crate::agent::session::NewSessionOptions>,
143    ) -> std::io::Result<Self> {
144        SessionManager::fork_from(source_path, target_cwd, session_dir, options).map(Self::new)
145    }
146
147    /// Configure compaction with API key, model, context window, and model config.
148    pub fn set_compaction_config(
149        &mut self,
150        api_key: String,
151        model_name: &str,
152        context_window: u64,
153        model_config: Option<yoagent::provider::model::ModelConfig>,
154    ) {
155        self.compaction_api_key = Some(api_key);
156        self.model_name = model_name.to_string();
157        self.context_window = context_window;
158        self.model_config = model_config;
159    }
160
161    /// Enable or disable auto-compaction.
162    pub fn set_auto_compact(&mut self, enabled: bool) {
163        self.compaction_settings.enabled = enabled;
164    }
165
166    /// Set the provider registry for per-message cost computation (pi-style).
167    pub fn set_registry(&mut self, registry: Arc<ProviderRegistry>) {
168        self.registry = Some(registry);
169    }
170
171    /// Sync the thinking level from the session context.
172    /// Should be called after the session context changes.
173    pub fn sync_thinking_level(&mut self) {
174        let ctx = self.mgr.session().build_context();
175        let level_str = ctx.thinking_level.to_lowercase();
176        self.thinking_level = match level_str.as_str() {
177            "off" => yoagent::types::ThinkingLevel::Off,
178            "minimal" => yoagent::types::ThinkingLevel::Minimal,
179            "low" => yoagent::types::ThinkingLevel::Low,
180            "medium" => yoagent::types::ThinkingLevel::Medium,
181            "high" => yoagent::types::ThinkingLevel::High,
182            _ => yoagent::types::ThinkingLevel::Off,
183        };
184    }
185
186    /// Get the current compaction settings (mutable, for modification).
187    pub fn compaction_settings_mut(&mut self) -> &mut CompactionSettings {
188        &mut self.compaction_settings
189    }
190
191    /// Get the current compaction settings.
192    pub fn compaction_settings(&self) -> &CompactionSettings {
193        &self.compaction_settings
194    }
195
196    /// Set the list of extensions (for compaction hooks).
197    pub fn set_extensions(&mut self, extensions: Vec<Box<dyn Extension>>) {
198        self.extensions = extensions;
199    }
200
201    /// Abort any in-progress compaction (matching pi's `abortCompaction()`).
202    /// The cancellation will be picked up by extension hooks on their next
203    /// `cancel.is_cancelled()` check.
204    pub fn abort_compaction(&self) {
205        self.compaction_cancel.cancel();
206    }
207
208    /// Register a compaction lifecycle event listener.
209    pub fn on_compaction_event(&mut self, callback: CompactionEventCallback) {
210        self.event_listeners.push(callback);
211    }
212
213    /// Emit a compaction event to all registered listeners.
214    fn emit_compaction_event(&self, event: &CompactionEvent) {
215        for listener in &self.event_listeners {
216            listener(event);
217        }
218    }
219
220    /// Reset overflow recovery state (called when starting a new turn).
221    /// Pi-compatible: reset overflow recovery when a user message arrives
222    /// (matches pi's _overflowRecoveryAttempted reset in message_start for user role).
223    pub fn reset_overflow_recovery(&mut self) {
224        self.overflow_recovery_attempted = false;
225        self.compaction_cancel = crate::agent::extension::Cancel::new();
226    }
227
228    /// Check if a provider error indicates context overflow.
229    /// Matches pi's context overflow detection patterns.
230    pub fn is_context_overflow_error(msg: &AgentMessage) -> bool {
231        let text = message_text(msg);
232        let lower = text.to_lowercase();
233        // Pi-compatible: detect HTTP 413, "prompt too long", "context_length_exceeded", etc.
234        lower.contains("413")
235            || lower.contains("request_too_large")
236            || lower.contains("prompt too long")
237            || lower.contains("context_length_exceeded")
238            || lower.contains("context overflow")
239            || lower.contains("max context length")
240            || lower.contains("exceeded max tokens")
241            || lower.contains("maximum context length")
242    }
243
244    // ── Accessors ─────────────────────────────────────────────────
245
246    /// Borrow the underlying session manager.
247    /// Borrow the underlying Session.
248    pub fn session(&self) -> &crate::agent::session::Session {
249        self.mgr.session()
250    }
251
252    /// Borrow the underlying SessionManager.
253    pub fn session_manager(&self) -> &crate::agent::session::SessionManager {
254        &self.mgr
255    }
256
257    /// Mutably borrow the underlying Session.
258    pub fn session_mut(&mut self) -> &mut crate::agent::session::Session {
259        self.mgr.session_mut()
260    }
261
262    /// Consume and return the inner Session.
263    pub fn into_session(self) -> crate::agent::session::Session {
264        self.mgr.into_session()
265    }
266
267    /// Flush is handled automatically by `SessionManager` on every `append_message`.
268    /// Call this to force an early flush (e.g. before saving state externally).
269    pub fn ensure_flushed(&mut self) {
270        self.mgr.ensure_flushed();
271    }
272
273    // ── App-level accessors ────────────────────────────────────
274
275    pub fn cwd(&self) -> &std::path::Path {
276        self.mgr.cwd()
277    }
278
279    pub fn session_dir(&self) -> &std::path::Path {
280        self.mgr.session_dir()
281    }
282
283    pub fn is_persisted(&self) -> bool {
284        self.mgr.is_persisted()
285    }
286
287    pub fn session_id(&self) -> String {
288        self.mgr.session().session_id()
289    }
290
291    pub fn session_file(&self) -> Option<std::path::PathBuf> {
292        self.mgr.session().session_file()
293    }
294
295    pub fn session_name(&self) -> Option<String> {
296        self.mgr.session().session_name()
297    }
298
299    // ── Model / thinking / tool change tracking ─────────────────
300
301    /// Persist a model change if it differs from the last known model.
302    /// Pi-compatible: writes immediately to the session.
303    pub fn on_model_change(&mut self, provider: &str, model_id: &str) -> bool {
304        let new = (provider.to_string(), model_id.to_string());
305        if self.last_model.as_ref() != Some(&new) {
306            self.mgr
307                .session_mut()
308                .append_model_change(provider, model_id);
309            self.last_model = Some(new);
310            true
311        } else {
312            false
313        }
314    }
315
316    /// Persist a thinking level change if it differs from the last known level.
317    /// Pi-compatible: writes immediately to the session.
318    pub fn on_thinking_level_change(&mut self, level: &str) -> bool {
319        if self.last_thinking_level != level {
320            self.mgr.session_mut().append_thinking_level_change(level);
321            self.last_thinking_level = level.to_string();
322            true
323        } else {
324            false
325        }
326    }
327
328    /// Persist an active tools change if it differs from the last known set.
329    /// Pi-compatible: writes immediately to the session.
330    pub fn on_active_tools_change(&mut self, tools: &[String]) -> bool {
331        let tools_vec = tools.to_vec();
332        if self.last_active_tools.as_ref() != Some(&tools_vec) {
333            self.mgr
334                .session_mut()
335                .append_active_tools_change(&tools_vec);
336            self.last_active_tools = Some(tools_vec);
337            true
338        } else {
339            false
340        }
341    }
342
343    // ── User message submission ───────────────────────────────────
344
345    /// Reset the session (creates a new empty session) and clear
346    /// all tracked state so the new session starts fresh.
347    pub fn new_session(&mut self) {
348        self.mgr.new_session(None);
349        self.last_model = None;
350        self.last_thinking_level = String::new();
351        self.last_active_tools = None;
352        self.compaction_cancel = crate::agent::extension::Cancel::new();
353    }
354
355    /// Append a user message to the session (pi-compatible: persists immediately).
356    /// Returns the entry id.
357    pub fn send_user_message(&mut self, content: &str) -> String {
358        let msg = user_message(content);
359        self.mgr.append_message(&msg)
360    }
361
362    /// Append a user message (pre-constructed) to the session.
363    /// Returns the entry id.
364    pub fn send_user_message_obj(&mut self, msg: &AgentMessage) -> String {
365        self.mgr.append_message(msg)
366    }
367
368    // ── Event-driven persistence ──────────────────────────────────
369
370    /// Process an agent event for automatic persistence (pi-compatible).
371    ///
372    /// Pi persists every message (user, assistant, tool result, custom) immediately
373    /// on `message_end`, not deferred to `agent_end`. Extension messages use
374    /// `custom_message` entries (excluded from LLM context); all others use regular
375    /// `message` entries.
376    ///
377    /// Cost is computed per-message at creation time using the model's cost config
378    /// from the provider registry (pi-style: `calculateCost` in models.ts).
379    ///
380    /// Call this from your agent event handler.
381    pub fn on_agent_event(&mut self, event: &yoagent::types::AgentEvent) {
382        // Pi-compatible: persist every message immediately on message_end
383        if let yoagent::types::AgentEvent::MessageEnd { message } = event {
384            // Pi-compatible: reset overflow recovery when a user message arrives
385            // (matches pi's _overflowRecoveryAttempted reset in message_start for user role).
386            if crate::agent::types::message_is_user(message) {
387                self.reset_overflow_recovery();
388            }
389            // Pi-compatible: persist every message immediately on message_end.
390            // Extension messages use custom_message entries (excluded from LLM context);
391            // all others use regular messages.
392            if crate::agent::types::message_is_extension(message) {
393                self.persist_extension_message(message);
394            } else {
395                // Compute cost per-message using model's cost config (pi-style).
396                let cost = self.compute_message_cost(message);
397                self.mgr.append_message_with_cost(message, cost);
398            }
399        }
400    }
401
402    /// Compute the USD cost of a message using the provider registry.
403    /// Returns 0.0 if the message isn't an assistant message, the registry is unset,
404    /// or the model can't be resolved.
405    fn compute_message_cost(&self, message: &AgentMessage) -> MessageCost {
406        // Only assistant messages have usage data.
407        let (provider, model_id, usage) = match message {
408            AgentMessage::Llm(Message::Assistant {
409                provider,
410                model,
411                usage,
412                ..
413            }) => (provider.as_str(), model.as_str(), usage),
414            _ => return MessageCost::ZERO,
415        };
416
417        let Some(ref registry) = self.registry else {
418            return MessageCost::ZERO;
419        };
420
421        // Resolve the model to get its cost config.
422        let Ok(resolved) = registry.resolve(model_id, Some(provider)) else {
423            return MessageCost::ZERO;
424        };
425
426        let cost_config = &resolved.model_config.cost;
427        let (input, output, cache_read, cache_write, _total) =
428            crate::provider::calculate_cost(cost_config, usage);
429        MessageCost::new(input, output, cache_read, cache_write)
430    }
431
432    // ── Compaction ────────────────────────────────────────────────
433
434    /// Check if compaction should run and execute it if needed.
435    /// Should be called after the agent finishes a turn (after on_agent_end).
436    /// Returns `true` if compaction was performed.
437    pub async fn check_auto_compact(&mut self) -> Result<bool, String> {
438        Ok(self
439            ._run_compaction(CompactionReason::Threshold, None, false)
440            .await?
441            .is_some())
442    }
443
444    /// Run compaction after a context overflow error.
445    /// If `will_retry` is true, the agent turn will be retried after compaction.
446    /// Returns `Ok(true)` if compaction was performed, `Ok(false)` if recovery already attempted.
447    pub async fn check_overflow_compact(&mut self, will_retry: bool) -> Result<bool, String> {
448        if self.overflow_recovery_attempted {
449            return Ok(false);
450        }
451        self.overflow_recovery_attempted = true;
452        Ok(self
453            ._run_compaction(CompactionReason::Overflow, None, will_retry)
454            .await?
455            .is_some())
456    }
457
458    /// Run compaction manually (ignores auto-compact setting).
459    /// Returns the compaction summary text, or an error message.
460    pub async fn run_manual_compact(
461        &mut self,
462        custom_instructions: Option<&str>,
463    ) -> Result<String, String> {
464        let result = self
465            ._run_compaction(CompactionReason::Manual, custom_instructions, false)
466            .await?;
467        Ok(result.map(|r| r.summary).unwrap_or_default())
468    }
469
470    /// Internal: run compaction with the given reason, emitting lifecycle events.
471    /// Returns the CompactionResult if compaction was performed, or None if skipped.
472    async fn _run_compaction(
473        &mut self,
474        reason: CompactionReason,
475        custom_instructions: Option<&str>,
476        will_retry: bool,
477    ) -> Result<Option<CompactionResult>, String> {
478        // For threshold compaction, check if auto-compact is enabled
479        if reason == CompactionReason::Threshold && !self.compaction_settings.enabled {
480            return Ok(None);
481        }
482
483        if self.compaction_api_key.is_none() || self.model_name.is_empty() {
484            return Ok(None);
485        }
486
487        // Create a fresh cancellation token for this compaction run
488        // (pi-compatible: matches AbortController per compaction call)
489        self.compaction_cancel = crate::agent::extension::Cancel::new();
490        let cancel = self.compaction_cancel.clone();
491
492        // Emit compaction_start
493        self.emit_compaction_event(&CompactionEvent::Start { reason });
494
495        // Check for cancellation before proceeding
496        if cancel.is_cancelled() {
497            return Ok(None);
498        }
499
500        let entries = self.mgr.get_entries();
501
502        // Check threshold for auto-compact
503        if reason == CompactionReason::Threshold {
504            let context_msgs = self.mgr.session().build_context().messages;
505            let context_tokens = compaction::estimate_context_tokens(&context_msgs);
506            if !compaction::should_compact(
507                context_tokens,
508                self.context_window,
509                &self.compaction_settings,
510            ) {
511                return Ok(None);
512            }
513        }
514
515        let Some(prep) = prepare_compaction(&entries, &self.compaction_settings) else {
516            return Ok(None);
517        };
518
519        // Extension hooks: before_compact
520        let mut from_hook = false;
521        let mut hook_summary: Option<String> = None;
522        let mut hook_details: Option<serde_json::Value> = None;
523
524        for ext in &self.extensions {
525            if cancel.is_cancelled() {
526                break;
527            }
528            if let Some(result) = ext.before_compact(
529                &prep.first_kept_entry_id,
530                prep.tokens_before,
531                &reason.to_string(),
532                &cancel,
533            ) {
534                if result.cancel {
535                    self.emit_compaction_event(&CompactionEvent::End {
536                        reason,
537                        aborted: true,
538                        will_retry: false,
539                        error_message: Some("Compaction cancelled by extension".to_string()),
540                        result: CompactionResult {
541                            summary: String::new(),
542                            first_kept_entry_id: prep.first_kept_entry_id.clone(),
543                            tokens_before: prep.tokens_before,
544                            estimated_tokens_after: 0,
545                            details: None,
546                        },
547                    });
548                    return Ok(None);
549                }
550                if result.summary.is_some() {
551                    hook_summary = result.summary;
552                    hook_details = result.details;
553                    from_hook = true;
554                    break;
555                }
556            }
557        }
558
559        let result = if let Some(summary) = hook_summary {
560            // Extension provided custom summary
561            CompactionResult {
562                summary,
563                first_kept_entry_id: prep.first_kept_entry_id.clone(),
564                tokens_before: prep.tokens_before,
565                estimated_tokens_after: 0, // will be computed after append
566                details: hook_details,
567            }
568        } else {
569            // Call provider for summarization
570            let api_key = self.compaction_api_key.as_ref().unwrap();
571            compact(
572                &prep,
573                api_key,
574                &self.model_name,
575                custom_instructions,
576                self.thinking_level,
577                self.model_config.clone(),
578            )
579            .await?
580        };
581
582        // Append the compaction entry to the session
583        self.mgr.session_mut().append_compaction(
584            &result.summary,
585            &result.first_kept_entry_id,
586            result.tokens_before,
587            result.details.clone(),
588            Some(from_hook),
589        );
590
591        // Compute estimated tokens after compaction
592        let context_after = self.mgr.session().build_context().messages;
593        let estimated_tokens_after = compaction::estimate_context_tokens(&context_after);
594
595        let final_result = CompactionResult {
596            estimated_tokens_after,
597            ..result
598        };
599
600        // Extension hooks: after_compact
601        for ext in &self.extensions {
602            if cancel.is_cancelled() {
603                break;
604            }
605            ext.after_compact(
606                &final_result.summary,
607                &final_result.first_kept_entry_id,
608                final_result.tokens_before,
609                final_result.estimated_tokens_after,
610                from_hook,
611                &reason.to_string(),
612                &cancel,
613            );
614        }
615
616        // Emit compaction_end
617        self.emit_compaction_event(&CompactionEvent::End {
618            reason,
619            result: final_result.clone(),
620            aborted: false,
621            will_retry,
622            error_message: None,
623        });
624
625        Ok(Some(final_result))
626    }
627
628    // ── Branch summarization ───────────────────────────────────────
629
630    /// Summarise the abandoned branch when navigating to a different node.
631    ///
632    /// Collects entries between `old_leaf_id` and the common ancestor with
633    /// `target_id`, summarises them via the provider, and appends a
634    /// `BranchSummaryEntry` to the session.
635    ///
636    /// Returns the summary text, or an error message.
637    pub async fn summarize_branch_navigation(
638        &mut self,
639        old_leaf_id: Option<&str>,
640        target_id: &str,
641        custom_instructions: Option<&str>,
642    ) -> Result<String, String> {
643        if self.compaction_api_key.is_none() || self.model_name.is_empty() {
644            return Err("No provider configured for summarization".to_string());
645        }
646
647        let (entries, _common_ancestor) =
648            collect_entries_for_branch_summary(self.session(), old_leaf_id, target_id);
649
650        if entries.is_empty() {
651            return Err("No abandoned entries to summarize".to_string());
652        }
653
654        let api_key = self.compaction_api_key.as_ref().unwrap();
655        generate_branch_summary(
656            self.mgr.session_mut(),
657            &entries,
658            target_id,
659            api_key,
660            &self.model_name,
661            self.thinking_level,
662            self.model_config.clone(),
663            custom_instructions,
664        )
665        .await
666    }
667
668    /// Move the leaf pointer to an earlier entry (starts a new branch).
669    /// Optionally summarizes the abandoned path if a provider is configured.
670    /// `custom_instructions` are passed to the summarization prompt (pi-compatible).
671    /// Returns the branch summary text if summarization was performed.
672    pub async fn set_branch(
673        &mut self,
674        branch_from_id: &str,
675        custom_instructions: Option<&str>,
676    ) -> Result<Option<String>, String> {
677        let old_leaf = self.mgr.session().get_leaf_id();
678
679        let summary = if self.compaction_api_key.is_some()
680            && !self.model_name.is_empty()
681            && let Some(ref old) = old_leaf
682            && old != branch_from_id
683        {
684            // Summarize the abandoned path
685            match self
686                .summarize_branch_navigation(Some(old), branch_from_id, custom_instructions)
687                .await
688            {
689                Ok(s) => Some(s),
690                Err(e) => {
691                    // Non-fatal: still allow the branch move
692                    eprintln!("Warning: branch summarization failed: {}", e);
693                    None
694                }
695            }
696        } else {
697            None
698        };
699
700        self.mgr
701            .session_mut()
702            .set_leaf_id(Some(branch_from_id))
703            .map_err(|e| format!("Failed to set branch: {}", e))?;
704
705        Ok(summary)
706    }
707
708    /// Persist a tool result message (public so the agent loop can persist crash-safely).
709    /// Deduplicates by tool_call_id.
710    /// Persist an Extension message as a `custom_message` session entry (pi-compatible).
711    /// Extension messages are NOT persisted as regular messages — they use the
712    /// `custom_message` entry type which supports `custom_type`, `display`, and `details`.
713    pub fn persist_extension_message(&mut self, msg: &AgentMessage) {
714        let Some(kind) = crate::agent::types::message_extension_kind(msg) else {
715            return;
716        };
717        let text = crate::agent::types::message_extension_text(msg)
718            .unwrap_or_else(|| crate::agent::types::message_text(msg));
719        let content = serde_json::json!({"text": text});
720        self.mgr
721            .session_mut()
722            .append_custom_message_entry(kind, content, true, None);
723    }
724}