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    // ------------------------------------------------------------------
335    // enhance_prompt
336    // ------------------------------------------------------------------
337
338    pub fn enhance_prompt(&self) -> Option<String> {
339        self.runtime_str(|m| m.enhance_prompt.as_ref(), keys::ENHANCE_PROMPT)
340    }
341
342    pub fn set_enhance_prompt(&mut self, value: impl Into<String>) {
343        let value = value.into();
344        self.runtime_metadata_mut().enhance_prompt = Some(value.clone());
345        self.metadata
346            .insert(keys::ENHANCE_PROMPT.to_string(), value);
347    }
348
349    pub fn clear_enhance_prompt(&mut self) {
350        if let Some(rm) = self.runtime_metadata.as_mut() {
351            rm.enhance_prompt = None;
352        }
353        self.metadata.remove(keys::ENHANCE_PROMPT);
354        self.prune_runtime_metadata();
355    }
356
357    // ------------------------------------------------------------------
358    // root_orchestration_prompt
359    // ------------------------------------------------------------------
360
361    /// Host-selected delegation guidance is scoped to a genuine root Session.
362    /// An imported or malformed child cannot acquire it from inherited metadata.
363    pub fn root_orchestration_prompt_enabled(&self) -> bool {
364        self.kind == SessionKind::Root
365            && self.parent_session_id.is_none()
366            && self
367                .runtime_metadata
368                .as_ref()
369                .and_then(|metadata| metadata.root_orchestration_prompt)
370                .or_else(|| {
371                    self.metadata
372                        .get(keys::ROOT_ORCHESTRATION_PROMPT)
373                        .and_then(|value| value.parse::<bool>().ok())
374                })
375                .unwrap_or(false)
376    }
377
378    /// An explicit `false` clears the selection while preserving the default
379    /// serialization of ordinary roots. Omitted chat values leave it untouched.
380    pub fn set_root_orchestration_prompt_enabled(&mut self, enabled: bool) {
381        if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
382            return;
383        }
384        let changed = self.root_orchestration_prompt_enabled() != enabled;
385        if enabled {
386            self.runtime_metadata_mut().root_orchestration_prompt = Some(true);
387            self.metadata.insert(
388                keys::ROOT_ORCHESTRATION_PROMPT.to_string(),
389                "true".to_string(),
390            );
391        } else {
392            if let Some(metadata) = self.runtime_metadata.as_mut() {
393                metadata.root_orchestration_prompt = None;
394            }
395            self.metadata.remove(keys::ROOT_ORCHESTRATION_PROMPT);
396            self.prune_runtime_metadata();
397        }
398        // A disabled enhancement must disappear from the next provider input,
399        // including old ledger snapshots. A new epoch also prevents an old
400        // native continuation from bypassing the changed prompt selection.
401        if changed && self.model_context_state.is_some() {
402            self.reset_model_context_epoch(
403                super::model_context::ModelContextResetReason::CacheScopeChanged,
404            );
405        }
406    }
407
408    // ------------------------------------------------------------------
409    // task_list_version / todo_list_version (string form)
410    // ------------------------------------------------------------------
411
412    pub fn task_list_version_meta(&self) -> Option<String> {
413        self.runtime_str(|m| m.task_list_version.as_ref(), keys::TASK_LIST_VERSION)
414    }
415
416    pub fn set_task_list_version_meta(&mut self, value: impl Into<String>) {
417        let value = value.into();
418        self.runtime_metadata_mut().task_list_version = Some(value.clone());
419        self.metadata
420            .insert(keys::TASK_LIST_VERSION.to_string(), value);
421    }
422
423    pub fn todo_list_version_meta(&self) -> Option<String> {
424        self.runtime_str(|m| m.todo_list_version.as_ref(), keys::TODO_LIST_VERSION)
425    }
426
427    pub fn set_todo_list_version_meta(&mut self, value: impl Into<String>) {
428        let value = value.into();
429        self.runtime_metadata_mut().todo_list_version = Some(value.clone());
430        self.metadata
431            .insert(keys::TODO_LIST_VERSION.to_string(), value);
432    }
433
434    // ------------------------------------------------------------------
435    // workspace_path
436    // ------------------------------------------------------------------
437
438    pub fn workspace_path_meta(&self) -> Option<String> {
439        self.runtime_str(|m| m.workspace_path.as_ref(), keys::WORKSPACE_PATH)
440    }
441
442    pub fn set_workspace_path_meta(&mut self, value: impl Into<String>) {
443        let value = value.into();
444        self.runtime_metadata_mut().workspace_path = Some(value.clone());
445        self.metadata
446            .insert(keys::WORKSPACE_PATH.to_string(), value);
447    }
448
449    // ------------------------------------------------------------------
450    // project_id
451    // ------------------------------------------------------------------
452
453    /// Read the stable Project identity, preferring typed runtime metadata and
454    /// falling back to the legacy metadata string during migration.
455    pub fn project_id_meta(&self) -> Option<String> {
456        self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
457    }
458
459    /// Persist Project identity on both planes while legacy raw-map readers
460    /// remain in the tree.
461    pub fn set_project_id_meta(&mut self, value: impl Into<String>) {
462        let value = value.into();
463        self.runtime_metadata_mut().project_id = Some(value.clone());
464        self.metadata.insert(keys::PROJECT_ID.to_string(), value);
465    }
466
467    pub fn clear_project_id_meta(&mut self) {
468        if let Some(runtime_metadata) = self.runtime_metadata.as_mut() {
469            runtime_metadata.project_id = None;
470        }
471        self.metadata.remove(keys::PROJECT_ID);
472        self.prune_runtime_metadata();
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use serde_json::json;
480
481    #[test]
482    fn root_orchestration_selection_survives_reload_but_never_applies_to_child() {
483        let mut root = Session::new("root-orchestration", "model");
484        assert!(!root.root_orchestration_prompt_enabled());
485        root.set_root_orchestration_prompt_enabled(true);
486        let stored = serde_json::to_string(&root).expect("serialize root");
487        let mut reloaded: Session = serde_json::from_str(&stored).expect("reload root");
488        assert!(reloaded.root_orchestration_prompt_enabled());
489        assert_eq!(
490            reloaded
491                .metadata
492                .get(keys::ROOT_ORCHESTRATION_PROMPT)
493                .map(String::as_str),
494            Some("true")
495        );
496
497        let mut child = Session::new_child_of("child", &reloaded, "model", "child");
498        child.set_root_orchestration_prompt_enabled(true);
499        assert!(!child.root_orchestration_prompt_enabled());
500
501        reloaded.set_root_orchestration_prompt_enabled(false);
502        assert!(!reloaded.root_orchestration_prompt_enabled());
503        assert!(!reloaded
504            .metadata
505            .contains_key(keys::ROOT_ORCHESTRATION_PROMPT));
506        assert!(reloaded
507            .runtime_metadata
508            .as_ref()
509            .and_then(|metadata| metadata.root_orchestration_prompt)
510            .is_none());
511    }
512
513    /// Hand-written OLD-format session JSON: only the legacy `metadata` map,
514    /// no `runtime_metadata` field. Includes a JSON-string
515    /// `pending_injected_messages`, a JSON-array-string `selected_skill_ids`,
516    /// the legacy `mode` key (not `skill_mode`), and open-ended keys that must
517    /// survive untouched.
518    const OLD_FORMAT_SESSION: &str = r#"{
519        "id": "sess-old",
520        "messages": [],
521        "created_at": "2025-01-01T00:00:00Z",
522        "updated_at": "2025-01-01T00:00:00Z",
523        "model": "gpt-test",
524        "metadata": {
525            "subagent_type": "researcher",
526            "last_run_status": "completed",
527            "provider_name": "openai",
528            "workspace_path": "/tmp/ws",
529            "project_id": "01JLEGACYPROJECT000000000000",
530            "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
531            "selected_skill_ids": "[\"pdf\",\"web\"]",
532            "mode": "ask",
533            "task_list_version": "7",
534            "gold_config": "{\"goal\":\"x\"}",
535            "a2a.foo": "bar",
536            "responses.previous_response_id": "resp-123"
537        }
538    }"#;
539
540    #[test]
541    fn old_format_deserializes_and_typed_getters_fall_back() {
542        let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
543        // No runtime_metadata in old JSON.
544        assert!(session.runtime_metadata.is_none());
545
546        // Typed getters resolve via the legacy metadata fallback.
547        assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
548        assert_eq!(session.last_run_status().as_deref(), Some("completed"));
549        assert_eq!(session.provider_name().as_deref(), Some("openai"));
550        assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
551        assert_eq!(
552            session.project_id_meta().as_deref(),
553            Some("01JLEGACYPROJECT000000000000")
554        );
555        assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
556
557        // skill_mode falls back to the legacy `mode` key.
558        assert_eq!(session.skill_mode().as_deref(), Some("ask"));
559
560        // JSON-string pending_injected_messages decodes into the typed vector.
561        let pending = session
562            .pending_injected_messages()
563            .expect("pending should decode");
564        assert_eq!(pending.len(), 2);
565        assert_eq!(pending[0]["content"], "hello");
566        assert_eq!(pending[1]["content"], "world");
567
568        // JSON-array-string selected_skill_ids decodes.
569        let ids = session
570            .selected_skill_ids()
571            .expect("skill ids should decode");
572        assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
573    }
574
575    #[test]
576    fn setters_dual_write_both_planes() {
577        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
578
579        session.set_subagent_type("planner");
580        // Typed field updated.
581        assert_eq!(
582            session
583                .runtime_metadata
584                .as_ref()
585                .and_then(|m| m.subagent_type.as_deref()),
586            Some("planner")
587        );
588        // Legacy string mirror updated.
589        assert_eq!(
590            session.metadata.get("subagent_type").map(String::as_str),
591            Some("planner")
592        );
593
594        session.set_skill_mode("code");
595        assert_eq!(
596            session
597                .runtime_metadata
598                .as_ref()
599                .and_then(|m| m.skill_mode.as_deref()),
600            Some("code")
601        );
602        assert_eq!(
603            session.metadata.get("skill_mode").map(String::as_str),
604            Some("code")
605        );
606        // Typed/canonical skill_mode now wins over the legacy `mode` key.
607        assert_eq!(session.skill_mode().as_deref(), Some("code"));
608
609        // pending_injected_messages dual-writes typed vec + JSON string mirror.
610        session.set_pending_injected_messages(vec![json!({"content": "again"})]);
611        assert_eq!(
612            session
613                .runtime_metadata
614                .as_ref()
615                .and_then(|m| m.pending_injected_messages.as_ref())
616                .map(Vec::len),
617            Some(1)
618        );
619        let raw = session.metadata.get("pending_injected_messages").unwrap();
620        let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
621        assert_eq!(decoded[0]["content"], "again");
622
623        // selected_skill_ids dual-writes typed vec + JSON string mirror.
624        session.set_selected_skill_ids(vec!["audio".to_string()]);
625        let raw = session.metadata.get("selected_skill_ids").unwrap();
626        let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
627        assert_eq!(decoded, vec!["audio".to_string()]);
628
629        session.set_project_id_meta("01JNEWPROJECT000000000000000");
630        assert_eq!(
631            session
632                .runtime_metadata
633                .as_ref()
634                .and_then(|metadata| metadata.project_id.as_deref()),
635            Some("01JNEWPROJECT000000000000000")
636        );
637        assert_eq!(
638            session.metadata.get("project_id").map(String::as_str),
639            Some("01JNEWPROJECT000000000000000")
640        );
641        session.clear_project_id_meta();
642        assert!(session.project_id_meta().is_none());
643    }
644
645    #[test]
646    fn round_trip_preserves_open_ended_and_typed_values() {
647        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
648        session.set_subagent_type("planner");
649        session.set_skill_mode("code");
650
651        // Serialize -> deserialize again.
652        let serialized = serde_json::to_string(&session).unwrap();
653        let restored: Session = serde_json::from_str(&serialized).unwrap();
654
655        // Open-ended keys survive untouched.
656        assert_eq!(
657            restored.metadata.get("gold_config").map(String::as_str),
658            Some("{\"goal\":\"x\"}")
659        );
660        assert_eq!(
661            restored.metadata.get("a2a.foo").map(String::as_str),
662            Some("bar")
663        );
664        assert_eq!(
665            restored
666                .metadata
667                .get("responses.previous_response_id")
668                .map(String::as_str),
669            Some("resp-123")
670        );
671
672        // Typed values round-trip through the new runtime_metadata object.
673        assert!(restored.runtime_metadata.is_some());
674        assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
675        assert_eq!(restored.skill_mode().as_deref(), Some("code"));
676        // And the legacy mirror is still there for un-migrated readers.
677        assert_eq!(
678            restored.metadata.get("subagent_type").map(String::as_str),
679            Some("planner")
680        );
681    }
682
683    #[test]
684    fn malformed_pending_injected_messages_never_panics() {
685        let json = r#"{
686            "id": "sess-bad",
687            "messages": [],
688            "created_at": "2025-01-01T00:00:00Z",
689            "updated_at": "2025-01-01T00:00:00Z",
690            "model": "gpt-test",
691            "metadata": { "pending_injected_messages": "not-json{" }
692        }"#;
693        let session: Session = serde_json::from_str(json).unwrap();
694        // Defensive parse: malformed legacy JSON => empty vec, not a panic.
695        assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
696        assert!(session.has_pending_injected_messages());
697    }
698
699    #[test]
700    fn divergent_skill_mode_and_mode_prefers_skill_mode() {
701        let json = r#"{
702            "id": "sess-div",
703            "messages": [],
704            "created_at": "2025-01-01T00:00:00Z",
705            "updated_at": "2025-01-01T00:00:00Z",
706            "model": "gpt-test",
707            "metadata": { "skill_mode": "code", "mode": "ask" }
708        }"#;
709        let session: Session = serde_json::from_str(json).unwrap();
710        assert_eq!(session.skill_mode().as_deref(), Some("code"));
711    }
712
713    #[test]
714    fn take_pending_clears_both_planes() {
715        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
716        let taken = session.take_pending_injected_messages().unwrap();
717        assert_eq!(taken.len(), 2);
718        assert!(!session.has_pending_injected_messages());
719        assert!(!session.metadata.contains_key("pending_injected_messages"));
720        assert!(session
721            .runtime_metadata
722            .as_ref()
723            .map(|m| m.pending_injected_messages.is_none())
724            .unwrap_or(true));
725    }
726
727    #[test]
728    fn empty_runtime_metadata_not_serialized() {
729        let session = Session::new("sess-empty", "gpt-test");
730        assert!(session.runtime_metadata.is_none());
731        let json = serde_json::to_string(&session).unwrap();
732        assert!(
733            !json.contains("runtime_metadata"),
734            "absent runtime_metadata must not serialize: {json}"
735        );
736    }
737}