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;
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    // ------------------------------------------------------------------
102    // provider_name
103    // ------------------------------------------------------------------
104
105    pub fn provider_name(&self) -> Option<String> {
106        self.runtime_str(|m| m.provider_name.as_ref(), keys::PROVIDER_NAME)
107    }
108
109    pub fn set_provider_name(&mut self, value: impl Into<String>) {
110        let value = value.into();
111        self.runtime_metadata_mut().provider_name = Some(value.clone());
112        self.metadata.insert(keys::PROVIDER_NAME.to_string(), value);
113    }
114
115    // ------------------------------------------------------------------
116    // pending_injected_messages (JSON-string on the legacy map)
117    // ------------------------------------------------------------------
118
119    /// Read the pending injected messages, decoding the legacy JSON-string form
120    /// defensively. Malformed legacy JSON yields `None` (never a panic).
121    pub fn pending_injected_messages(&self) -> Option<Vec<serde_json::Value>> {
122        if let Some(messages) = self
123            .runtime_metadata
124            .as_ref()
125            .and_then(|m| m.pending_injected_messages.clone())
126        {
127            return Some(messages);
128        }
129        let raw = self.metadata.get(keys::PENDING_INJECTED_MESSAGES)?;
130        match serde_json::from_str::<Vec<serde_json::Value>>(raw) {
131            Ok(messages) => Some(messages),
132            Err(_) => Some(Vec::new()),
133        }
134    }
135
136    /// Set pending injected messages on both planes. The legacy mirror stores
137    /// the JSON-encoded string form to preserve byte-for-byte compatibility.
138    pub fn set_pending_injected_messages(&mut self, messages: Vec<serde_json::Value>) {
139        let serialized = serde_json::to_string(&messages).unwrap_or_else(|_| "[]".to_string());
140        self.runtime_metadata_mut().pending_injected_messages = Some(messages);
141        self.metadata
142            .insert(keys::PENDING_INJECTED_MESSAGES.to_string(), serialized);
143    }
144
145    /// True when there are queued injected messages on either plane.
146    pub fn has_pending_injected_messages(&self) -> bool {
147        if self
148            .runtime_metadata
149            .as_ref()
150            .is_some_and(|m| m.pending_injected_messages.is_some())
151        {
152            return true;
153        }
154        self.metadata.contains_key(keys::PENDING_INJECTED_MESSAGES)
155    }
156
157    /// Take and clear pending injected messages from both planes.
158    pub fn take_pending_injected_messages(&mut self) -> Option<Vec<serde_json::Value>> {
159        let value = self.pending_injected_messages();
160        self.clear_pending_injected_messages();
161        value
162    }
163
164    pub fn clear_pending_injected_messages(&mut self) {
165        if let Some(rm) = self.runtime_metadata.as_mut() {
166            rm.pending_injected_messages = None;
167        }
168        self.metadata.remove(keys::PENDING_INJECTED_MESSAGES);
169        self.prune_runtime_metadata();
170    }
171
172    // ------------------------------------------------------------------
173    // selected_skill_ids (JSON-array-string on the legacy map)
174    // ------------------------------------------------------------------
175
176    /// Read selected skill ids. The typed field is preferred; the legacy
177    /// fallback parses the stored JSON-array string defensively (malformed →
178    /// `None`).
179    pub fn selected_skill_ids(&self) -> Option<Vec<String>> {
180        if let Some(ids) = self
181            .runtime_metadata
182            .as_ref()
183            .and_then(|m| m.selected_skill_ids.clone())
184        {
185            return Some(ids);
186        }
187        let raw = self.metadata.get(keys::SELECTED_SKILL_IDS)?;
188        serde_json::from_str::<Vec<String>>(raw).ok()
189    }
190
191    /// Set selected skill ids on both planes. The legacy mirror stores the
192    /// JSON-array string form.
193    pub fn set_selected_skill_ids(&mut self, ids: Vec<String>) {
194        let serialized = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string());
195        self.runtime_metadata_mut().selected_skill_ids = Some(ids);
196        self.metadata
197            .insert(keys::SELECTED_SKILL_IDS.to_string(), serialized);
198    }
199
200    pub fn clear_selected_skill_ids(&mut self) {
201        if let Some(rm) = self.runtime_metadata.as_mut() {
202            rm.selected_skill_ids = None;
203        }
204        self.metadata.remove(keys::SELECTED_SKILL_IDS);
205        self.prune_runtime_metadata();
206    }
207
208    // ------------------------------------------------------------------
209    // skill_mode (canonical) / mode (legacy duplicate)
210    // ------------------------------------------------------------------
211
212    /// Read the skill mode. Resolution order: typed field, then legacy
213    /// `skill_mode` key, then the historical `mode` key. When both legacy keys
214    /// are present and disagree, a warning is logged and `skill_mode` wins.
215    pub fn skill_mode(&self) -> Option<String> {
216        if let Some(mode) = self
217            .runtime_metadata
218            .as_ref()
219            .and_then(|m| m.skill_mode.clone())
220        {
221            return Some(mode);
222        }
223        let canonical = self.metadata.get(keys::SKILL_MODE);
224        let legacy = self.metadata.get(keys::SKILL_MODE_LEGACY);
225        if let (Some(canonical), Some(legacy)) = (canonical, legacy) {
226            if canonical != legacy {
227                tracing::warn!(
228                    canonical = %canonical,
229                    legacy = %legacy,
230                    "session metadata has divergent skill_mode and legacy mode keys; preferring skill_mode"
231                );
232            }
233        }
234        canonical.or(legacy).cloned()
235    }
236
237    /// Set the skill mode. Writes the typed field and the canonical
238    /// `skill_mode` legacy key (the legacy `mode` key is never written).
239    pub fn set_skill_mode(&mut self, value: impl Into<String>) {
240        let value = value.into();
241        self.runtime_metadata_mut().skill_mode = Some(value.clone());
242        self.metadata.insert(keys::SKILL_MODE.to_string(), value);
243    }
244
245    pub fn clear_skill_mode(&mut self) {
246        if let Some(rm) = self.runtime_metadata.as_mut() {
247            rm.skill_mode = None;
248        }
249        self.metadata.remove(keys::SKILL_MODE);
250        self.prune_runtime_metadata();
251    }
252
253    // ------------------------------------------------------------------
254    // reasoning_effort (string form)
255    // ------------------------------------------------------------------
256
257    pub fn reasoning_effort_meta(&self) -> Option<String> {
258        self.runtime_str(|m| m.reasoning_effort.as_ref(), keys::REASONING_EFFORT)
259    }
260
261    pub fn set_reasoning_effort_meta(&mut self, value: impl Into<String>) {
262        let value = value.into();
263        self.runtime_metadata_mut().reasoning_effort = Some(value.clone());
264        self.metadata
265            .insert(keys::REASONING_EFFORT.to_string(), value);
266    }
267
268    // ------------------------------------------------------------------
269    // enhance_prompt
270    // ------------------------------------------------------------------
271
272    pub fn enhance_prompt(&self) -> Option<String> {
273        self.runtime_str(|m| m.enhance_prompt.as_ref(), keys::ENHANCE_PROMPT)
274    }
275
276    pub fn set_enhance_prompt(&mut self, value: impl Into<String>) {
277        let value = value.into();
278        self.runtime_metadata_mut().enhance_prompt = Some(value.clone());
279        self.metadata
280            .insert(keys::ENHANCE_PROMPT.to_string(), value);
281    }
282
283    pub fn clear_enhance_prompt(&mut self) {
284        if let Some(rm) = self.runtime_metadata.as_mut() {
285            rm.enhance_prompt = None;
286        }
287        self.metadata.remove(keys::ENHANCE_PROMPT);
288        self.prune_runtime_metadata();
289    }
290
291    // ------------------------------------------------------------------
292    // task_list_version / todo_list_version (string form)
293    // ------------------------------------------------------------------
294
295    pub fn task_list_version_meta(&self) -> Option<String> {
296        self.runtime_str(|m| m.task_list_version.as_ref(), keys::TASK_LIST_VERSION)
297    }
298
299    pub fn set_task_list_version_meta(&mut self, value: impl Into<String>) {
300        let value = value.into();
301        self.runtime_metadata_mut().task_list_version = Some(value.clone());
302        self.metadata
303            .insert(keys::TASK_LIST_VERSION.to_string(), value);
304    }
305
306    pub fn todo_list_version_meta(&self) -> Option<String> {
307        self.runtime_str(|m| m.todo_list_version.as_ref(), keys::TODO_LIST_VERSION)
308    }
309
310    pub fn set_todo_list_version_meta(&mut self, value: impl Into<String>) {
311        let value = value.into();
312        self.runtime_metadata_mut().todo_list_version = Some(value.clone());
313        self.metadata
314            .insert(keys::TODO_LIST_VERSION.to_string(), value);
315    }
316
317    // ------------------------------------------------------------------
318    // workspace_path
319    // ------------------------------------------------------------------
320
321    pub fn workspace_path_meta(&self) -> Option<String> {
322        self.runtime_str(|m| m.workspace_path.as_ref(), keys::WORKSPACE_PATH)
323    }
324
325    pub fn set_workspace_path_meta(&mut self, value: impl Into<String>) {
326        let value = value.into();
327        self.runtime_metadata_mut().workspace_path = Some(value.clone());
328        self.metadata
329            .insert(keys::WORKSPACE_PATH.to_string(), value);
330    }
331
332    // ------------------------------------------------------------------
333    // project_id
334    // ------------------------------------------------------------------
335
336    /// Read the stable Project identity, preferring typed runtime metadata and
337    /// falling back to the legacy metadata string during migration.
338    pub fn project_id_meta(&self) -> Option<String> {
339        self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
340    }
341
342    /// Persist Project identity on both planes while legacy raw-map readers
343    /// remain in the tree.
344    pub fn set_project_id_meta(&mut self, value: impl Into<String>) {
345        let value = value.into();
346        self.runtime_metadata_mut().project_id = Some(value.clone());
347        self.metadata.insert(keys::PROJECT_ID.to_string(), value);
348    }
349
350    pub fn clear_project_id_meta(&mut self) {
351        if let Some(runtime_metadata) = self.runtime_metadata.as_mut() {
352            runtime_metadata.project_id = None;
353        }
354        self.metadata.remove(keys::PROJECT_ID);
355        self.prune_runtime_metadata();
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use serde_json::json;
363
364    /// Hand-written OLD-format session JSON: only the legacy `metadata` map,
365    /// no `runtime_metadata` field. Includes a JSON-string
366    /// `pending_injected_messages`, a JSON-array-string `selected_skill_ids`,
367    /// the legacy `mode` key (not `skill_mode`), and open-ended keys that must
368    /// survive untouched.
369    const OLD_FORMAT_SESSION: &str = r#"{
370        "id": "sess-old",
371        "messages": [],
372        "created_at": "2025-01-01T00:00:00Z",
373        "updated_at": "2025-01-01T00:00:00Z",
374        "model": "gpt-test",
375        "metadata": {
376            "subagent_type": "researcher",
377            "last_run_status": "completed",
378            "provider_name": "openai",
379            "workspace_path": "/tmp/ws",
380            "project_id": "01JLEGACYPROJECT000000000000",
381            "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
382            "selected_skill_ids": "[\"pdf\",\"web\"]",
383            "mode": "ask",
384            "task_list_version": "7",
385            "gold_config": "{\"goal\":\"x\"}",
386            "a2a.foo": "bar",
387            "responses.previous_response_id": "resp-123"
388        }
389    }"#;
390
391    #[test]
392    fn old_format_deserializes_and_typed_getters_fall_back() {
393        let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
394        // No runtime_metadata in old JSON.
395        assert!(session.runtime_metadata.is_none());
396
397        // Typed getters resolve via the legacy metadata fallback.
398        assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
399        assert_eq!(session.last_run_status().as_deref(), Some("completed"));
400        assert_eq!(session.provider_name().as_deref(), Some("openai"));
401        assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
402        assert_eq!(
403            session.project_id_meta().as_deref(),
404            Some("01JLEGACYPROJECT000000000000")
405        );
406        assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
407
408        // skill_mode falls back to the legacy `mode` key.
409        assert_eq!(session.skill_mode().as_deref(), Some("ask"));
410
411        // JSON-string pending_injected_messages decodes into the typed vector.
412        let pending = session
413            .pending_injected_messages()
414            .expect("pending should decode");
415        assert_eq!(pending.len(), 2);
416        assert_eq!(pending[0]["content"], "hello");
417        assert_eq!(pending[1]["content"], "world");
418
419        // JSON-array-string selected_skill_ids decodes.
420        let ids = session
421            .selected_skill_ids()
422            .expect("skill ids should decode");
423        assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
424    }
425
426    #[test]
427    fn setters_dual_write_both_planes() {
428        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
429
430        session.set_subagent_type("planner");
431        // Typed field updated.
432        assert_eq!(
433            session
434                .runtime_metadata
435                .as_ref()
436                .and_then(|m| m.subagent_type.as_deref()),
437            Some("planner")
438        );
439        // Legacy string mirror updated.
440        assert_eq!(
441            session.metadata.get("subagent_type").map(String::as_str),
442            Some("planner")
443        );
444
445        session.set_skill_mode("code");
446        assert_eq!(
447            session
448                .runtime_metadata
449                .as_ref()
450                .and_then(|m| m.skill_mode.as_deref()),
451            Some("code")
452        );
453        assert_eq!(
454            session.metadata.get("skill_mode").map(String::as_str),
455            Some("code")
456        );
457        // Typed/canonical skill_mode now wins over the legacy `mode` key.
458        assert_eq!(session.skill_mode().as_deref(), Some("code"));
459
460        // pending_injected_messages dual-writes typed vec + JSON string mirror.
461        session.set_pending_injected_messages(vec![json!({"content": "again"})]);
462        assert_eq!(
463            session
464                .runtime_metadata
465                .as_ref()
466                .and_then(|m| m.pending_injected_messages.as_ref())
467                .map(Vec::len),
468            Some(1)
469        );
470        let raw = session.metadata.get("pending_injected_messages").unwrap();
471        let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
472        assert_eq!(decoded[0]["content"], "again");
473
474        // selected_skill_ids dual-writes typed vec + JSON string mirror.
475        session.set_selected_skill_ids(vec!["audio".to_string()]);
476        let raw = session.metadata.get("selected_skill_ids").unwrap();
477        let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
478        assert_eq!(decoded, vec!["audio".to_string()]);
479
480        session.set_project_id_meta("01JNEWPROJECT000000000000000");
481        assert_eq!(
482            session
483                .runtime_metadata
484                .as_ref()
485                .and_then(|metadata| metadata.project_id.as_deref()),
486            Some("01JNEWPROJECT000000000000000")
487        );
488        assert_eq!(
489            session.metadata.get("project_id").map(String::as_str),
490            Some("01JNEWPROJECT000000000000000")
491        );
492        session.clear_project_id_meta();
493        assert!(session.project_id_meta().is_none());
494    }
495
496    #[test]
497    fn round_trip_preserves_open_ended_and_typed_values() {
498        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
499        session.set_subagent_type("planner");
500        session.set_skill_mode("code");
501
502        // Serialize -> deserialize again.
503        let serialized = serde_json::to_string(&session).unwrap();
504        let restored: Session = serde_json::from_str(&serialized).unwrap();
505
506        // Open-ended keys survive untouched.
507        assert_eq!(
508            restored.metadata.get("gold_config").map(String::as_str),
509            Some("{\"goal\":\"x\"}")
510        );
511        assert_eq!(
512            restored.metadata.get("a2a.foo").map(String::as_str),
513            Some("bar")
514        );
515        assert_eq!(
516            restored
517                .metadata
518                .get("responses.previous_response_id")
519                .map(String::as_str),
520            Some("resp-123")
521        );
522
523        // Typed values round-trip through the new runtime_metadata object.
524        assert!(restored.runtime_metadata.is_some());
525        assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
526        assert_eq!(restored.skill_mode().as_deref(), Some("code"));
527        // And the legacy mirror is still there for un-migrated readers.
528        assert_eq!(
529            restored.metadata.get("subagent_type").map(String::as_str),
530            Some("planner")
531        );
532    }
533
534    #[test]
535    fn malformed_pending_injected_messages_never_panics() {
536        let json = r#"{
537            "id": "sess-bad",
538            "messages": [],
539            "created_at": "2025-01-01T00:00:00Z",
540            "updated_at": "2025-01-01T00:00:00Z",
541            "model": "gpt-test",
542            "metadata": { "pending_injected_messages": "not-json{" }
543        }"#;
544        let session: Session = serde_json::from_str(json).unwrap();
545        // Defensive parse: malformed legacy JSON => empty vec, not a panic.
546        assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
547        assert!(session.has_pending_injected_messages());
548    }
549
550    #[test]
551    fn divergent_skill_mode_and_mode_prefers_skill_mode() {
552        let json = r#"{
553            "id": "sess-div",
554            "messages": [],
555            "created_at": "2025-01-01T00:00:00Z",
556            "updated_at": "2025-01-01T00:00:00Z",
557            "model": "gpt-test",
558            "metadata": { "skill_mode": "code", "mode": "ask" }
559        }"#;
560        let session: Session = serde_json::from_str(json).unwrap();
561        assert_eq!(session.skill_mode().as_deref(), Some("code"));
562    }
563
564    #[test]
565    fn take_pending_clears_both_planes() {
566        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
567        let taken = session.take_pending_injected_messages().unwrap();
568        assert_eq!(taken.len(), 2);
569        assert!(!session.has_pending_injected_messages());
570        assert!(!session.metadata.contains_key("pending_injected_messages"));
571        assert!(session
572            .runtime_metadata
573            .as_ref()
574            .map(|m| m.pending_injected_messages.is_none())
575            .unwrap_or(true));
576    }
577
578    #[test]
579    fn empty_runtime_metadata_not_serialized() {
580        let session = Session::new("sess-empty", "gpt-test");
581        assert!(session.runtime_metadata.is_none());
582        let json = serde_json::to_string(&session).unwrap();
583        assert!(
584            !json.contains("runtime_metadata"),
585            "absent runtime_metadata must not serialize: {json}"
586        );
587    }
588}