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