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 selected_skill_ids(&self) -> Option<Vec<String>> {
180 if let Some(ids) = self
181 .runtime_metadata
182 .as_ref()
183 .and_then(|m| m.selected_skill_ids.clone())
184 {
185 return Some(ids);
186 }
187 let raw = self.metadata.get(keys::SELECTED_SKILL_IDS)?;
188 serde_json::from_str::<Vec<String>>(raw).ok()
189 }
190
191 pub fn set_selected_skill_ids(&mut self, ids: Vec<String>) {
194 let serialized = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string());
195 self.runtime_metadata_mut().selected_skill_ids = Some(ids);
196 self.metadata
197 .insert(keys::SELECTED_SKILL_IDS.to_string(), serialized);
198 }
199
200 pub fn clear_selected_skill_ids(&mut self) {
201 if let Some(rm) = self.runtime_metadata.as_mut() {
202 rm.selected_skill_ids = None;
203 }
204 self.metadata.remove(keys::SELECTED_SKILL_IDS);
205 self.prune_runtime_metadata();
206 }
207
208 pub fn skill_mode(&self) -> Option<String> {
216 if let Some(mode) = self
217 .runtime_metadata
218 .as_ref()
219 .and_then(|m| m.skill_mode.clone())
220 {
221 return Some(mode);
222 }
223 let canonical = self.metadata.get(keys::SKILL_MODE);
224 let legacy = self.metadata.get(keys::SKILL_MODE_LEGACY);
225 if let (Some(canonical), Some(legacy)) = (canonical, legacy) {
226 if canonical != legacy {
227 tracing::warn!(
228 canonical = %canonical,
229 legacy = %legacy,
230 "session metadata has divergent skill_mode and legacy mode keys; preferring skill_mode"
231 );
232 }
233 }
234 canonical.or(legacy).cloned()
235 }
236
237 pub fn set_skill_mode(&mut self, value: impl Into<String>) {
240 let value = value.into();
241 self.runtime_metadata_mut().skill_mode = Some(value.clone());
242 self.metadata.insert(keys::SKILL_MODE.to_string(), value);
243 }
244
245 pub fn clear_skill_mode(&mut self) {
246 if let Some(rm) = self.runtime_metadata.as_mut() {
247 rm.skill_mode = None;
248 }
249 self.metadata.remove(keys::SKILL_MODE);
250 self.prune_runtime_metadata();
251 }
252
253 pub fn reasoning_effort_meta(&self) -> Option<String> {
258 self.runtime_str(|m| m.reasoning_effort.as_ref(), keys::REASONING_EFFORT)
259 }
260
261 pub fn set_reasoning_effort_meta(&mut self, value: impl Into<String>) {
262 let value = value.into();
263 self.runtime_metadata_mut().reasoning_effort = Some(value.clone());
264 self.metadata
265 .insert(keys::REASONING_EFFORT.to_string(), value);
266 }
267
268 pub fn enhance_prompt(&self) -> Option<String> {
273 self.runtime_str(|m| m.enhance_prompt.as_ref(), keys::ENHANCE_PROMPT)
274 }
275
276 pub fn set_enhance_prompt(&mut self, value: impl Into<String>) {
277 let value = value.into();
278 self.runtime_metadata_mut().enhance_prompt = Some(value.clone());
279 self.metadata
280 .insert(keys::ENHANCE_PROMPT.to_string(), value);
281 }
282
283 pub fn clear_enhance_prompt(&mut self) {
284 if let Some(rm) = self.runtime_metadata.as_mut() {
285 rm.enhance_prompt = None;
286 }
287 self.metadata.remove(keys::ENHANCE_PROMPT);
288 self.prune_runtime_metadata();
289 }
290
291 pub fn task_list_version_meta(&self) -> Option<String> {
296 self.runtime_str(|m| m.task_list_version.as_ref(), keys::TASK_LIST_VERSION)
297 }
298
299 pub fn set_task_list_version_meta(&mut self, value: impl Into<String>) {
300 let value = value.into();
301 self.runtime_metadata_mut().task_list_version = Some(value.clone());
302 self.metadata
303 .insert(keys::TASK_LIST_VERSION.to_string(), value);
304 }
305
306 pub fn todo_list_version_meta(&self) -> Option<String> {
307 self.runtime_str(|m| m.todo_list_version.as_ref(), keys::TODO_LIST_VERSION)
308 }
309
310 pub fn set_todo_list_version_meta(&mut self, value: impl Into<String>) {
311 let value = value.into();
312 self.runtime_metadata_mut().todo_list_version = Some(value.clone());
313 self.metadata
314 .insert(keys::TODO_LIST_VERSION.to_string(), value);
315 }
316
317 pub fn workspace_path_meta(&self) -> Option<String> {
322 self.runtime_str(|m| m.workspace_path.as_ref(), keys::WORKSPACE_PATH)
323 }
324
325 pub fn set_workspace_path_meta(&mut self, value: impl Into<String>) {
326 let value = value.into();
327 self.runtime_metadata_mut().workspace_path = Some(value.clone());
328 self.metadata
329 .insert(keys::WORKSPACE_PATH.to_string(), value);
330 }
331
332 pub fn project_id_meta(&self) -> Option<String> {
339 self.runtime_str(|m| m.project_id.as_ref(), keys::PROJECT_ID)
340 }
341
342 pub fn set_project_id_meta(&mut self, value: impl Into<String>) {
345 let value = value.into();
346 self.runtime_metadata_mut().project_id = Some(value.clone());
347 self.metadata.insert(keys::PROJECT_ID.to_string(), value);
348 }
349
350 pub fn clear_project_id_meta(&mut self) {
351 if let Some(runtime_metadata) = self.runtime_metadata.as_mut() {
352 runtime_metadata.project_id = None;
353 }
354 self.metadata.remove(keys::PROJECT_ID);
355 self.prune_runtime_metadata();
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use serde_json::json;
363
364 const OLD_FORMAT_SESSION: &str = r#"{
370 "id": "sess-old",
371 "messages": [],
372 "created_at": "2025-01-01T00:00:00Z",
373 "updated_at": "2025-01-01T00:00:00Z",
374 "model": "gpt-test",
375 "metadata": {
376 "subagent_type": "researcher",
377 "last_run_status": "completed",
378 "provider_name": "openai",
379 "workspace_path": "/tmp/ws",
380 "project_id": "01JLEGACYPROJECT000000000000",
381 "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
382 "selected_skill_ids": "[\"pdf\",\"web\"]",
383 "mode": "ask",
384 "task_list_version": "7",
385 "gold_config": "{\"goal\":\"x\"}",
386 "a2a.foo": "bar",
387 "responses.previous_response_id": "resp-123"
388 }
389 }"#;
390
391 #[test]
392 fn old_format_deserializes_and_typed_getters_fall_back() {
393 let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
394 assert!(session.runtime_metadata.is_none());
396
397 assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
399 assert_eq!(session.last_run_status().as_deref(), Some("completed"));
400 assert_eq!(session.provider_name().as_deref(), Some("openai"));
401 assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
402 assert_eq!(
403 session.project_id_meta().as_deref(),
404 Some("01JLEGACYPROJECT000000000000")
405 );
406 assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
407
408 assert_eq!(session.skill_mode().as_deref(), Some("ask"));
410
411 let pending = session
413 .pending_injected_messages()
414 .expect("pending should decode");
415 assert_eq!(pending.len(), 2);
416 assert_eq!(pending[0]["content"], "hello");
417 assert_eq!(pending[1]["content"], "world");
418
419 let ids = session
421 .selected_skill_ids()
422 .expect("skill ids should decode");
423 assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
424 }
425
426 #[test]
427 fn setters_dual_write_both_planes() {
428 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
429
430 session.set_subagent_type("planner");
431 assert_eq!(
433 session
434 .runtime_metadata
435 .as_ref()
436 .and_then(|m| m.subagent_type.as_deref()),
437 Some("planner")
438 );
439 assert_eq!(
441 session.metadata.get("subagent_type").map(String::as_str),
442 Some("planner")
443 );
444
445 session.set_skill_mode("code");
446 assert_eq!(
447 session
448 .runtime_metadata
449 .as_ref()
450 .and_then(|m| m.skill_mode.as_deref()),
451 Some("code")
452 );
453 assert_eq!(
454 session.metadata.get("skill_mode").map(String::as_str),
455 Some("code")
456 );
457 assert_eq!(session.skill_mode().as_deref(), Some("code"));
459
460 session.set_pending_injected_messages(vec![json!({"content": "again"})]);
462 assert_eq!(
463 session
464 .runtime_metadata
465 .as_ref()
466 .and_then(|m| m.pending_injected_messages.as_ref())
467 .map(Vec::len),
468 Some(1)
469 );
470 let raw = session.metadata.get("pending_injected_messages").unwrap();
471 let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
472 assert_eq!(decoded[0]["content"], "again");
473
474 session.set_selected_skill_ids(vec!["audio".to_string()]);
476 let raw = session.metadata.get("selected_skill_ids").unwrap();
477 let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
478 assert_eq!(decoded, vec!["audio".to_string()]);
479
480 session.set_project_id_meta("01JNEWPROJECT000000000000000");
481 assert_eq!(
482 session
483 .runtime_metadata
484 .as_ref()
485 .and_then(|metadata| metadata.project_id.as_deref()),
486 Some("01JNEWPROJECT000000000000000")
487 );
488 assert_eq!(
489 session.metadata.get("project_id").map(String::as_str),
490 Some("01JNEWPROJECT000000000000000")
491 );
492 session.clear_project_id_meta();
493 assert!(session.project_id_meta().is_none());
494 }
495
496 #[test]
497 fn round_trip_preserves_open_ended_and_typed_values() {
498 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
499 session.set_subagent_type("planner");
500 session.set_skill_mode("code");
501
502 let serialized = serde_json::to_string(&session).unwrap();
504 let restored: Session = serde_json::from_str(&serialized).unwrap();
505
506 assert_eq!(
508 restored.metadata.get("gold_config").map(String::as_str),
509 Some("{\"goal\":\"x\"}")
510 );
511 assert_eq!(
512 restored.metadata.get("a2a.foo").map(String::as_str),
513 Some("bar")
514 );
515 assert_eq!(
516 restored
517 .metadata
518 .get("responses.previous_response_id")
519 .map(String::as_str),
520 Some("resp-123")
521 );
522
523 assert!(restored.runtime_metadata.is_some());
525 assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
526 assert_eq!(restored.skill_mode().as_deref(), Some("code"));
527 assert_eq!(
529 restored.metadata.get("subagent_type").map(String::as_str),
530 Some("planner")
531 );
532 }
533
534 #[test]
535 fn malformed_pending_injected_messages_never_panics() {
536 let json = r#"{
537 "id": "sess-bad",
538 "messages": [],
539 "created_at": "2025-01-01T00:00:00Z",
540 "updated_at": "2025-01-01T00:00:00Z",
541 "model": "gpt-test",
542 "metadata": { "pending_injected_messages": "not-json{" }
543 }"#;
544 let session: Session = serde_json::from_str(json).unwrap();
545 assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
547 assert!(session.has_pending_injected_messages());
548 }
549
550 #[test]
551 fn divergent_skill_mode_and_mode_prefers_skill_mode() {
552 let json = r#"{
553 "id": "sess-div",
554 "messages": [],
555 "created_at": "2025-01-01T00:00:00Z",
556 "updated_at": "2025-01-01T00:00:00Z",
557 "model": "gpt-test",
558 "metadata": { "skill_mode": "code", "mode": "ask" }
559 }"#;
560 let session: Session = serde_json::from_str(json).unwrap();
561 assert_eq!(session.skill_mode().as_deref(), Some("code"));
562 }
563
564 #[test]
565 fn take_pending_clears_both_planes() {
566 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
567 let taken = session.take_pending_injected_messages().unwrap();
568 assert_eq!(taken.len(), 2);
569 assert!(!session.has_pending_injected_messages());
570 assert!(!session.metadata.contains_key("pending_injected_messages"));
571 assert!(session
572 .runtime_metadata
573 .as_ref()
574 .map(|m| m.pending_injected_messages.is_none())
575 .unwrap_or(true));
576 }
577
578 #[test]
579 fn empty_runtime_metadata_not_serialized() {
580 let session = Session::new("sess-empty", "gpt-test");
581 assert!(session.runtime_metadata.is_none());
582 let json = serde_json::to_string(&session).unwrap();
583 assert!(
584 !json.contains("runtime_metadata"),
585 "absent runtime_metadata must not serialize: {json}"
586 );
587 }
588}