1use super::runtime_metadata::{keys, SessionRuntimeMetadata};
16use super::types::{Session, SessionKind};
17
18impl Session {
19 fn runtime_metadata_mut(&mut self) -> &mut SessionRuntimeMetadata {
21 self.runtime_metadata.get_or_insert_with(Default::default)
22 }
23
24 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 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 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 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 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 pub fn child_launch_generation(&self) -> u64 {
104 self.runtime_metadata
105 .as_ref()
106 .and_then(|metadata| metadata.child_launch_generation)
107 .unwrap_or(0)
108 }
109
110 pub fn advance_child_launch_generation(&mut self) -> Option<u64> {
111 let next = self.child_launch_generation().checked_add(1)?;
112 self.runtime_metadata_mut().child_launch_generation = Some(next);
113 Some(next)
114 }
115
116 pub fn is_child_launch_cancelled(&self, generation: u64) -> bool {
117 self.runtime_metadata
118 .as_ref()
119 .and_then(|metadata| metadata.child_cancelled_generation)
120 .is_some_and(|cancelled| cancelled >= generation)
121 }
122
123 pub fn cancel_child_launch_generation(&mut self) {
124 let generation = self.child_launch_generation();
125 self.runtime_metadata_mut().child_cancelled_generation = Some(generation);
126 }
127
128 pub fn mark_child_auto_run_launch_intent(&mut self) {
131 let generation = self.child_launch_generation();
132 self.runtime_metadata_mut()
133 .child_auto_run_launch_intent_generation = Some(generation);
134 }
135
136 pub fn recoverable_child_launch_generation(&self) -> Option<u64> {
139 if self.kind != SessionKind::Child || self.last_run_status().as_deref() != Some("pending") {
140 return None;
141 }
142 let generation = self.child_launch_generation();
143 self.runtime_metadata
144 .as_ref()
145 .and_then(|metadata| metadata.child_auto_run_launch_intent_generation)
146 .filter(|intent| *intent == generation && !self.is_child_launch_cancelled(*intent))
147 }
148
149 pub fn provider_name(&self) -> Option<String> {
154 self.runtime_str(|m| m.provider_name.as_ref(), keys::PROVIDER_NAME)
155 }
156
157 pub fn set_provider_name(&mut self, value: impl Into<String>) {
158 let value = value.into();
159 self.runtime_metadata_mut().provider_name = Some(value.clone());
160 self.metadata.insert(keys::PROVIDER_NAME.to_string(), value);
161 }
162
163 pub fn pending_injected_messages(&self) -> Option<Vec<serde_json::Value>> {
170 if let Some(messages) = self
171 .runtime_metadata
172 .as_ref()
173 .and_then(|m| m.pending_injected_messages.clone())
174 {
175 return Some(messages);
176 }
177 let raw = self.metadata.get(keys::PENDING_INJECTED_MESSAGES)?;
178 match serde_json::from_str::<Vec<serde_json::Value>>(raw) {
179 Ok(messages) => Some(messages),
180 Err(_) => Some(Vec::new()),
181 }
182 }
183
184 pub fn set_pending_injected_messages(&mut self, messages: Vec<serde_json::Value>) {
187 let serialized = serde_json::to_string(&messages).unwrap_or_else(|_| "[]".to_string());
188 self.runtime_metadata_mut().pending_injected_messages = Some(messages);
189 self.metadata
190 .insert(keys::PENDING_INJECTED_MESSAGES.to_string(), serialized);
191 }
192
193 pub fn has_pending_injected_messages(&self) -> bool {
195 if self
196 .runtime_metadata
197 .as_ref()
198 .is_some_and(|m| m.pending_injected_messages.is_some())
199 {
200 return true;
201 }
202 self.metadata.contains_key(keys::PENDING_INJECTED_MESSAGES)
203 }
204
205 pub fn take_pending_injected_messages(&mut self) -> Option<Vec<serde_json::Value>> {
207 let value = self.pending_injected_messages();
208 self.clear_pending_injected_messages();
209 value
210 }
211
212 pub fn clear_pending_injected_messages(&mut self) {
213 if let Some(rm) = self.runtime_metadata.as_mut() {
214 rm.pending_injected_messages = None;
215 }
216 self.metadata.remove(keys::PENDING_INJECTED_MESSAGES);
217 self.prune_runtime_metadata();
218 }
219
220 pub fn session_inbox_admission(&self) -> Option<&super::SessionInboxAdmissionState> {
225 self.runtime_metadata
226 .as_ref()
227 .and_then(|metadata| metadata.session_inbox_admission.as_ref())
228 }
229
230 pub fn session_inbox_admission_mut(&mut self) -> &mut super::SessionInboxAdmissionState {
231 self.runtime_metadata_mut()
232 .session_inbox_admission
233 .get_or_insert_with(Default::default)
234 }
235
236 pub fn selected_skill_ids(&self) -> Option<Vec<String>> {
244 if let Some(ids) = self
245 .runtime_metadata
246 .as_ref()
247 .and_then(|m| m.selected_skill_ids.clone())
248 {
249 return Some(ids);
250 }
251 let raw = self.metadata.get(keys::SELECTED_SKILL_IDS)?;
252 serde_json::from_str::<Vec<String>>(raw).ok()
253 }
254
255 pub fn set_selected_skill_ids(&mut self, ids: Vec<String>) {
258 let serialized = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string());
259 self.runtime_metadata_mut().selected_skill_ids = Some(ids);
260 self.metadata
261 .insert(keys::SELECTED_SKILL_IDS.to_string(), serialized);
262 }
263
264 pub fn clear_selected_skill_ids(&mut self) {
265 if let Some(rm) = self.runtime_metadata.as_mut() {
266 rm.selected_skill_ids = None;
267 }
268 self.metadata.remove(keys::SELECTED_SKILL_IDS);
269 self.prune_runtime_metadata();
270 }
271
272 pub fn skill_mode(&self) -> Option<String> {
280 if let Some(mode) = self
281 .runtime_metadata
282 .as_ref()
283 .and_then(|m| m.skill_mode.clone())
284 {
285 return Some(mode);
286 }
287 let canonical = self.metadata.get(keys::SKILL_MODE);
288 let legacy = self.metadata.get(keys::SKILL_MODE_LEGACY);
289 if let (Some(canonical), Some(legacy)) = (canonical, legacy) {
290 if canonical != legacy {
291 tracing::warn!(
292 canonical = %canonical,
293 legacy = %legacy,
294 "session metadata has divergent skill_mode and legacy mode keys; preferring skill_mode"
295 );
296 }
297 }
298 canonical.or(legacy).cloned()
299 }
300
301 pub fn set_skill_mode(&mut self, value: impl Into<String>) {
304 let value = value.into();
305 self.runtime_metadata_mut().skill_mode = Some(value.clone());
306 self.metadata.insert(keys::SKILL_MODE.to_string(), value);
307 }
308
309 pub fn clear_skill_mode(&mut self) {
310 if let Some(rm) = self.runtime_metadata.as_mut() {
311 rm.skill_mode = None;
312 }
313 self.metadata.remove(keys::SKILL_MODE);
314 self.prune_runtime_metadata();
315 }
316
317 pub fn reasoning_effort_meta(&self) -> Option<String> {
322 self.runtime_str(|m| m.reasoning_effort.as_ref(), keys::REASONING_EFFORT)
323 }
324
325 pub fn set_reasoning_effort_meta(&mut self, value: impl Into<String>) {
326 let value = value.into();
327 self.runtime_metadata_mut().reasoning_effort = Some(value.clone());
328 self.metadata
329 .insert(keys::REASONING_EFFORT.to_string(), value);
330 }
331
332 pub fn enhance_prompt(&self) -> Option<String> {
337 self.runtime_str(|m| m.enhance_prompt.as_ref(), keys::ENHANCE_PROMPT)
338 }
339
340 pub fn set_enhance_prompt(&mut self, value: impl Into<String>) {
341 let value = value.into();
342 self.runtime_metadata_mut().enhance_prompt = Some(value.clone());
343 self.metadata
344 .insert(keys::ENHANCE_PROMPT.to_string(), value);
345 }
346
347 pub fn clear_enhance_prompt(&mut self) {
348 if let Some(rm) = self.runtime_metadata.as_mut() {
349 rm.enhance_prompt = None;
350 }
351 self.metadata.remove(keys::ENHANCE_PROMPT);
352 self.prune_runtime_metadata();
353 }
354
355 pub fn root_orchestration_prompt_enabled(&self) -> bool {
362 self.kind == SessionKind::Root
363 && self.parent_session_id.is_none()
364 && self
365 .runtime_metadata
366 .as_ref()
367 .and_then(|metadata| metadata.root_orchestration_prompt)
368 .or_else(|| {
369 self.metadata
370 .get(keys::ROOT_ORCHESTRATION_PROMPT)
371 .and_then(|value| value.parse::<bool>().ok())
372 })
373 .unwrap_or(false)
374 }
375
376 pub fn set_root_orchestration_prompt_enabled(&mut self, enabled: bool) {
379 if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
380 return;
381 }
382 let changed = self.root_orchestration_prompt_enabled() != enabled;
383 if enabled {
384 self.runtime_metadata_mut().root_orchestration_prompt = Some(true);
385 self.metadata.insert(
386 keys::ROOT_ORCHESTRATION_PROMPT.to_string(),
387 "true".to_string(),
388 );
389 } else {
390 if let Some(metadata) = self.runtime_metadata.as_mut() {
391 metadata.root_orchestration_prompt = None;
392 }
393 self.metadata.remove(keys::ROOT_ORCHESTRATION_PROMPT);
394 self.prune_runtime_metadata();
395 }
396 if changed && self.model_context_state.is_some() {
400 self.reset_model_context_epoch(
401 super::model_context::ModelContextResetReason::CacheScopeChanged,
402 );
403 }
404 }
405
406 pub fn task_list_version_meta(&self) -> Option<String> {
411 self.runtime_str(|m| m.task_list_version.as_ref(), keys::TASK_LIST_VERSION)
412 }
413
414 pub fn set_task_list_version_meta(&mut self, value: impl Into<String>) {
415 let value = value.into();
416 self.runtime_metadata_mut().task_list_version = Some(value.clone());
417 self.metadata
418 .insert(keys::TASK_LIST_VERSION.to_string(), value);
419 }
420
421 pub fn todo_list_version_meta(&self) -> Option<String> {
422 self.runtime_str(|m| m.todo_list_version.as_ref(), keys::TODO_LIST_VERSION)
423 }
424
425 pub fn set_todo_list_version_meta(&mut self, value: impl Into<String>) {
426 let value = value.into();
427 self.runtime_metadata_mut().todo_list_version = Some(value.clone());
428 self.metadata
429 .insert(keys::TODO_LIST_VERSION.to_string(), value);
430 }
431
432 pub fn workspace_path_meta(&self) -> Option<String> {
437 self.runtime_str(|m| m.workspace_path.as_ref(), keys::WORKSPACE_PATH)
438 }
439
440 pub fn set_workspace_path_meta(&mut self, value: impl Into<String>) {
441 let value = value.into();
442 self.runtime_metadata_mut().workspace_path = Some(value.clone());
443 self.metadata
444 .insert(keys::WORKSPACE_PATH.to_string(), value);
445 }
446
447 pub fn project_id_meta(&self) -> Option<String> {
454 self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
455 }
456
457 pub fn set_project_id_meta(&mut self, value: impl Into<String>) {
460 let value = value.into();
461 self.runtime_metadata_mut().project_id = Some(value.clone());
462 self.metadata.insert(keys::PROJECT_ID.to_string(), value);
463 }
464
465 pub fn clear_project_id_meta(&mut self) {
466 if let Some(runtime_metadata) = self.runtime_metadata.as_mut() {
467 runtime_metadata.project_id = None;
468 }
469 self.metadata.remove(keys::PROJECT_ID);
470 self.prune_runtime_metadata();
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use serde_json::json;
478
479 #[test]
480 fn root_orchestration_selection_survives_reload_but_never_applies_to_child() {
481 let mut root = Session::new("root-orchestration", "model");
482 assert!(!root.root_orchestration_prompt_enabled());
483 root.set_root_orchestration_prompt_enabled(true);
484 let stored = serde_json::to_string(&root).expect("serialize root");
485 let mut reloaded: Session = serde_json::from_str(&stored).expect("reload root");
486 assert!(reloaded.root_orchestration_prompt_enabled());
487 assert_eq!(
488 reloaded
489 .metadata
490 .get(keys::ROOT_ORCHESTRATION_PROMPT)
491 .map(String::as_str),
492 Some("true")
493 );
494
495 let mut child = Session::new_child_of("child", &reloaded, "model", "child");
496 child.set_root_orchestration_prompt_enabled(true);
497 assert!(!child.root_orchestration_prompt_enabled());
498
499 reloaded.set_root_orchestration_prompt_enabled(false);
500 assert!(!reloaded.root_orchestration_prompt_enabled());
501 assert!(!reloaded
502 .metadata
503 .contains_key(keys::ROOT_ORCHESTRATION_PROMPT));
504 assert!(reloaded
505 .runtime_metadata
506 .as_ref()
507 .and_then(|metadata| metadata.root_orchestration_prompt)
508 .is_none());
509 }
510
511 const OLD_FORMAT_SESSION: &str = r#"{
517 "id": "sess-old",
518 "messages": [],
519 "created_at": "2025-01-01T00:00:00Z",
520 "updated_at": "2025-01-01T00:00:00Z",
521 "model": "gpt-test",
522 "metadata": {
523 "subagent_type": "researcher",
524 "last_run_status": "completed",
525 "provider_name": "openai",
526 "workspace_path": "/tmp/ws",
527 "project_id": "01JLEGACYPROJECT000000000000",
528 "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
529 "selected_skill_ids": "[\"pdf\",\"web\"]",
530 "mode": "ask",
531 "task_list_version": "7",
532 "gold_config": "{\"goal\":\"x\"}",
533 "a2a.foo": "bar",
534 "responses.previous_response_id": "resp-123"
535 }
536 }"#;
537
538 #[test]
539 fn old_format_deserializes_and_typed_getters_fall_back() {
540 let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
541 assert!(session.runtime_metadata.is_none());
543
544 assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
546 assert_eq!(session.last_run_status().as_deref(), Some("completed"));
547 assert_eq!(session.provider_name().as_deref(), Some("openai"));
548 assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
549 assert_eq!(
550 session.project_id_meta().as_deref(),
551 Some("01JLEGACYPROJECT000000000000")
552 );
553 assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
554
555 assert_eq!(session.skill_mode().as_deref(), Some("ask"));
557
558 let pending = session
560 .pending_injected_messages()
561 .expect("pending should decode");
562 assert_eq!(pending.len(), 2);
563 assert_eq!(pending[0]["content"], "hello");
564 assert_eq!(pending[1]["content"], "world");
565
566 let ids = session
568 .selected_skill_ids()
569 .expect("skill ids should decode");
570 assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
571 }
572
573 #[test]
574 fn setters_dual_write_both_planes() {
575 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
576
577 session.set_subagent_type("planner");
578 assert_eq!(
580 session
581 .runtime_metadata
582 .as_ref()
583 .and_then(|m| m.subagent_type.as_deref()),
584 Some("planner")
585 );
586 assert_eq!(
588 session.metadata.get("subagent_type").map(String::as_str),
589 Some("planner")
590 );
591
592 session.set_skill_mode("code");
593 assert_eq!(
594 session
595 .runtime_metadata
596 .as_ref()
597 .and_then(|m| m.skill_mode.as_deref()),
598 Some("code")
599 );
600 assert_eq!(
601 session.metadata.get("skill_mode").map(String::as_str),
602 Some("code")
603 );
604 assert_eq!(session.skill_mode().as_deref(), Some("code"));
606
607 session.set_pending_injected_messages(vec![json!({"content": "again"})]);
609 assert_eq!(
610 session
611 .runtime_metadata
612 .as_ref()
613 .and_then(|m| m.pending_injected_messages.as_ref())
614 .map(Vec::len),
615 Some(1)
616 );
617 let raw = session.metadata.get("pending_injected_messages").unwrap();
618 let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
619 assert_eq!(decoded[0]["content"], "again");
620
621 session.set_selected_skill_ids(vec!["audio".to_string()]);
623 let raw = session.metadata.get("selected_skill_ids").unwrap();
624 let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
625 assert_eq!(decoded, vec!["audio".to_string()]);
626
627 session.set_project_id_meta("01JNEWPROJECT000000000000000");
628 assert_eq!(
629 session
630 .runtime_metadata
631 .as_ref()
632 .and_then(|metadata| metadata.project_id.as_deref()),
633 Some("01JNEWPROJECT000000000000000")
634 );
635 assert_eq!(
636 session.metadata.get("project_id").map(String::as_str),
637 Some("01JNEWPROJECT000000000000000")
638 );
639 session.clear_project_id_meta();
640 assert!(session.project_id_meta().is_none());
641 }
642
643 #[test]
644 fn round_trip_preserves_open_ended_and_typed_values() {
645 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
646 session.set_subagent_type("planner");
647 session.set_skill_mode("code");
648
649 let serialized = serde_json::to_string(&session).unwrap();
651 let restored: Session = serde_json::from_str(&serialized).unwrap();
652
653 assert_eq!(
655 restored.metadata.get("gold_config").map(String::as_str),
656 Some("{\"goal\":\"x\"}")
657 );
658 assert_eq!(
659 restored.metadata.get("a2a.foo").map(String::as_str),
660 Some("bar")
661 );
662 assert_eq!(
663 restored
664 .metadata
665 .get("responses.previous_response_id")
666 .map(String::as_str),
667 Some("resp-123")
668 );
669
670 assert!(restored.runtime_metadata.is_some());
672 assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
673 assert_eq!(restored.skill_mode().as_deref(), Some("code"));
674 assert_eq!(
676 restored.metadata.get("subagent_type").map(String::as_str),
677 Some("planner")
678 );
679 }
680
681 #[test]
682 fn malformed_pending_injected_messages_never_panics() {
683 let json = r#"{
684 "id": "sess-bad",
685 "messages": [],
686 "created_at": "2025-01-01T00:00:00Z",
687 "updated_at": "2025-01-01T00:00:00Z",
688 "model": "gpt-test",
689 "metadata": { "pending_injected_messages": "not-json{" }
690 }"#;
691 let session: Session = serde_json::from_str(json).unwrap();
692 assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
694 assert!(session.has_pending_injected_messages());
695 }
696
697 #[test]
698 fn divergent_skill_mode_and_mode_prefers_skill_mode() {
699 let json = r#"{
700 "id": "sess-div",
701 "messages": [],
702 "created_at": "2025-01-01T00:00:00Z",
703 "updated_at": "2025-01-01T00:00:00Z",
704 "model": "gpt-test",
705 "metadata": { "skill_mode": "code", "mode": "ask" }
706 }"#;
707 let session: Session = serde_json::from_str(json).unwrap();
708 assert_eq!(session.skill_mode().as_deref(), Some("code"));
709 }
710
711 #[test]
712 fn take_pending_clears_both_planes() {
713 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
714 let taken = session.take_pending_injected_messages().unwrap();
715 assert_eq!(taken.len(), 2);
716 assert!(!session.has_pending_injected_messages());
717 assert!(!session.metadata.contains_key("pending_injected_messages"));
718 assert!(session
719 .runtime_metadata
720 .as_ref()
721 .map(|m| m.pending_injected_messages.is_none())
722 .unwrap_or(true));
723 }
724
725 #[test]
726 fn empty_runtime_metadata_not_serialized() {
727 let session = Session::new("sess-empty", "gpt-test");
728 assert!(session.runtime_metadata.is_none());
729 let json = serde_json::to_string(&session).unwrap();
730 assert!(
731 !json.contains("runtime_metadata"),
732 "absent runtime_metadata must not serialize: {json}"
733 );
734 }
735}