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