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#[cfg(test)]
334mod tests {
335    use super::*;
336    use serde_json::json;
337
338    /// Hand-written OLD-format session JSON: only the legacy `metadata` map,
339    /// no `runtime_metadata` field. Includes a JSON-string
340    /// `pending_injected_messages`, a JSON-array-string `selected_skill_ids`,
341    /// the legacy `mode` key (not `skill_mode`), and open-ended keys that must
342    /// survive untouched.
343    const OLD_FORMAT_SESSION: &str = r#"{
344        "id": "sess-old",
345        "messages": [],
346        "created_at": "2025-01-01T00:00:00Z",
347        "updated_at": "2025-01-01T00:00:00Z",
348        "model": "gpt-test",
349        "metadata": {
350            "subagent_type": "researcher",
351            "last_run_status": "completed",
352            "provider_name": "openai",
353            "workspace_path": "/tmp/ws",
354            "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
355            "selected_skill_ids": "[\"pdf\",\"web\"]",
356            "mode": "ask",
357            "task_list_version": "7",
358            "gold_config": "{\"goal\":\"x\"}",
359            "a2a.foo": "bar",
360            "responses.previous_response_id": "resp-123"
361        }
362    }"#;
363
364    #[test]
365    fn old_format_deserializes_and_typed_getters_fall_back() {
366        let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
367        // No runtime_metadata in old JSON.
368        assert!(session.runtime_metadata.is_none());
369
370        // Typed getters resolve via the legacy metadata fallback.
371        assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
372        assert_eq!(session.last_run_status().as_deref(), Some("completed"));
373        assert_eq!(session.provider_name().as_deref(), Some("openai"));
374        assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
375        assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
376
377        // skill_mode falls back to the legacy `mode` key.
378        assert_eq!(session.skill_mode().as_deref(), Some("ask"));
379
380        // JSON-string pending_injected_messages decodes into the typed vector.
381        let pending = session
382            .pending_injected_messages()
383            .expect("pending should decode");
384        assert_eq!(pending.len(), 2);
385        assert_eq!(pending[0]["content"], "hello");
386        assert_eq!(pending[1]["content"], "world");
387
388        // JSON-array-string selected_skill_ids decodes.
389        let ids = session
390            .selected_skill_ids()
391            .expect("skill ids should decode");
392        assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
393    }
394
395    #[test]
396    fn setters_dual_write_both_planes() {
397        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
398
399        session.set_subagent_type("planner");
400        // Typed field updated.
401        assert_eq!(
402            session
403                .runtime_metadata
404                .as_ref()
405                .and_then(|m| m.subagent_type.as_deref()),
406            Some("planner")
407        );
408        // Legacy string mirror updated.
409        assert_eq!(
410            session.metadata.get("subagent_type").map(String::as_str),
411            Some("planner")
412        );
413
414        session.set_skill_mode("code");
415        assert_eq!(
416            session
417                .runtime_metadata
418                .as_ref()
419                .and_then(|m| m.skill_mode.as_deref()),
420            Some("code")
421        );
422        assert_eq!(
423            session.metadata.get("skill_mode").map(String::as_str),
424            Some("code")
425        );
426        // Typed/canonical skill_mode now wins over the legacy `mode` key.
427        assert_eq!(session.skill_mode().as_deref(), Some("code"));
428
429        // pending_injected_messages dual-writes typed vec + JSON string mirror.
430        session.set_pending_injected_messages(vec![json!({"content": "again"})]);
431        assert_eq!(
432            session
433                .runtime_metadata
434                .as_ref()
435                .and_then(|m| m.pending_injected_messages.as_ref())
436                .map(Vec::len),
437            Some(1)
438        );
439        let raw = session.metadata.get("pending_injected_messages").unwrap();
440        let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
441        assert_eq!(decoded[0]["content"], "again");
442
443        // selected_skill_ids dual-writes typed vec + JSON string mirror.
444        session.set_selected_skill_ids(vec!["audio".to_string()]);
445        let raw = session.metadata.get("selected_skill_ids").unwrap();
446        let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
447        assert_eq!(decoded, vec!["audio".to_string()]);
448    }
449
450    #[test]
451    fn round_trip_preserves_open_ended_and_typed_values() {
452        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
453        session.set_subagent_type("planner");
454        session.set_skill_mode("code");
455
456        // Serialize -> deserialize again.
457        let serialized = serde_json::to_string(&session).unwrap();
458        let restored: Session = serde_json::from_str(&serialized).unwrap();
459
460        // Open-ended keys survive untouched.
461        assert_eq!(
462            restored.metadata.get("gold_config").map(String::as_str),
463            Some("{\"goal\":\"x\"}")
464        );
465        assert_eq!(
466            restored.metadata.get("a2a.foo").map(String::as_str),
467            Some("bar")
468        );
469        assert_eq!(
470            restored
471                .metadata
472                .get("responses.previous_response_id")
473                .map(String::as_str),
474            Some("resp-123")
475        );
476
477        // Typed values round-trip through the new runtime_metadata object.
478        assert!(restored.runtime_metadata.is_some());
479        assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
480        assert_eq!(restored.skill_mode().as_deref(), Some("code"));
481        // And the legacy mirror is still there for un-migrated readers.
482        assert_eq!(
483            restored.metadata.get("subagent_type").map(String::as_str),
484            Some("planner")
485        );
486    }
487
488    #[test]
489    fn malformed_pending_injected_messages_never_panics() {
490        let json = r#"{
491            "id": "sess-bad",
492            "messages": [],
493            "created_at": "2025-01-01T00:00:00Z",
494            "updated_at": "2025-01-01T00:00:00Z",
495            "model": "gpt-test",
496            "metadata": { "pending_injected_messages": "not-json{" }
497        }"#;
498        let session: Session = serde_json::from_str(json).unwrap();
499        // Defensive parse: malformed legacy JSON => empty vec, not a panic.
500        assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
501        assert!(session.has_pending_injected_messages());
502    }
503
504    #[test]
505    fn divergent_skill_mode_and_mode_prefers_skill_mode() {
506        let json = r#"{
507            "id": "sess-div",
508            "messages": [],
509            "created_at": "2025-01-01T00:00:00Z",
510            "updated_at": "2025-01-01T00:00:00Z",
511            "model": "gpt-test",
512            "metadata": { "skill_mode": "code", "mode": "ask" }
513        }"#;
514        let session: Session = serde_json::from_str(json).unwrap();
515        assert_eq!(session.skill_mode().as_deref(), Some("code"));
516    }
517
518    #[test]
519    fn take_pending_clears_both_planes() {
520        let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
521        let taken = session.take_pending_injected_messages().unwrap();
522        assert_eq!(taken.len(), 2);
523        assert!(!session.has_pending_injected_messages());
524        assert!(!session.metadata.contains_key("pending_injected_messages"));
525        assert!(session
526            .runtime_metadata
527            .as_ref()
528            .map(|m| m.pending_injected_messages.is_none())
529            .unwrap_or(true));
530    }
531
532    #[test]
533    fn empty_runtime_metadata_not_serialized() {
534        let session = Session::new("sess-empty", "gpt-test");
535        assert!(session.runtime_metadata.is_none());
536        let json = serde_json::to_string(&session).unwrap();
537        assert!(
538            !json.contains("runtime_metadata"),
539            "absent runtime_metadata must not serialize: {json}"
540        );
541    }
542}