Skip to main content

bamboo_server/app_state/
session_loader.rs

1//! Unified session loading helpers on AppState.
2//!
3//! Consolidates the three session loading patterns previously duplicated across handlers:
4//!
5//! - **`load_session`** (strict): memory → storage, returns `Option`
6//! - **`load_or_create_session`**: memory → storage → create new
7//! - **`load_session_merged`**: merges memory + storage with `should_prefer_storage` heuristic
8//! - **`save_and_cache_session`**: dual write (persist + memory cache)
9//!
10//! Also provides the `SessionAccess` trait implementation for `AppState`,
11//! bridging the application-layer use cases to the server infrastructure.
12
13use super::*;
14
15use bamboo_agent_core::Session;
16use bamboo_engine::session_app::errors::{RespondError, SessionLoadError, SessionSaveError};
17use bamboo_engine::session_app::repository::SessionAccess;
18
19// `SessionAccess` for `AppState` is a pure forward to the framework-owned
20// `session_repo` (the canonical `SessionAccess` impl lives on
21// `bamboo_engine::SessionRepository`). The coordination + error mapping is
22// defined once there, not duplicated here.
23#[async_trait::async_trait]
24impl SessionAccess for AppState {
25    async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
26        SessionAccess::load_session(&self.session_repo, id).await
27    }
28
29    async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
30        SessionAccess::load_or_create(&self.session_repo, id, model).await
31    }
32
33    async fn load_merged(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
34        SessionAccess::load_merged(&self.session_repo, id).await
35    }
36
37    async fn save_session(&self, session: &mut Session) -> Result<(), SessionSaveError> {
38        SessionAccess::save_session(&self.session_repo, session).await
39    }
40
41    async fn save_and_cache(&self, session: &mut Session) -> Result<(), SessionSaveError> {
42        SessionAccess::save_and_cache(&self.session_repo, session).await
43    }
44
45    async fn inspect_for_response(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
46        SessionAccess::inspect_for_response(&self.session_repo, id).await
47    }
48
49    async fn mutate_for_response(
50        &self,
51        id: &str,
52        mutate: Box<
53            dyn for<'session> FnOnce(&'session mut Session) -> Result<(), RespondError>
54                + Send
55                + 'static,
56        >,
57    ) -> Result<Option<Session>, RespondError> {
58        SessionAccess::mutate_for_response(&self.session_repo, id, mutate).await
59    }
60}
61
62// The canonical load/save coordination now lives in
63// `bamboo_engine::SessionRepository`. These inherent methods are kept as thin
64// delegations so the ~hundreds of `state.load_session(...)` call sites stay
65// unchanged; new code can take a `&SessionRepository` directly.
66impl AppState {
67    /// Load a session from memory cache, falling back to persistent storage.
68    pub async fn load_session(&self, session_id: &str) -> Option<bamboo_agent_core::Session> {
69        self.session_repo.load(session_id).await
70    }
71
72    /// Load a session, creating a new one if it doesn't exist.
73    pub async fn load_or_create_session(
74        &self,
75        session_id: &str,
76        model: &str,
77    ) -> bamboo_agent_core::Session {
78        self.session_repo.load_or_create(session_id, model).await
79    }
80
81    /// Load a session, merging memory and storage using a preference heuristic.
82    pub async fn load_session_merged(
83        &self,
84        session_id: &str,
85    ) -> Option<bamboo_agent_core::Session> {
86        self.session_repo.load_merged(session_id).await
87    }
88
89    /// Persist session to storage and update the in-memory cache.
90    pub async fn save_and_cache_session(&self, session: &mut bamboo_agent_core::Session) {
91        self.session_repo.save_and_cache(session).await
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[tokio::test]
100    async fn load_session_returns_from_memory_first() {
101        let temp_dir = tempfile::tempdir().expect("temp dir");
102        let state = AppState::new(temp_dir.path().to_path_buf())
103            .await
104            .expect("app state");
105
106        let session_id = "session-memory-first";
107        let session = bamboo_agent_core::Session::new(session_id.to_string(), "test-model");
108
109        // Seed memory cache.
110        state.sessions.insert(
111            session_id.to_string(),
112            Arc::new(bamboo_engine::SessionSnapshot::new(session.clone())),
113        );
114
115        let loaded = state.load_session(session_id).await;
116        assert!(loaded.is_some());
117        assert_eq!(loaded.unwrap().id, session_id);
118    }
119
120    #[tokio::test]
121    async fn load_session_falls_back_to_storage() {
122        let temp_dir = tempfile::tempdir().expect("temp dir");
123        let state = AppState::new(temp_dir.path().to_path_buf())
124            .await
125            .expect("app state");
126
127        let session_id = "session-storage-fallback";
128        let session = bamboo_agent_core::Session::new(session_id.to_string(), "test-model");
129
130        // Seed storage only.
131        state
132            .storage
133            .save_session(&session)
134            .await
135            .expect("save session");
136
137        let loaded = state.load_session(session_id).await;
138        assert!(loaded.is_some());
139        assert_eq!(loaded.unwrap().id, session_id);
140    }
141
142    #[tokio::test]
143    async fn load_session_returns_none_when_missing() {
144        let temp_dir = tempfile::tempdir().expect("temp dir");
145        let state = AppState::new(temp_dir.path().to_path_buf())
146            .await
147            .expect("app state");
148
149        let loaded = state.load_session("nonexistent").await;
150        assert!(loaded.is_none());
151    }
152
153    #[tokio::test]
154    async fn load_or_create_creates_new_when_missing() {
155        let temp_dir = tempfile::tempdir().expect("temp dir");
156        let state = AppState::new(temp_dir.path().to_path_buf())
157            .await
158            .expect("app state");
159
160        let session = state.load_or_create_session("new-session", "gpt-4").await;
161        assert_eq!(session.id, "new-session");
162        assert_eq!(session.model, "gpt-4");
163    }
164
165    #[tokio::test]
166    async fn load_session_merged_prefers_storage_with_pending_question() {
167        let temp_dir = tempfile::tempdir().expect("temp dir");
168        let state = AppState::new(temp_dir.path().to_path_buf())
169            .await
170            .expect("app state");
171
172        let session_id = "session-merge-pending";
173        let memory_session = bamboo_agent_core::Session::new(session_id.to_string(), "test-model");
174        let mut storage_session = memory_session.clone();
175        storage_session.set_pending_question(
176            "tool-call-1".to_string(),
177            "ConclusionWithOptions".to_string(),
178            "Need confirmation?".to_string(),
179            vec!["OK".to_string()],
180            true,
181        );
182
183        state.sessions.insert(
184            session_id.to_string(),
185            Arc::new(bamboo_engine::SessionSnapshot::new(memory_session)),
186        );
187        state
188            .storage
189            .save_session(&storage_session)
190            .await
191            .expect("save session");
192
193        let loaded = state.load_session_merged(session_id).await;
194        assert!(loaded.is_some());
195        assert!(loaded.unwrap().pending_question.is_some());
196    }
197
198    #[tokio::test]
199    async fn save_and_cache_session_writes_both() {
200        let temp_dir = tempfile::tempdir().expect("temp dir");
201        let state = AppState::new(temp_dir.path().to_path_buf())
202            .await
203            .expect("app state");
204
205        let session_id = "session-save-cache";
206        let mut session = bamboo_agent_core::Session::new(session_id.to_string(), "test-model");
207        session.title = "test-title".to_string();
208
209        state.save_and_cache_session(&mut session).await;
210
211        // Verify memory cache.
212        let cached = { bamboo_engine::read_cached_session(&state.sessions, session_id) };
213        assert!(cached.is_some());
214        assert_eq!(cached.unwrap().title, "test-title");
215
216        // Verify storage.
217        let loaded = state.storage.load_session(session_id).await;
218        assert!(loaded.is_ok());
219        assert_eq!(loaded.unwrap().unwrap().title, "test-title");
220    }
221}