Skip to main content

bamboo_domain/session/
runtime_metadata_access.rs

1//! Symmetric accessor layer for [`SessionRuntimeMetadata`].
2//!
3//! Every accessor follows two rules, mirroring the existing
4//! `agent_runtime_state` <-> `metadata["agent.runtime.state"]` idiom:
5//!
6//! - **Fallback-read**: getters read the typed `runtime_metadata` field first,
7//!   then fall back to the legacy `metadata["<key>"]` string. This keeps reads
8//!   correct for sessions persisted before the typed field existed.
9//! - **Dual-write**: setters write BOTH the typed field AND the legacy metadata
10//!   string. This keeps the ~120 un-migrated call sites that still read the raw
11//!   `metadata` map correct until a future cleanup wave removes the mirror.
12//!
13//! Clearers remove from both planes symmetrically.
14
15use super::runtime_metadata::{keys, SessionRuntimeMetadata};
16use super::types::{Session, SessionKind};
17
18impl Session {
19    /// Mutable handle to the typed runtime metadata, creating it on demand.
20    fn runtime_metadata_mut(&mut self) -> &mut SessionRuntimeMetadata {
21        self.runtime_metadata.get_or_insert_with(Default::default)
22    }
23
24    /// Drop the typed runtime metadata object if it carries no values, so an
25    /// emptied session does not serialize an empty `runtime_metadata` object.
26    fn prune_runtime_metadata(&mut self) {
27        if self
28            .runtime_metadata
29            .as_ref()
30            .is_some_and(SessionRuntimeMetadata::is_empty)
31        {
32            self.runtime_metadata = None;
33        }
34    }
35
36    /// Read a typed `Option<String>` field, falling back to the legacy key.
37    fn runtime_str(
38        &self,
39        select: impl FnOnce(&SessionRuntimeMetadata) -> Option<&String>,
40        legacy_key: &str,
41    ) -> Option<String> {
42        self.runtime_metadata
43            .as_ref()
44            .and_then(select)
45            .cloned()
46            .or_else(|| self.metadata.get(legacy_key).cloned())
47    }
48
49    // ------------------------------------------------------------------
50    // subagent_type
51    // ------------------------------------------------------------------
52
53    pub fn subagent_type(&self) -> Option<String> {
54        self.runtime_str(|m| m.subagent_type.as_ref(), keys::SUBAGENT_TYPE)
55    }
56
57    pub fn set_subagent_type(&mut self, value: impl Into<String>) {
58        let value = value.into();
59        self.runtime_metadata_mut().subagent_type = Some(value.clone());
60        self.metadata.insert(keys::SUBAGENT_TYPE.to_string(), value);
61    }
62
63    // ------------------------------------------------------------------
64    // last_run_status
65    // ------------------------------------------------------------------
66
67    pub fn last_run_status(&self) -> Option<String> {
68        self.runtime_str(|m| m.last_run_status.as_ref(), keys::LAST_RUN_STATUS)
69    }
70
71    pub fn set_last_run_status(&mut self, value: impl Into<String>) {
72        let value = value.into();
73        self.runtime_metadata_mut().last_run_status = Some(value.clone());
74        self.metadata
75            .insert(keys::LAST_RUN_STATUS.to_string(), value);
76    }
77
78    // ------------------------------------------------------------------
79    // last_run_error
80    // ------------------------------------------------------------------
81
82    pub fn last_run_error(&self) -> Option<String> {
83        self.runtime_str(|m| m.last_run_error.as_ref(), keys::LAST_RUN_ERROR)
84    }
85
86    pub fn set_last_run_error(&mut self, value: impl Into<String>) {
87        let value = value.into();
88        self.retain_llm_request_diagnostic_for_error(&value);
89        self.runtime_metadata_mut().last_run_error = Some(value.clone());
90        self.metadata
91            .insert(keys::LAST_RUN_ERROR.to_string(), value);
92    }
93
94    pub fn clear_last_run_error(&mut self) {
95        self.clear_llm_request_diagnostic();
96        if let Some(rm) = self.runtime_metadata.as_mut() {
97            rm.last_run_error = None;
98        }
99        self.metadata.remove(keys::LAST_RUN_ERROR);
100        self.prune_runtime_metadata();
101    }
102
103    /// The queued launch identity is durable on the logical Child session,
104    /// independent of a process-local runner or scheduler entry.
105    pub fn child_launch_generation(&self) -> u64 {
106        self.runtime_metadata
107            .as_ref()
108            .and_then(|metadata| metadata.child_launch_generation)
109            .unwrap_or(0)
110    }
111
112    pub fn advance_child_launch_generation(&mut self) -> Option<u64> {
113        let next = self.child_launch_generation().checked_add(1)?;
114        self.runtime_metadata_mut().child_launch_generation = Some(next);
115        Some(next)
116    }
117
118    pub fn is_child_launch_cancelled(&self, generation: u64) -> bool {
119        self.runtime_metadata
120            .as_ref()
121            .and_then(|metadata| metadata.child_cancelled_generation)
122            .is_some_and(|cancelled| cancelled >= generation)
123    }
124
125    pub fn cancel_child_launch_generation(&mut self) {
126        let generation = self.child_launch_generation();
127        self.runtime_metadata_mut().child_cancelled_generation = Some(generation);
128    }
129
130    /// Explicitly promise to launch the current Child generation. A draft
131    /// never acquires this marker merely by being `pending`.
132    pub fn mark_child_auto_run_launch_intent(&mut self) {
133        let generation = self.child_launch_generation();
134        self.runtime_metadata_mut()
135            .child_auto_run_launch_intent_generation = Some(generation);
136    }
137
138    /// Reconciliation is deliberately narrower than a `pending` status:
139    /// an old intent cannot borrow a later draft/retry generation.
140    pub fn recoverable_child_launch_generation(&self) -> Option<u64> {
141        if self.kind != SessionKind::Child || self.last_run_status().as_deref() != Some("pending") {
142            return None;
143        }
144        let generation = self.child_launch_generation();
145        self.runtime_metadata
146            .as_ref()
147            .and_then(|metadata| metadata.child_auto_run_launch_intent_generation)
148            .filter(|intent| *intent == generation && !self.is_child_launch_cancelled(*intent))
149    }
150
151    // ------------------------------------------------------------------
152    // provider_name
153    // ------------------------------------------------------------------
154
155    pub fn provider_name(&self) -> Option<String> {
156        self.runtime_str(|m| m.provider_name.as_ref(), keys::PROVIDER_NAME)
157    }
158
159    pub fn set_provider_name(&mut self, value: impl Into<String>) {
160        let value = value.into();
161        self.runtime_metadata_mut().provider_name = Some(value.clone());
162        self.metadata.insert(keys::PROVIDER_NAME.to_string(), value);
163    }
164
165    // ------------------------------------------------------------------
166    // pending_injected_messages (JSON-string on the legacy map)
167    // ------------------------------------------------------------------
168
169    /// Read the pending injected messages, decoding the legacy JSON-string form
170    /// defensively. Malformed legacy JSON yields `None` (never a panic).
171    pub fn pending_injected_messages(&self) -> Option<Vec<serde_json::Value>> {
172        if let Some(messages) = self
173            .runtime_metadata
174            .as_ref()
175            .and_then(|m| m.pending_injected_messages.clone())
176        {
177            return Some(messages);
178        }
179        let raw = self.metadata.get(keys::PENDING_INJECTED_MESSAGES)?;
180        match serde_json::from_str::<Vec<serde_json::Value>>(raw) {
181            Ok(messages) => Some(messages),
182            Err(_) => Some(Vec::new()),
183        }
184    }
185
186    /// Set pending injected messages on both planes. The legacy mirror stores
187    /// the JSON-encoded string form to preserve byte-for-byte compatibility.
188    pub fn set_pending_injected_messages(&mut self, messages: Vec<serde_json::Value>) {
189        let serialized = serde_json::to_string(&messages).unwrap_or_else(|_| "[]".to_string());
190        self.runtime_metadata_mut().pending_injected_messages = Some(messages);
191        self.metadata
192            .insert(keys::PENDING_INJECTED_MESSAGES.to_string(), serialized);
193    }
194
195    /// True when there are queued injected messages on either plane.
196    pub fn has_pending_injected_messages(&self) -> bool {
197        if self
198            .runtime_metadata
199            .as_ref()
200            .is_some_and(|m| m.pending_injected_messages.is_some())
201        {
202            return true;
203        }
204        self.metadata.contains_key(keys::PENDING_INJECTED_MESSAGES)
205    }
206
207    /// Take and clear pending injected messages from both planes.
208    pub fn take_pending_injected_messages(&mut self) -> Option<Vec<serde_json::Value>> {
209        let value = self.pending_injected_messages();
210        self.clear_pending_injected_messages();
211        value
212    }
213
214    pub fn clear_pending_injected_messages(&mut self) {
215        if let Some(rm) = self.runtime_metadata.as_mut() {
216            rm.pending_injected_messages = None;
217        }
218        self.metadata.remove(keys::PENDING_INJECTED_MESSAGES);
219        self.prune_runtime_metadata();
220    }
221
222    // ------------------------------------------------------------------
223    // typed durable SessionInbox admission state (no legacy mirror)
224    // ------------------------------------------------------------------
225
226    pub fn session_inbox_admission(&self) -> Option<&super::SessionInboxAdmissionState> {
227        self.runtime_metadata
228            .as_ref()
229            .and_then(|metadata| metadata.session_inbox_admission.as_ref())
230    }
231
232    pub fn session_inbox_admission_mut(&mut self) -> &mut super::SessionInboxAdmissionState {
233        self.runtime_metadata_mut()
234            .session_inbox_admission
235            .get_or_insert_with(Default::default)
236    }
237
238    // ------------------------------------------------------------------
239    // selected_skill_ids (JSON-array-string on the legacy map)
240    // ------------------------------------------------------------------
241
242    /// Read selected skill ids. The typed field is preferred; the legacy
243    /// fallback parses the stored JSON-array string defensively (malformed →
244    /// `None`).
245    pub fn selected_skill_ids(&self) -> Option<Vec<String>> {
246        if let Some(ids) = self
247            .runtime_metadata
248            .as_ref()
249            .and_then(|m| m.selected_skill_ids.clone())
250        {
251            return Some(ids);
252        }
253        let raw = self.metadata.get(keys::SELECTED_SKILL_IDS)?;
254        serde_json::from_str::<Vec<String>>(raw).ok()
255    }
256
257    /// Set selected skill ids on both planes. The legacy mirror stores the
258    /// JSON-array string form.
259    pub fn set_selected_skill_ids(&mut self, ids: Vec<String>) {
260        let serialized = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string());
261        self.runtime_metadata_mut().selected_skill_ids = Some(ids);
262        self.metadata
263            .insert(keys::SELECTED_SKILL_IDS.to_string(), serialized);
264    }
265
266    pub fn clear_selected_skill_ids(&mut self) {
267        if let Some(rm) = self.runtime_metadata.as_mut() {
268            rm.selected_skill_ids = None;
269        }
270        self.metadata.remove(keys::SELECTED_SKILL_IDS);
271        self.prune_runtime_metadata();
272    }
273
274    // ------------------------------------------------------------------
275    // skill_mode (canonical) / mode (legacy duplicate)
276    // ------------------------------------------------------------------
277
278    /// Read the skill mode. Resolution order: typed field, then legacy
279    /// `skill_mode` key, then the historical `mode` key. When both legacy keys
280    /// are present and disagree, a warning is logged and `skill_mode` wins.
281    pub fn skill_mode(&self) -> Option<String> {
282        if let Some(mode) = self
283            .runtime_metadata
284            .as_ref()
285            .and_then(|m| m.skill_mode.clone())
286        {
287            return Some(mode);
288        }
289        let canonical = self.metadata.get(keys::SKILL_MODE);
290        let legacy = self.metadata.get(keys::SKILL_MODE_LEGACY);
291        if let (Some(canonical), Some(legacy)) = (canonical, legacy) {
292            if canonical != legacy {
293                tracing::warn!(
294                    canonical = %canonical,
295                    legacy = %legacy,
296                    "session metadata has divergent skill_mode and legacy mode keys; preferring skill_mode"
297                );
298            }
299        }
300        canonical.or(legacy).cloned()
301    }
302
303    /// Set the skill mode. Writes the typed field and the canonical
304    /// `skill_mode` legacy key (the legacy `mode` key is never written).
305    pub fn set_skill_mode(&mut self, value: impl Into<String>) {
306        let value = value.into();
307        self.runtime_metadata_mut().skill_mode = Some(value.clone());
308        self.metadata.insert(keys::SKILL_MODE.to_string(), value);
309    }
310
311    pub fn clear_skill_mode(&mut self) {
312        if let Some(rm) = self.runtime_metadata.as_mut() {
313            rm.skill_mode = None;
314        }
315        self.metadata.remove(keys::SKILL_MODE);
316        self.prune_runtime_metadata();
317    }
318
319    // ------------------------------------------------------------------
320    // reasoning_effort (string form)
321    // ------------------------------------------------------------------
322
323    pub fn reasoning_effort_meta(&self) -> Option<String> {
324        self.runtime_str(|m| m.reasoning_effort.as_ref(), keys::REASONING_EFFORT)
325    }
326
327    pub fn set_reasoning_effort_meta(&mut self, value: impl Into<String>) {
328        let value = value.into();
329        self.runtime_metadata_mut().reasoning_effort = Some(value.clone());
330        self.metadata
331            .insert(keys::REASONING_EFFORT.to_string(), value);
332    }
333
334    pub fn clear_reasoning_effort_meta(&mut self) {
335        if let Some(metadata) = self.runtime_metadata.as_mut() {
336            metadata.reasoning_effort = None;
337        }
338        self.metadata.remove(keys::REASONING_EFFORT);
339        self.prune_runtime_metadata();
340    }
341
342    // ------------------------------------------------------------------
343    // enhance_prompt
344    // ------------------------------------------------------------------
345
346    pub fn enhance_prompt(&self) -> Option<String> {
347        self.runtime_str(|m| m.enhance_prompt.as_ref(), keys::ENHANCE_PROMPT)
348    }
349
350    pub fn set_enhance_prompt(&mut self, value: impl Into<String>) {
351        let value = value.into();
352        self.runtime_metadata_mut().enhance_prompt = Some(value.clone());
353        self.metadata
354            .insert(keys::ENHANCE_PROMPT.to_string(), value);
355    }
356
357    pub fn clear_enhance_prompt(&mut self) {
358        if let Some(rm) = self.runtime_metadata.as_mut() {
359            rm.enhance_prompt = None;
360        }
361        self.metadata.remove(keys::ENHANCE_PROMPT);
362        self.prune_runtime_metadata();
363    }
364
365    // ------------------------------------------------------------------
366    // root_orchestration_prompt
367    // ------------------------------------------------------------------
368
369    /// Host-selected delegation guidance is scoped to a genuine root Session.
370    /// An imported or malformed child cannot acquire it from inherited metadata.
371    pub fn root_orchestration_prompt_enabled(&self) -> bool {
372        self.kind == SessionKind::Root
373            && self.parent_session_id.is_none()
374            && self
375                .runtime_metadata
376                .as_ref()
377                .and_then(|metadata| metadata.root_orchestration_prompt)
378                .or_else(|| {
379                    self.metadata
380                        .get(keys::ROOT_ORCHESTRATION_PROMPT)
381                        .and_then(|value| value.parse::<bool>().ok())
382                })
383                .unwrap_or(false)
384    }
385
386    /// An explicit `false` clears the selection while preserving the default
387    /// serialization of ordinary roots. Omitted chat values leave it untouched.
388    pub fn set_root_orchestration_prompt_enabled(&mut self, enabled: bool) {
389        if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
390            return;
391        }
392        let changed = self.root_orchestration_prompt_enabled() != enabled;
393        if enabled {
394            self.runtime_metadata_mut().root_orchestration_prompt = Some(true);
395            self.metadata.insert(
396                keys::ROOT_ORCHESTRATION_PROMPT.to_string(),
397                "true".to_string(),
398            );
399        } else {
400            if let Some(metadata) = self.runtime_metadata.as_mut() {
401                metadata.root_orchestration_prompt = None;
402            }
403            self.metadata.remove(keys::ROOT_ORCHESTRATION_PROMPT);
404            self.prune_runtime_metadata();
405        }
406        // A disabled enhancement must disappear from the next provider input,
407        // including old ledger snapshots. A new epoch also prevents an old
408        // native continuation from bypassing the changed prompt selection.
409        if changed && self.model_context_state.is_some() {
410            self.reset_model_context_epoch(
411                super::model_context::ModelContextResetReason::CacheScopeChanged,
412            );
413        }
414    }
415
416    // ------------------------------------------------------------------
417    // task_list_version / todo_list_version (string form)
418    // ------------------------------------------------------------------
419
420    pub fn task_list_version_meta(&self) -> Option<String> {
421        self.runtime_str(|m| m.task_list_version.as_ref(), keys::TASK_LIST_VERSION)
422    }
423
424    pub fn set_task_list_version_meta(&mut self, value: impl Into<String>) {
425        let value = value.into();
426        self.runtime_metadata_mut().task_list_version = Some(value.clone());
427        self.metadata
428            .insert(keys::TASK_LIST_VERSION.to_string(), value);
429    }
430
431    pub fn todo_list_version_meta(&self) -> Option<String> {
432        self.runtime_str(|m| m.todo_list_version.as_ref(), keys::TODO_LIST_VERSION)
433    }
434
435    pub fn set_todo_list_version_meta(&mut self, value: impl Into<String>) {
436        let value = value.into();
437        self.runtime_metadata_mut().todo_list_version = Some(value.clone());
438        self.metadata
439            .insert(keys::TODO_LIST_VERSION.to_string(), value);
440    }
441
442    // ------------------------------------------------------------------
443    // workspace_path
444    // ------------------------------------------------------------------
445
446    pub fn workspace_path_meta(&self) -> Option<String> {
447        self.runtime_str(|m| m.workspace_path.as_ref(), keys::WORKSPACE_PATH)
448    }
449
450    pub fn set_workspace_path_meta(&mut self, value: impl Into<String>) {
451        let value = value.into();
452        self.runtime_metadata_mut().workspace_path = Some(value.clone());
453        self.metadata
454            .insert(keys::WORKSPACE_PATH.to_string(), value);
455    }
456
457    // ------------------------------------------------------------------
458    // project_id
459    // ------------------------------------------------------------------
460
461    /// Read the stable Project identity, preferring typed runtime metadata and
462    /// falling back to the legacy metadata string during migration.
463    pub fn project_id_meta(&self) -> Option<String> {
464        self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
465    }
466
467    /// Persist Project identity on both planes while legacy raw-map readers
468    /// remain in the tree.
469    pub fn set_project_id_meta(&mut self, value: impl Into<String>) {
470        let value = value.into();
471        self.runtime_metadata_mut().project_id = Some(value.clone());
472        self.metadata.insert(keys::PROJECT_ID.to_string(), value);
473    }
474
475    pub fn clear_project_id_meta(&mut self) {
476        if let Some(runtime_metadata) = self.runtime_metadata.as_mut() {
477            runtime_metadata.project_id = None;
478        }
479        self.metadata.remove(keys::PROJECT_ID);
480        self.prune_runtime_metadata();
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use serde_json::json;
488
489    #[test]
490    fn root_orchestration_selection_survives_reload_but_never_applies_to_child() {
491        let mut root = Session::new("root-orchestration", "model");
492        assert!(!root.root_orchestration_prompt_enabled());
493        root.set_root_orchestration_prompt_enabled(true);
494        let stored = serde_json::to_string(&root).expect("serialize root");
495        let mut reloaded: Session = serde_json::from_str(&stored).expect("reload root");
496        assert!(reloaded.root_orchestration_prompt_enabled());
497        assert_eq!(
498            reloaded
499                .metadata
500                .get(keys::ROOT_ORCHESTRATION_PROMPT)
501                .map(String::as_str),
502            Some("true")
503        );
504
505        let mut child = Session::new_child_of("child", &reloaded, "model", "child");
506        child.set_root_orchestration_prompt_enabled(true);
507        assert!(!child.root_orchestration_prompt_enabled());
508
509        reloaded.set_root_orchestration_prompt_enabled(false);
510        assert!(!reloaded.root_orchestration_prompt_enabled());
511        assert!(!reloaded
512            .metadata
513            .contains_key(keys::ROOT_ORCHESTRATION_PROMPT));
514        assert!(reloaded
515            .runtime_metadata
516            .as_ref()
517            .and_then(|metadata| metadata.root_orchestration_prompt)
518            .is_none());
519    }
520
521    /// Hand-written OLD-format session JSON: only the legacy `metadata` map,
522    /// no `runtime_metadata` field. Includes a JSON-string
523    /// `pending_injected_messages`, a JSON-array-string `selected_skill_ids`,
524    /// the legacy `mode` key (not `skill_mode`), and open-ended keys that must
525    /// survive untouched.
526    const OLD_FORMAT_SESSION: &str = r#"{
527        "id": "sess-old",
528        "messages": [],
529        "created_at": "2025-01-01T00:00:00Z",
530        "updated_at": "2025-01-01T00:00:00Z",
531        "model": "gpt-test",
532        "metadata": {
533            "subagent_type": "researcher",
534            "last_run_status": "completed",
535            "provider_name": "openai",
536            "workspace_path": "/tmp/ws",
537            "project_id": "01JLEGACYPROJECT000000000000",
538            "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
539            "selected_skill_ids": "[\"pdf\",\"web\"]",
540            "mode": "ask",
541            "task_list_version": "7",
542            "gold_config": "{\"goal\":\"x\"}",
543            "a2a.foo": "bar",
544            "responses.previous_response_id": "resp-123"
545        }
546    }"#;
547
548    #[test]
549    fn old_format_deserializes_and_typed_getters_fall_back() {
550        let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
551        // No runtime_metadata in old JSON.
552        assert!(session.runtime_metadata.is_none());
553
554        // Typed getters resolve via the legacy metadata fallback.
555        assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
556        assert_eq!(session.last_run_status().as_deref(), Some("completed"));
557        assert_eq!(session.provider_name().as_deref(), Some("openai"));
558        assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
559        assert_eq!(
560            session.project_id_meta().as_deref(),
561            Some("01JLEGACYPROJECT000000000000")
562        );
563        assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
564
565        // skill_mode falls back to the legacy `mode` key.
566        assert_eq!(session.skill_mode().as_deref(), Some("ask"));
567
568        // JSON-string pending_injected_messages decodes into the typed vector.
569        let pending = session
570            .pending_injected_messages()
571            .expect("pending should decode");
572        assert_eq!(pending.len(), 2);
573        assert_eq!(pending[0]["content"], "hello");
574        assert_eq!(pending[1]["content"], "world");
575
576        // JSON-array-string selected_skill_ids decodes.
577        let ids = session
578            .selected_skill_ids()
579            .expect("skill ids should decode");
580        assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
581    }
582
583    #[test]
584    fn setters_dual_write_both_planes() {
585        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
586
587        session.set_subagent_type("planner");
588        // Typed field updated.
589        assert_eq!(
590            session
591                .runtime_metadata
592                .as_ref()
593                .and_then(|m| m.subagent_type.as_deref()),
594            Some("planner")
595        );
596        // Legacy string mirror updated.
597        assert_eq!(
598            session.metadata.get("subagent_type").map(String::as_str),
599            Some("planner")
600        );
601
602        session.set_skill_mode("code");
603        assert_eq!(
604            session
605                .runtime_metadata
606                .as_ref()
607                .and_then(|m| m.skill_mode.as_deref()),
608            Some("code")
609        );
610        assert_eq!(
611            session.metadata.get("skill_mode").map(String::as_str),
612            Some("code")
613        );
614        // Typed/canonical skill_mode now wins over the legacy `mode` key.
615        assert_eq!(session.skill_mode().as_deref(), Some("code"));
616
617        // pending_injected_messages dual-writes typed vec + JSON string mirror.
618        session.set_pending_injected_messages(vec![json!({"content": "again"})]);
619        assert_eq!(
620            session
621                .runtime_metadata
622                .as_ref()
623                .and_then(|m| m.pending_injected_messages.as_ref())
624                .map(Vec::len),
625            Some(1)
626        );
627        let raw = session.metadata.get("pending_injected_messages").unwrap();
628        let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
629        assert_eq!(decoded[0]["content"], "again");
630
631        // selected_skill_ids dual-writes typed vec + JSON string mirror.
632        session.set_selected_skill_ids(vec!["audio".to_string()]);
633        let raw = session.metadata.get("selected_skill_ids").unwrap();
634        let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
635        assert_eq!(decoded, vec!["audio".to_string()]);
636
637        session.set_project_id_meta("01JNEWPROJECT000000000000000");
638        assert_eq!(
639            session
640                .runtime_metadata
641                .as_ref()
642                .and_then(|metadata| metadata.project_id.as_deref()),
643            Some("01JNEWPROJECT000000000000000")
644        );
645        assert_eq!(
646            session.metadata.get("project_id").map(String::as_str),
647            Some("01JNEWPROJECT000000000000000")
648        );
649        session.clear_project_id_meta();
650        assert!(session.project_id_meta().is_none());
651    }
652
653    #[test]
654    fn round_trip_preserves_open_ended_and_typed_values() {
655        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
656        session.set_subagent_type("planner");
657        session.set_skill_mode("code");
658
659        // Serialize -> deserialize again.
660        let serialized = serde_json::to_string(&session).unwrap();
661        let restored: Session = serde_json::from_str(&serialized).unwrap();
662
663        // Open-ended keys survive untouched.
664        assert_eq!(
665            restored.metadata.get("gold_config").map(String::as_str),
666            Some("{\"goal\":\"x\"}")
667        );
668        assert_eq!(
669            restored.metadata.get("a2a.foo").map(String::as_str),
670            Some("bar")
671        );
672        assert_eq!(
673            restored
674                .metadata
675                .get("responses.previous_response_id")
676                .map(String::as_str),
677            Some("resp-123")
678        );
679
680        // Typed values round-trip through the new runtime_metadata object.
681        assert!(restored.runtime_metadata.is_some());
682        assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
683        assert_eq!(restored.skill_mode().as_deref(), Some("code"));
684        // And the legacy mirror is still there for un-migrated readers.
685        assert_eq!(
686            restored.metadata.get("subagent_type").map(String::as_str),
687            Some("planner")
688        );
689    }
690
691    #[test]
692    fn malformed_pending_injected_messages_never_panics() {
693        let json = r#"{
694            "id": "sess-bad",
695            "messages": [],
696            "created_at": "2025-01-01T00:00:00Z",
697            "updated_at": "2025-01-01T00:00:00Z",
698            "model": "gpt-test",
699            "metadata": { "pending_injected_messages": "not-json{" }
700        }"#;
701        let session: Session = serde_json::from_str(json).unwrap();
702        // Defensive parse: malformed legacy JSON => empty vec, not a panic.
703        assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
704        assert!(session.has_pending_injected_messages());
705    }
706
707    #[test]
708    fn divergent_skill_mode_and_mode_prefers_skill_mode() {
709        let json = r#"{
710            "id": "sess-div",
711            "messages": [],
712            "created_at": "2025-01-01T00:00:00Z",
713            "updated_at": "2025-01-01T00:00:00Z",
714            "model": "gpt-test",
715            "metadata": { "skill_mode": "code", "mode": "ask" }
716        }"#;
717        let session: Session = serde_json::from_str(json).unwrap();
718        assert_eq!(session.skill_mode().as_deref(), Some("code"));
719    }
720
721    #[test]
722    fn take_pending_clears_both_planes() {
723        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
724        let taken = session.take_pending_injected_messages().unwrap();
725        assert_eq!(taken.len(), 2);
726        assert!(!session.has_pending_injected_messages());
727        assert!(!session.metadata.contains_key("pending_injected_messages"));
728        assert!(session
729            .runtime_metadata
730            .as_ref()
731            .map(|m| m.pending_injected_messages.is_none())
732            .unwrap_or(true));
733    }
734
735    #[test]
736    fn empty_runtime_metadata_not_serialized() {
737        let session = Session::new("sess-empty", "gpt-test");
738        assert!(session.runtime_metadata.is_none());
739        let json = serde_json::to_string(&session).unwrap();
740        assert!(
741            !json.contains("runtime_metadata"),
742            "absent runtime_metadata must not serialize: {json}"
743        );
744    }
745}