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    /// Optional workspace path, same semantics as `ChatTurnInput::workspace_path`
24    /// (#480: connect bridge sets workspace at session-creation; an external
25    /// connector using `POST /sessions` should be able to as well). Only the
26    /// metadata write happens here — syncing the runtime workspace directory
27    /// on disk (`ensure_session_workspace`) is a handler-layer concern, same
28    /// split as the chat turn use case.
29    pub workspace_path: Option<String>,
30}
31
32/// Configuration defaults for session creation.
33///
34/// Captured from server `Config` as plain values so the crate stays
35/// decoupled from `bamboo-infrastructure-config`.
36pub struct CreateSessionConfig {
37    pub default_model: Option<String>,
38    pub default_reasoning_effort: Option<ReasoningEffort>,
39    pub global_default_prompt: String,
40    pub builtin_fallback_prompt: &'static str,
41}
42
43/// Build a new session from request input and config defaults.
44pub fn build_new_session(input: &CreateSessionInput, config: &CreateSessionConfig) -> Session {
45    let model = input
46        .model_ref
47        .as_ref()
48        .map(|model_ref| model_ref.model.clone())
49        .unwrap_or_else(|| resolve_model(input.model.as_deref(), config.default_model.as_deref()));
50    let mut session = Session::new(input.id.clone(), model);
51    if let Some(model_ref) = input.model_ref.as_ref() {
52        persist_model_ref(&mut session, model_ref);
53    } else {
54        persist_legacy_model_provider(&mut session, input.model.as_deref(), None);
55    }
56    session.reasoning_effort =
57        resolve_reasoning_effort(input.reasoning_effort, config.default_reasoning_effort);
58    if let Some(gold_config_json) = trimmed_non_empty(input.gold_config_json.as_deref()) {
59        session
60            .metadata
61            .insert(GOLD_CONFIG_METADATA_KEY.to_string(), gold_config_json);
62    }
63    if let Some(workspace_path) = trimmed_non_empty(input.workspace_path.as_deref()) {
64        session.set_workspace_path_meta(workspace_path);
65    }
66
67    if let Some(title) = trimmed_non_empty(input.title.as_deref()) {
68        session.title = title;
69    }
70    let explicit_prompt = trimmed_non_empty(input.system_prompt.as_deref());
71    let has_explicit_prompt = explicit_prompt.is_some();
72    let base_prompt = explicit_prompt.unwrap_or_else(|| {
73        let trimmed = config.global_default_prompt.trim();
74        if trimmed.is_empty() {
75            config.builtin_fallback_prompt.to_string()
76        } else {
77            trimmed.to_string()
78        }
79    });
80    session
81        .metadata
82        .insert("base_system_prompt".to_string(), base_prompt.clone());
83
84    if has_explicit_prompt {
85        session.add_message(Message::system(base_prompt));
86        crate::runner::refresh_prompt_snapshot(&mut session);
87    }
88
89    session
90}
91
92/// Resolve the model from request → config → fallback.
93pub fn resolve_model(request_model: Option<&str>, config_model: Option<&str>) -> String {
94    trimmed_non_empty(request_model)
95        .or_else(|| config_model.map(ToString::to_string))
96        .unwrap_or_else(|| "unknown".to_string())
97}
98
99/// Resolve reasoning effort from request → config.
100pub fn resolve_reasoning_effort(
101    request_effort: Option<ReasoningEffort>,
102    config_effort: Option<ReasoningEffort>,
103) -> Option<ReasoningEffort> {
104    request_effort.or(config_effort)
105}
106
107/// Trim whitespace and return `None` for empty strings.
108pub fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
109    value
110        .map(str::trim)
111        .filter(|value| !value.is_empty())
112        .map(ToString::to_string)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    const BUILTIN_FALLBACK: &str = "You are a helpful assistant.";
120
121    fn default_config() -> CreateSessionConfig {
122        CreateSessionConfig {
123            default_model: None,
124            default_reasoning_effort: None,
125            global_default_prompt: "Global fallback".to_string(),
126            builtin_fallback_prompt: BUILTIN_FALLBACK,
127        }
128    }
129
130    #[test]
131    fn resolve_model_uses_request_model_when_present() {
132        assert_eq!(resolve_model(Some("  gpt-5  "), None), "gpt-5");
133    }
134
135    #[test]
136    fn resolve_model_falls_back_to_config() {
137        assert_eq!(resolve_model(None, Some("gpt-4")), "gpt-4");
138    }
139
140    #[test]
141    fn resolve_model_falls_back_to_unknown() {
142        assert_eq!(resolve_model(None, None), "unknown");
143    }
144
145    #[test]
146    fn resolve_model_ignores_blank_request() {
147        assert_eq!(resolve_model(Some("   "), Some("gpt-4")), "gpt-4");
148    }
149
150    #[test]
151    fn resolve_reasoning_effort_prefers_request() {
152        assert_eq!(
153            resolve_reasoning_effort(Some(ReasoningEffort::High), Some(ReasoningEffort::Low)),
154            Some(ReasoningEffort::High)
155        );
156    }
157
158    #[test]
159    fn resolve_reasoning_effort_falls_back_to_config() {
160        assert_eq!(
161            resolve_reasoning_effort(None, Some(ReasoningEffort::Medium)),
162            Some(ReasoningEffort::Medium)
163        );
164    }
165
166    #[test]
167    fn build_new_session_applies_title_and_system_prompt() {
168        let input = CreateSessionInput {
169            id: "session-1".to_string(),
170            title: Some("  Sprint Session  ".to_string()),
171            system_prompt: Some("  You are helpful  ".to_string()),
172            model: Some("gpt-5".to_string()),
173            model_ref: None,
174            reasoning_effort: Some(ReasoningEffort::High),
175            gold_config_json: None,
176            workspace_path: None,
177        };
178        let session = build_new_session(&input, &default_config());
179
180        assert_eq!(session.title, "Sprint Session");
181        assert_eq!(
182            session
183                .metadata
184                .get("base_system_prompt")
185                .map(String::as_str),
186            Some("You are helpful")
187        );
188        assert_eq!(session.reasoning_effort, Some(ReasoningEffort::High));
189        assert_eq!(
190            session.messages.first().map(|m| m.content.as_str()),
191            Some("You are helpful")
192        );
193    }
194
195    #[test]
196    fn build_new_session_uses_global_default_when_no_explicit_prompt() {
197        let input = CreateSessionInput {
198            id: "session-2".to_string(),
199            title: None,
200            system_prompt: None,
201            model: Some("gpt-5".to_string()),
202            model_ref: None,
203            reasoning_effort: None,
204            gold_config_json: None,
205            workspace_path: None,
206        };
207        let session = build_new_session(&input, &default_config());
208
209        assert_eq!(
210            session
211                .metadata
212                .get("base_system_prompt")
213                .map(String::as_str),
214            Some("Global fallback")
215        );
216        assert!(session.messages.is_empty());
217    }
218
219    #[test]
220    fn build_new_session_uses_builtin_fallback_when_global_is_empty() {
221        let config = CreateSessionConfig {
222            global_default_prompt: "   ".to_string(),
223            ..default_config()
224        };
225        let input = CreateSessionInput {
226            id: "session-3".to_string(),
227            title: None,
228            system_prompt: None,
229            model: Some("gpt-5".to_string()),
230            model_ref: None,
231            reasoning_effort: None,
232            gold_config_json: None,
233            workspace_path: None,
234        };
235        let session = build_new_session(&input, &config);
236
237        assert_eq!(
238            session
239                .metadata
240                .get("base_system_prompt")
241                .map(String::as_str),
242            Some(BUILTIN_FALLBACK)
243        );
244    }
245
246    #[test]
247    fn build_new_session_with_explicit_prompt_generates_snapshot() {
248        let input = CreateSessionInput {
249            id: "session-4".to_string(),
250            title: None,
251            system_prompt: Some("Custom prompt".to_string()),
252            model: Some("gpt-5".to_string()),
253            model_ref: None,
254            reasoning_effort: None,
255            gold_config_json: None,
256            workspace_path: None,
257        };
258        let session = build_new_session(&input, &default_config());
259
260        let snapshot =
261            crate::runner::read_prompt_snapshot(&session).expect("prompt snapshot should exist");
262        assert_eq!(snapshot.base_system_prompt, "Custom prompt");
263        assert_eq!(snapshot.effective_system_prompt, "Custom prompt");
264    }
265
266    #[test]
267    fn build_new_session_with_model_ref_persists_bare_model_and_provider_metadata() {
268        let input = CreateSessionInput {
269            id: "session-5".to_string(),
270            title: None,
271            system_prompt: None,
272            model: Some("ignored-compat-model".to_string()),
273            model_ref: Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet")),
274            reasoning_effort: None,
275            gold_config_json: None,
276            workspace_path: None,
277        };
278        let session = build_new_session(&input, &default_config());
279
280        assert_eq!(session.model, "claude-3-7-sonnet");
281        assert_eq!(
282            session.model_ref,
283            Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet"))
284        );
285        assert_eq!(
286            session.metadata.get("provider_name").map(String::as_str),
287            Some("anthropic")
288        );
289    }
290
291    /// #480: `POST /sessions` gets the same `workspace_path` semantics as
292    /// `POST /chat`'s `ChatTurnInput.workspace_path` — set at session creation.
293    #[test]
294    fn build_new_session_with_workspace_path_sets_workspace_metadata() {
295        let input = CreateSessionInput {
296            id: "session-6".to_string(),
297            title: None,
298            system_prompt: None,
299            model: Some("gpt-5".to_string()),
300            model_ref: None,
301            reasoning_effort: None,
302            gold_config_json: None,
303            workspace_path: Some("  /tmp/my-workspace  ".to_string()),
304        };
305        let session = build_new_session(&input, &default_config());
306
307        assert_eq!(
308            session.workspace_path_meta().as_deref(),
309            Some("/tmp/my-workspace")
310        );
311    }
312
313    #[test]
314    fn build_new_session_without_workspace_path_leaves_metadata_unset() {
315        let input = CreateSessionInput {
316            id: "session-7".to_string(),
317            title: None,
318            system_prompt: None,
319            model: Some("gpt-5".to_string()),
320            model_ref: None,
321            reasoning_effort: None,
322            gold_config_json: None,
323            workspace_path: None,
324        };
325        let session = build_new_session(&input, &default_config());
326
327        assert!(session.workspace_path_meta().is_none());
328    }
329}