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