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