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
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use serde_json::json;
337
338 const OLD_FORMAT_SESSION: &str = r#"{
344 "id": "sess-old",
345 "messages": [],
346 "created_at": "2025-01-01T00:00:00Z",
347 "updated_at": "2025-01-01T00:00:00Z",
348 "model": "gpt-test",
349 "metadata": {
350 "subagent_type": "researcher",
351 "last_run_status": "completed",
352 "provider_name": "openai",
353 "workspace_path": "/tmp/ws",
354 "pending_injected_messages": "[{\"content\":\"hello\"},{\"content\":\"world\"}]",
355 "selected_skill_ids": "[\"pdf\",\"web\"]",
356 "mode": "ask",
357 "task_list_version": "7",
358 "gold_config": "{\"goal\":\"x\"}",
359 "a2a.foo": "bar",
360 "responses.previous_response_id": "resp-123"
361 }
362 }"#;
363
364 #[test]
365 fn old_format_deserializes_and_typed_getters_fall_back() {
366 let session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
367 assert!(session.runtime_metadata.is_none());
369
370 assert_eq!(session.subagent_type().as_deref(), Some("researcher"));
372 assert_eq!(session.last_run_status().as_deref(), Some("completed"));
373 assert_eq!(session.provider_name().as_deref(), Some("openai"));
374 assert_eq!(session.workspace_path_meta().as_deref(), Some("/tmp/ws"));
375 assert_eq!(session.task_list_version_meta().as_deref(), Some("7"));
376
377 assert_eq!(session.skill_mode().as_deref(), Some("ask"));
379
380 let pending = session
382 .pending_injected_messages()
383 .expect("pending should decode");
384 assert_eq!(pending.len(), 2);
385 assert_eq!(pending[0]["content"], "hello");
386 assert_eq!(pending[1]["content"], "world");
387
388 let ids = session
390 .selected_skill_ids()
391 .expect("skill ids should decode");
392 assert_eq!(ids, vec!["pdf".to_string(), "web".to_string()]);
393 }
394
395 #[test]
396 fn setters_dual_write_both_planes() {
397 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
398
399 session.set_subagent_type("planner");
400 assert_eq!(
402 session
403 .runtime_metadata
404 .as_ref()
405 .and_then(|m| m.subagent_type.as_deref()),
406 Some("planner")
407 );
408 assert_eq!(
410 session.metadata.get("subagent_type").map(String::as_str),
411 Some("planner")
412 );
413
414 session.set_skill_mode("code");
415 assert_eq!(
416 session
417 .runtime_metadata
418 .as_ref()
419 .and_then(|m| m.skill_mode.as_deref()),
420 Some("code")
421 );
422 assert_eq!(
423 session.metadata.get("skill_mode").map(String::as_str),
424 Some("code")
425 );
426 assert_eq!(session.skill_mode().as_deref(), Some("code"));
428
429 session.set_pending_injected_messages(vec![json!({"content": "again"})]);
431 assert_eq!(
432 session
433 .runtime_metadata
434 .as_ref()
435 .and_then(|m| m.pending_injected_messages.as_ref())
436 .map(Vec::len),
437 Some(1)
438 );
439 let raw = session.metadata.get("pending_injected_messages").unwrap();
440 let decoded: Vec<serde_json::Value> = serde_json::from_str(raw).unwrap();
441 assert_eq!(decoded[0]["content"], "again");
442
443 session.set_selected_skill_ids(vec!["audio".to_string()]);
445 let raw = session.metadata.get("selected_skill_ids").unwrap();
446 let decoded: Vec<String> = serde_json::from_str(raw).unwrap();
447 assert_eq!(decoded, vec!["audio".to_string()]);
448 }
449
450 #[test]
451 fn round_trip_preserves_open_ended_and_typed_values() {
452 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
453 session.set_subagent_type("planner");
454 session.set_skill_mode("code");
455
456 let serialized = serde_json::to_string(&session).unwrap();
458 let restored: Session = serde_json::from_str(&serialized).unwrap();
459
460 assert_eq!(
462 restored.metadata.get("gold_config").map(String::as_str),
463 Some("{\"goal\":\"x\"}")
464 );
465 assert_eq!(
466 restored.metadata.get("a2a.foo").map(String::as_str),
467 Some("bar")
468 );
469 assert_eq!(
470 restored
471 .metadata
472 .get("responses.previous_response_id")
473 .map(String::as_str),
474 Some("resp-123")
475 );
476
477 assert!(restored.runtime_metadata.is_some());
479 assert_eq!(restored.subagent_type().as_deref(), Some("planner"));
480 assert_eq!(restored.skill_mode().as_deref(), Some("code"));
481 assert_eq!(
483 restored.metadata.get("subagent_type").map(String::as_str),
484 Some("planner")
485 );
486 }
487
488 #[test]
489 fn malformed_pending_injected_messages_never_panics() {
490 let json = r#"{
491 "id": "sess-bad",
492 "messages": [],
493 "created_at": "2025-01-01T00:00:00Z",
494 "updated_at": "2025-01-01T00:00:00Z",
495 "model": "gpt-test",
496 "metadata": { "pending_injected_messages": "not-json{" }
497 }"#;
498 let session: Session = serde_json::from_str(json).unwrap();
499 assert_eq!(session.pending_injected_messages(), Some(Vec::new()));
501 assert!(session.has_pending_injected_messages());
502 }
503
504 #[test]
505 fn divergent_skill_mode_and_mode_prefers_skill_mode() {
506 let json = r#"{
507 "id": "sess-div",
508 "messages": [],
509 "created_at": "2025-01-01T00:00:00Z",
510 "updated_at": "2025-01-01T00:00:00Z",
511 "model": "gpt-test",
512 "metadata": { "skill_mode": "code", "mode": "ask" }
513 }"#;
514 let session: Session = serde_json::from_str(json).unwrap();
515 assert_eq!(session.skill_mode().as_deref(), Some("code"));
516 }
517
518 #[test]
519 fn take_pending_clears_both_planes() {
520 let mut session: Session = serde_json::from_str(OLD_FORMAT_SESSION).unwrap();
521 let taken = session.take_pending_injected_messages().unwrap();
522 assert_eq!(taken.len(), 2);
523 assert!(!session.has_pending_injected_messages());
524 assert!(!session.metadata.contains_key("pending_injected_messages"));
525 assert!(session
526 .runtime_metadata
527 .as_ref()
528 .map(|m| m.pending_injected_messages.is_none())
529 .unwrap_or(true));
530 }
531
532 #[test]
533 fn empty_runtime_metadata_not_serialized() {
534 let session = Session::new("sess-empty", "gpt-test");
535 assert!(session.runtime_metadata.is_none());
536 let json = serde_json::to_string(&session).unwrap();
537 assert!(
538 !json.contains("runtime_metadata"),
539 "absent runtime_metadata must not serialize: {json}"
540 );
541 }
542}