1use super::runtime_metadata::{keys, SessionRuntimeMetadata};
16use super::types::Session;
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn project_id_meta(&self) -> Option<String> {
355 self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
356 }
357
358 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 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 assert!(session.runtime_metadata.is_none());
412
413 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 assert_eq!(session.skill_mode().as_deref(), Some("ask"));
426
427 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 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 assert_eq!(
449 session
450 .runtime_metadata
451 .as_ref()
452 .and_then(|m| m.subagent_type.as_deref()),
453 Some("planner")
454 );
455 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 assert_eq!(session.skill_mode().as_deref(), Some("code"));
475
476 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 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 let serialized = serde_json::to_string(&session).unwrap();
520 let restored: Session = serde_json::from_str(&serialized).unwrap();
521
522 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 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 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 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}