Skip to main content

bamboo_engine/session_app/
session_create.rs

1//! Session creation use case.
2//!
3//! Pure business logic for constructing a new session from request
4//! parameters and configuration defaults. The handler builds the
5//! value types from HTTP request / AppState, then delegates here.
6
7use bamboo_agent_core::{Message, Session};
8use bamboo_domain::reasoning::ReasoningEffort;
9use bamboo_domain::ProviderModelRef;
10
11use super::provider_model::{persist_legacy_model_provider, persist_model_ref};
12use crate::model_config_helper::GOLD_CONFIG_METADATA_KEY;
13
14/// Request-level input for session creation.
15pub struct CreateSessionInput {
16    pub id: String,
17    pub title: Option<String>,
18    pub system_prompt: Option<String>,
19    pub model: Option<String>,
20    pub model_ref: Option<ProviderModelRef>,
21    pub reasoning_effort: Option<ReasoningEffort>,
22    pub gold_config_json: Option<String>,
23}
24
25/// Configuration defaults for session creation.
26///
27/// Captured from server `Config` as plain values so the crate stays
28/// decoupled from `bamboo-infrastructure-config`.
29pub struct CreateSessionConfig {
30    pub default_model: Option<String>,
31    pub default_reasoning_effort: Option<ReasoningEffort>,
32    pub global_default_prompt: String,
33    pub builtin_fallback_prompt: &'static str,
34}
35
36/// Build a new session from request input and config defaults.
37pub fn build_new_session(input: &CreateSessionInput, config: &CreateSessionConfig) -> Session {
38    let model = input
39        .model_ref
40        .as_ref()
41        .map(|model_ref| model_ref.model.clone())
42        .unwrap_or_else(|| resolve_model(input.model.as_deref(), config.default_model.as_deref()));
43    let mut session = Session::new(input.id.clone(), model);
44    if let Some(model_ref) = input.model_ref.as_ref() {
45        persist_model_ref(&mut session, model_ref);
46    } else {
47        persist_legacy_model_provider(&mut session, input.model.as_deref(), None);
48    }
49    session.reasoning_effort =
50        resolve_reasoning_effort(input.reasoning_effort, config.default_reasoning_effort);
51    if let Some(gold_config_json) = trimmed_non_empty(input.gold_config_json.as_deref()) {
52        session
53            .metadata
54            .insert(GOLD_CONFIG_METADATA_KEY.to_string(), gold_config_json);
55    }
56
57    if let Some(title) = trimmed_non_empty(input.title.as_deref()) {
58        session.title = title;
59    }
60    let explicit_prompt = trimmed_non_empty(input.system_prompt.as_deref());
61    let has_explicit_prompt = explicit_prompt.is_some();
62    let base_prompt = explicit_prompt.unwrap_or_else(|| {
63        let trimmed = config.global_default_prompt.trim();
64        if trimmed.is_empty() {
65            config.builtin_fallback_prompt.to_string()
66        } else {
67            trimmed.to_string()
68        }
69    });
70    session
71        .metadata
72        .insert("base_system_prompt".to_string(), base_prompt.clone());
73
74    if has_explicit_prompt {
75        session.add_message(Message::system(base_prompt));
76        crate::runner::refresh_prompt_snapshot(&mut session);
77    }
78
79    session
80}
81
82/// Resolve the model from request → config → fallback.
83pub fn resolve_model(request_model: Option<&str>, config_model: Option<&str>) -> String {
84    trimmed_non_empty(request_model)
85        .or_else(|| config_model.map(ToString::to_string))
86        .unwrap_or_else(|| "unknown".to_string())
87}
88
89/// Resolve reasoning effort from request → config.
90pub fn resolve_reasoning_effort(
91    request_effort: Option<ReasoningEffort>,
92    config_effort: Option<ReasoningEffort>,
93) -> Option<ReasoningEffort> {
94    request_effort.or(config_effort)
95}
96
97/// Trim whitespace and return `None` for empty strings.
98pub fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
99    value
100        .map(str::trim)
101        .filter(|value| !value.is_empty())
102        .map(ToString::to_string)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    const BUILTIN_FALLBACK: &str = "You are a helpful assistant.";
110
111    fn default_config() -> CreateSessionConfig {
112        CreateSessionConfig {
113            default_model: None,
114            default_reasoning_effort: None,
115            global_default_prompt: "Global fallback".to_string(),
116            builtin_fallback_prompt: BUILTIN_FALLBACK,
117        }
118    }
119
120    #[test]
121    fn resolve_model_uses_request_model_when_present() {
122        assert_eq!(resolve_model(Some("  gpt-5  "), None), "gpt-5");
123    }
124
125    #[test]
126    fn resolve_model_falls_back_to_config() {
127        assert_eq!(resolve_model(None, Some("gpt-4")), "gpt-4");
128    }
129
130    #[test]
131    fn resolve_model_falls_back_to_unknown() {
132        assert_eq!(resolve_model(None, None), "unknown");
133    }
134
135    #[test]
136    fn resolve_model_ignores_blank_request() {
137        assert_eq!(resolve_model(Some("   "), Some("gpt-4")), "gpt-4");
138    }
139
140    #[test]
141    fn resolve_reasoning_effort_prefers_request() {
142        assert_eq!(
143            resolve_reasoning_effort(Some(ReasoningEffort::High), Some(ReasoningEffort::Low)),
144            Some(ReasoningEffort::High)
145        );
146    }
147
148    #[test]
149    fn resolve_reasoning_effort_falls_back_to_config() {
150        assert_eq!(
151            resolve_reasoning_effort(None, Some(ReasoningEffort::Medium)),
152            Some(ReasoningEffort::Medium)
153        );
154    }
155
156    #[test]
157    fn build_new_session_applies_title_and_system_prompt() {
158        let input = CreateSessionInput {
159            id: "session-1".to_string(),
160            title: Some("  Sprint Session  ".to_string()),
161            system_prompt: Some("  You are helpful  ".to_string()),
162            model: Some("gpt-5".to_string()),
163            model_ref: None,
164            reasoning_effort: Some(ReasoningEffort::High),
165            gold_config_json: None,
166        };
167        let session = build_new_session(&input, &default_config());
168
169        assert_eq!(session.title, "Sprint Session");
170        assert_eq!(
171            session
172                .metadata
173                .get("base_system_prompt")
174                .map(String::as_str),
175            Some("You are helpful")
176        );
177        assert_eq!(session.reasoning_effort, Some(ReasoningEffort::High));
178        assert_eq!(
179            session.messages.first().map(|m| m.content.as_str()),
180            Some("You are helpful")
181        );
182    }
183
184    #[test]
185    fn build_new_session_uses_global_default_when_no_explicit_prompt() {
186        let input = CreateSessionInput {
187            id: "session-2".to_string(),
188            title: None,
189            system_prompt: None,
190            model: Some("gpt-5".to_string()),
191            model_ref: None,
192            reasoning_effort: None,
193            gold_config_json: None,
194        };
195        let session = build_new_session(&input, &default_config());
196
197        assert_eq!(
198            session
199                .metadata
200                .get("base_system_prompt")
201                .map(String::as_str),
202            Some("Global fallback")
203        );
204        assert!(session.messages.is_empty());
205    }
206
207    #[test]
208    fn build_new_session_uses_builtin_fallback_when_global_is_empty() {
209        let config = CreateSessionConfig {
210            global_default_prompt: "   ".to_string(),
211            ..default_config()
212        };
213        let input = CreateSessionInput {
214            id: "session-3".to_string(),
215            title: None,
216            system_prompt: None,
217            model: Some("gpt-5".to_string()),
218            model_ref: None,
219            reasoning_effort: None,
220            gold_config_json: None,
221        };
222        let session = build_new_session(&input, &config);
223
224        assert_eq!(
225            session
226                .metadata
227                .get("base_system_prompt")
228                .map(String::as_str),
229            Some(BUILTIN_FALLBACK)
230        );
231    }
232
233    #[test]
234    fn build_new_session_with_explicit_prompt_generates_snapshot() {
235        let input = CreateSessionInput {
236            id: "session-4".to_string(),
237            title: None,
238            system_prompt: Some("Custom prompt".to_string()),
239            model: Some("gpt-5".to_string()),
240            model_ref: None,
241            reasoning_effort: None,
242            gold_config_json: None,
243        };
244        let session = build_new_session(&input, &default_config());
245
246        let snapshot = crate::runner::read_prompt_snapshot(&session)
247            .expect("prompt snapshot should exist");
248        assert_eq!(snapshot.base_system_prompt, "Custom prompt");
249        assert_eq!(snapshot.effective_system_prompt, "Custom prompt");
250    }
251
252    #[test]
253    fn build_new_session_with_model_ref_persists_bare_model_and_provider_metadata() {
254        let input = CreateSessionInput {
255            id: "session-5".to_string(),
256            title: None,
257            system_prompt: None,
258            model: Some("ignored-compat-model".to_string()),
259            model_ref: Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet")),
260            reasoning_effort: None,
261            gold_config_json: None,
262        };
263        let session = build_new_session(&input, &default_config());
264
265        assert_eq!(session.model, "claude-3-7-sonnet");
266        assert_eq!(
267            session.model_ref,
268            Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet"))
269        );
270        assert_eq!(
271            session.metadata.get("provider_name").map(String::as_str),
272            Some("anthropic")
273        );
274    }
275}