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 clear_reasoning_effort_meta(&mut self) {
335 if let Some(metadata) = self.runtime_metadata.as_mut() {
336 metadata.reasoning_effort = None;
337 }
338 self.metadata.remove(keys::REASONING_EFFORT);
339 self.prune_runtime_metadata();
340 }
341
342 pub fn enhance_prompt(&self) -> Option<String> {
347 self.runtime_str(|m| m.enhance_prompt.as_ref(), keys::ENHANCE_PROMPT)
348 }
349
350 pub fn set_enhance_prompt(&mut self, value: impl Into<String>) {
351 let value = value.into();
352 self.runtime_metadata_mut().enhance_prompt = Some(value.clone());
353 self.metadata
354 .insert(keys::ENHANCE_PROMPT.to_string(), value);
355 }
356
357 pub fn clear_enhance_prompt(&mut self) {
358 if let Some(rm) = self.runtime_metadata.as_mut() {
359 rm.enhance_prompt = None;
360 }
361 self.metadata.remove(keys::ENHANCE_PROMPT);
362 self.prune_runtime_metadata();
363 }
364
365 pub fn root_orchestration_prompt_enabled(&self) -> bool {
372 self.kind == SessionKind::Root
373 && self.parent_session_id.is_none()
374 && self
375 .runtime_metadata
376 .as_ref()
377 .and_then(|metadata| metadata.root_orchestration_prompt)
378 .or_else(|| {
379 self.metadata
380 .get(keys::ROOT_ORCHESTRATION_PROMPT)
381 .and_then(|value| value.parse::<bool>().ok())
382 })
383 .unwrap_or(false)
384 }
385
386 pub fn set_root_orchestration_prompt_enabled(&mut self, enabled: bool) {
389 if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
390 return;
391 }
392 let changed = self.root_orchestration_prompt_enabled() != enabled;
393 if enabled {
394 self.runtime_metadata_mut().root_orchestration_prompt = Some(true);
395 self.metadata.insert(
396 keys::ROOT_ORCHESTRATION_PROMPT.to_string(),
397 "true".to_string(),
398 );
399 } else {
400 if let Some(metadata) = self.runtime_metadata.as_mut() {
401 metadata.root_orchestration_prompt = None;
402 }
403 self.metadata.remove(keys::ROOT_ORCHESTRATION_PROMPT);
404 self.prune_runtime_metadata();
405 }
406 if changed && self.model_context_state.is_some() {
410 self.reset_model_context_epoch(
411 super::model_context::ModelContextResetReason::CacheScopeChanged,
412 );
413 }
414 }
415
416 pub fn task_list_version_meta(&self) -> Option<String> {
421 self.runtime_str(|m| m.task_list_version.as_ref(), keys::TASK_LIST_VERSION)
422 }
423
424 pub fn set_task_list_version_meta(&mut self, value: impl Into<String>) {
425 let value = value.into();
426 self.runtime_metadata_mut().task_list_version = Some(value.clone());
427 self.metadata
428 .insert(keys::TASK_LIST_VERSION.to_string(), value);
429 }
430
431 pub fn todo_list_version_meta(&self) -> Option<String> {
432 self.runtime_str(|m| m.todo_list_version.as_ref(), keys::TODO_LIST_VERSION)
433 }
434
435 pub fn set_todo_list_version_meta(&mut self, value: impl Into<String>) {
436 let value = value.into();
437 self.runtime_metadata_mut().todo_list_version = Some(value.clone());
438 self.metadata
439 .insert(keys::TODO_LIST_VERSION.to_string(), value);
440 }
441
442 pub fn workspace_path_meta(&self) -> Option<String> {
447 self.runtime_str(|m| m.workspace_path.as_ref(), keys::WORKSPACE_PATH)
448 }
449
450 pub fn set_workspace_path_meta(&mut self, value: impl Into<String>) {
451 let value = value.into();
452 self.runtime_metadata_mut().workspace_path = Some(value.clone());
453 self.metadata
454 .insert(keys::WORKSPACE_PATH.to_string(), value);
455 }
456
457 pub fn project_id_meta(&self) -> Option<String> {
464 self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
465 }
466
467 pub fn set_project_id_meta(&mut self, value: impl Into<String>) {
470 let value = value.into();
471 self.runtime_metadata_mut().project_id = Some(value.clone());
472 self.metadata.insert(keys::PROJECT_ID.to_string(), value);
473 }
474
475 pub fn clear_project_id_meta(&mut self) {
476 if let Some(runtime_metadata) = self.runtime_metadata.as_mut() {
477 runtime_metadata.project_id = None;
478 }
479 self.metadata.remove(keys::PROJECT_ID);
480 self.prune_runtime_metadata();
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use serde_json::json;
488
489 #[test]
490 fn root_orchestration_selection_survives_reload_but_never_applies_to_child() {
491 let mut root = Session::new("root-orchestration", "model");
492 assert!(!root.root_orchestration_prompt_enabled());
493 root.set_root_orchestration_prompt_enabled(true);
494 let stored = serde_json::to_string(&root).expect("serialize root");
495 let mut reloaded: Session = serde_json::from_str(&stored).expect("reload root");
496 assert!(reloaded.root_orchestration_prompt_enabled());
497 assert_eq!(
498 reloaded
499 .metadata
500 .get(keys::ROOT_ORCHESTRATION_PROMPT)
501 .map(String::as_str),
502 Some("true")
503 );
504
505 let mut child = Session::new_child_of("child", &reloaded, "model", "child");
506 child.set_root_orchestration_prompt_enabled(true);
507 assert!(!child.root_orchestration_prompt_enabled());
508
509 reloaded.set_root_orchestration_prompt_enabled(false);
510 assert!(!reloaded.root_orchestration_prompt_enabled());
511 assert!(!reloaded
512 .metadata
513 .contains_key(keys::ROOT_ORCHESTRATION_PROMPT));
514 assert!(reloaded
515 .runtime_metadata
516 .as_ref()
517 .and_then(|metadata| metadata.root_orchestration_prompt)
518 .is_none());
519 }
520
521 const OLD_FORMAT_SESSION: &str = r#"{
527 "id": "sess-old",
528 "messages": [],
529 "created_at": "2025-01-01T00:00:00Z",
530 "updated_at": "2025-01-01T00:00:00Z",
531 "model": "gpt-test",
532 "metadata": {
533 "subagent_type": "researcher",
534 "last_run_status": "completed",
535 "provider_name": "openai",
536 "workspace_path": "/tmp/ws",
537 "project_id": "01JLEGACYPROJECT000000000000",
538 "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
539 "selected_skill_ids": "[\"pdf\",\"web\"]",
540 "mode": "ask",
541 "task_list_version": "7",
542 "gold_config": "{\"goal\":\"x\"}",
543 "a2a.foo": "bar",
544 "responses.previous_response_id": "resp-123"
545 }
546 }"#;
547
548 #[test]
549 fn old_format_deserializes_and_typed_getters_fall_back() {
550 let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
551 assert!(session.runtime_metadata.is_none());
553
554 assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
556 assert_eq!(session.last_run_status().as_deref(), Some("completed"));
557 assert_eq!(session.provider_name().as_deref(), Some("openai"));
558 assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
559 assert_eq!(
560 session.project_id_meta().as_deref(),
561 Some("01JLEGACYPROJECT000000000000")
562 );
563 assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
564
565 assert_eq!(session.skill_mode().as_deref(), Some("ask"));
567
568 let pending = session
570 .pending_injected_messages()
571 .expect("pending should decode");
572 assert_eq!(pending.len(), 2);
573 assert_eq!(pending[0]["content"], "hello");
574 assert_eq!(pending[1]["content"], "world");
575
576 let ids = session
578 .selected_skill_ids()
579 .expect("skill ids should decode");
580 assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
581 }
582
583 #[test]
584 fn setters_dual_write_both_planes() {
585 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
586
587 session.set_subagent_type("planner");
588 assert_eq!(
590 session
591 .runtime_metadata
592 .as_ref()
593 .and_then(|m| m.subagent_type.as_deref()),
594 Some("planner")
595 );
596 assert_eq!(
598 session.metadata.get("subagent_type").map(String::as_str),
599 Some("planner")
600 );
601
602 session.set_skill_mode("code");
603 assert_eq!(
604 session
605 .runtime_metadata
606 .as_ref()
607 .and_then(|m| m.skill_mode.as_deref()),
608 Some("code")
609 );
610 assert_eq!(
611 session.metadata.get("skill_mode").map(String::as_str),
612 Some("code")
613 );
614 assert_eq!(session.skill_mode().as_deref(), Some("code"));
616
617 session.set_pending_injected_messages(vec![json!({"content": "again"})]);
619 assert_eq!(
620 session
621 .runtime_metadata
622 .as_ref()
623 .and_then(|m| m.pending_injected_messages.as_ref())
624 .map(Vec::len),
625 Some(1)
626 );
627 let raw = session.metadata.get("pending_injected_messages").unwrap();
628 let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
629 assert_eq!(decoded[0]["content"], "again");
630
631 session.set_selected_skill_ids(vec!["audio".to_string()]);
633 let raw = session.metadata.get("selected_skill_ids").unwrap();
634 let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
635 assert_eq!(decoded, vec!["audio".to_string()]);
636
637 session.set_project_id_meta("01JNEWPROJECT000000000000000");
638 assert_eq!(
639 session
640 .runtime_metadata
641 .as_ref()
642 .and_then(|metadata| metadata.project_id.as_deref()),
643 Some("01JNEWPROJECT000000000000000")
644 );
645 assert_eq!(
646 session.metadata.get("project_id").map(String::as_str),
647 Some("01JNEWPROJECT000000000000000")
648 );
649 session.clear_project_id_meta();
650 assert!(session.project_id_meta().is_none());
651 }
652
653 #[test]
654 fn round_trip_preserves_open_ended_and_typed_values() {
655 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
656 session.set_subagent_type("planner");
657 session.set_skill_mode("code");
658
659 let serialized = serde_json::to_string(&session).unwrap();
661 let restored: Session = serde_json::from_str(&serialized).unwrap();
662
663 assert_eq!(
665 restored.metadata.get("gold_config").map(String::as_str),
666 Some("{\"goal\":\"x\"}")
667 );
668 assert_eq!(
669 restored.metadata.get("a2a.foo").map(String::as_str),
670 Some("bar")
671 );
672 assert_eq!(
673 restored
674 .metadata
675 .get("responses.previous_response_id")
676 .map(String::as_str),
677 Some("resp-123")
678 );
679
680 assert!(restored.runtime_metadata.is_some());
682 assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
683 assert_eq!(restored.skill_mode().as_deref(), Some("code"));
684 assert_eq!(
686 restored.metadata.get("subagent_type").map(String::as_str),
687 Some("planner")
688 );
689 }
690
691 #[test]
692 fn malformed_pending_injected_messages_never_panics() {
693 let json = r#"{
694 "id": "sess-bad",
695 "messages": [],
696 "created_at": "2025-01-01T00:00:00Z",
697 "updated_at": "2025-01-01T00:00:00Z",
698 "model": "gpt-test",
699 "metadata": { "pending_injected_messages": "not-json{" }
700 }"#;
701 let session: Session = serde_json::from_str(json).unwrap();
702 assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
704 assert!(session.has_pending_injected_messages());
705 }
706
707 #[test]
708 fn divergent_skill_mode_and_mode_prefers_skill_mode() {
709 let json = r#"{
710 "id": "sess-div",
711 "messages": [],
712 "created_at": "2025-01-01T00:00:00Z",
713 "updated_at": "2025-01-01T00:00:00Z",
714 "model": "gpt-test",
715 "metadata": { "skill_mode": "code", "mode": "ask" }
716 }"#;
717 let session: Session = serde_json::from_str(json).unwrap();
718 assert_eq!(session.skill_mode().as_deref(), Some("code"));
719 }
720
721 #[test]
722 fn take_pending_clears_both_planes() {
723 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
724 let taken = session.take_pending_injected_messages().unwrap();
725 assert_eq!(taken.len(), 2);
726 assert!(!session.has_pending_injected_messages());
727 assert!(!session.metadata.contains_key("pending_injected_messages"));
728 assert!(session
729 .runtime_metadata
730 .as_ref()
731 .map(|m| m.pending_injected_messages.is_none())
732 .unwrap_or(true));
733 }
734
735 #[test]
736 fn empty_runtime_metadata_not_serialized() {
737 let session = Session::new("sess-empty", "gpt-test");
738 assert!(session.runtime_metadata.is_none());
739 let json = serde_json::to_string(&session).unwrap();
740 assert!(
741 !json.contains("runtime_metadata"),
742 "absent runtime_metadata must not serialize: {json}"
743 );
744 }
745}