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        session.metadata.insert(
72            crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
73            crate::project_context::WorkspaceSource::Explicit
74                .as_str()
75                .to_string(),
76        );
77        session.metadata.insert(
78            crate::project_context::WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
79            crate::project_context::WorkspaceBindingStatus::Unregistered
80                .as_str()
81                .to_string(),
82        );
83    }
84    if let Some(project_id) = input.project_id.as_ref() {
85        session.set_project_id_meta(project_id.to_string());
86    }
87
88    let explicit_title = trimmed_non_empty(input.title.as_deref());
89    if let Some(title) = explicit_title.as_ref() {
90        session.title = title.clone();
91    }
92    session.title_generated = input
93        .title_generated
94        .unwrap_or_else(|| explicit_title.is_some());
95    let explicit_prompt = trimmed_non_empty(input.system_prompt.as_deref());
96    let has_explicit_prompt = explicit_prompt.is_some();
97    let base_prompt = explicit_prompt.unwrap_or_else(|| {
98        let trimmed = config.global_default_prompt.trim();
99        if trimmed.is_empty() {
100            config.builtin_fallback_prompt.to_string()
101        } else {
102            trimmed.to_string()
103        }
104    });
105    session
106        .metadata
107        .insert("base_system_prompt".to_string(), base_prompt.clone());
108
109    if has_explicit_prompt {
110        session.add_message(Message::system(base_prompt));
111        crate::runner::refresh_prompt_snapshot(&mut session);
112    }
113
114    session
115}
116
117/// Resolve the model from request → config → fallback.
118pub fn resolve_model(request_model: Option<&str>, config_model: Option<&str>) -> String {
119    trimmed_non_empty(request_model)
120        .or_else(|| config_model.map(ToString::to_string))
121        .unwrap_or_else(|| "unknown".to_string())
122}
123
124/// Resolve reasoning effort from request → config.
125pub fn resolve_reasoning_effort(
126    request_effort: Option<ReasoningEffort>,
127    config_effort: Option<ReasoningEffort>,
128) -> Option<ReasoningEffort> {
129    request_effort.or(config_effort)
130}
131
132/// Trim whitespace and return `None` for empty strings.
133pub fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
134    value
135        .map(str::trim)
136        .filter(|value| !value.is_empty())
137        .map(ToString::to_string)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    const BUILTIN_FALLBACK: &str = "You are a helpful assistant.";
145
146    fn default_config() -> CreateSessionConfig {
147        CreateSessionConfig {
148            default_model: None,
149            default_reasoning_effort: None,
150            global_default_prompt: "Global fallback".to_string(),
151            builtin_fallback_prompt: BUILTIN_FALLBACK,
152        }
153    }
154
155    #[test]
156    fn resolve_model_uses_request_model_when_present() {
157        assert_eq!(resolve_model(Some("  gpt-5  "), None), "gpt-5");
158    }
159
160    #[test]
161    fn resolve_model_falls_back_to_config() {
162        assert_eq!(resolve_model(None, Some("gpt-4")), "gpt-4");
163    }
164
165    #[test]
166    fn resolve_model_falls_back_to_unknown() {
167        assert_eq!(resolve_model(None, None), "unknown");
168    }
169
170    #[test]
171    fn resolve_model_ignores_blank_request() {
172        assert_eq!(resolve_model(Some("   "), Some("gpt-4")), "gpt-4");
173    }
174
175    #[test]
176    fn resolve_reasoning_effort_prefers_request() {
177        assert_eq!(
178            resolve_reasoning_effort(Some(ReasoningEffort::High), Some(ReasoningEffort::Low)),
179            Some(ReasoningEffort::High)
180        );
181    }
182
183    #[test]
184    fn resolve_reasoning_effort_falls_back_to_config() {
185        assert_eq!(
186            resolve_reasoning_effort(None, Some(ReasoningEffort::Medium)),
187            Some(ReasoningEffort::Medium)
188        );
189    }
190
191    #[test]
192    fn build_new_session_applies_title_and_system_prompt() {
193        let input = CreateSessionInput {
194            id: "session-1".to_string(),
195            project_id: None,
196            title: Some("  Sprint Session  ".to_string()),
197            title_generated: None,
198            system_prompt: Some("  You are helpful  ".to_string()),
199            model: Some("gpt-5".to_string()),
200            model_ref: None,
201            reasoning_effort: Some(ReasoningEffort::High),
202            gold_config_json: None,
203            workspace_path: None,
204        };
205        let session = build_new_session(&input, &default_config());
206
207        assert_eq!(session.title, "Sprint Session");
208        assert!(session.title_generated);
209        assert_eq!(
210            session
211                .metadata
212                .get("base_system_prompt")
213                .map(String::as_str),
214            Some("You are helpful")
215        );
216        assert_eq!(session.reasoning_effort, Some(ReasoningEffort::High));
217        assert_eq!(
218            session.messages.first().map(|m| m.content.as_str()),
219            Some("You are helpful")
220        );
221    }
222
223    #[test]
224    fn build_new_session_keeps_explicit_placeholder_title_pending() {
225        let input = CreateSessionInput {
226            id: "session-placeholder".to_string(),
227            project_id: None,
228            title: Some("Any UI Placeholder".to_string()),
229            title_generated: Some(false),
230            system_prompt: None,
231            model: Some("gpt-5".to_string()),
232            model_ref: None,
233            reasoning_effort: None,
234            gold_config_json: None,
235            workspace_path: None,
236        };
237
238        let session = build_new_session(&input, &default_config());
239        assert_eq!(session.title, "Any UI Placeholder");
240        assert!(!session.title_generated);
241    }
242
243    #[test]
244    fn build_new_session_uses_global_default_when_no_explicit_prompt() {
245        let input = CreateSessionInput {
246            id: "session-2".to_string(),
247            project_id: None,
248            title: None,
249            title_generated: None,
250            system_prompt: None,
251            model: Some("gpt-5".to_string()),
252            model_ref: None,
253            reasoning_effort: None,
254            gold_config_json: None,
255            workspace_path: None,
256        };
257        let session = build_new_session(&input, &default_config());
258
259        assert!(!session.title_generated);
260        assert_eq!(
261            session
262                .metadata
263                .get("base_system_prompt")
264                .map(String::as_str),
265            Some("Global fallback")
266        );
267        assert!(session.messages.is_empty());
268    }
269
270    #[test]
271    fn build_new_session_uses_builtin_fallback_when_global_is_empty() {
272        let config = CreateSessionConfig {
273            global_default_prompt: "   ".to_string(),
274            ..default_config()
275        };
276        let input = CreateSessionInput {
277            id: "session-3".to_string(),
278            project_id: None,
279            title: None,
280            title_generated: None,
281            system_prompt: None,
282            model: Some("gpt-5".to_string()),
283            model_ref: None,
284            reasoning_effort: None,
285            gold_config_json: None,
286            workspace_path: None,
287        };
288        let session = build_new_session(&input, &config);
289
290        assert_eq!(
291            session
292                .metadata
293                .get("base_system_prompt")
294                .map(String::as_str),
295            Some(BUILTIN_FALLBACK)
296        );
297    }
298
299    #[test]
300    fn build_new_session_with_explicit_prompt_generates_snapshot() {
301        let input = CreateSessionInput {
302            id: "session-4".to_string(),
303            project_id: None,
304            title: None,
305            title_generated: None,
306            system_prompt: Some("Custom prompt".to_string()),
307            model: Some("gpt-5".to_string()),
308            model_ref: None,
309            reasoning_effort: None,
310            gold_config_json: None,
311            workspace_path: None,
312        };
313        let session = build_new_session(&input, &default_config());
314
315        let snapshot =
316            crate::runner::read_prompt_snapshot(&session).expect("prompt snapshot should exist");
317        assert_eq!(snapshot.base_system_prompt, "Custom prompt");
318        assert_eq!(snapshot.effective_system_prompt, "Custom prompt");
319    }
320
321    #[test]
322    fn build_new_session_with_model_ref_persists_bare_model_and_provider_metadata() {
323        let input = CreateSessionInput {
324            id: "session-5".to_string(),
325            project_id: None,
326            title: None,
327            title_generated: None,
328            system_prompt: None,
329            model: Some("ignored-compat-model".to_string()),
330            model_ref: Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet")),
331            reasoning_effort: None,
332            gold_config_json: None,
333            workspace_path: None,
334        };
335        let session = build_new_session(&input, &default_config());
336
337        assert_eq!(session.model, "claude-3-7-sonnet");
338        assert_eq!(
339            session.model_ref,
340            Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet"))
341        );
342        assert_eq!(
343            session.metadata.get("provider_name").map(String::as_str),
344            Some("anthropic")
345        );
346    }
347
348    /// #480: `POST /sessions` gets the same `workspace_path` semantics as
349    /// `POST /chat`'s `ChatTurnInput.workspace_path` — set at session creation.
350    #[test]
351    fn build_new_session_with_workspace_path_sets_workspace_metadata() {
352        let input = CreateSessionInput {
353            id: "session-6".to_string(),
354            project_id: None,
355            title: None,
356            title_generated: None,
357            system_prompt: None,
358            model: Some("gpt-5".to_string()),
359            model_ref: None,
360            reasoning_effort: None,
361            gold_config_json: None,
362            workspace_path: Some("  /tmp/my-workspace  ".to_string()),
363        };
364        let session = build_new_session(&input, &default_config());
365
366        assert_eq!(
367            session.workspace_path_meta().as_deref(),
368            Some("/tmp/my-workspace")
369        );
370    }
371
372    #[test]
373    fn build_new_session_without_workspace_path_leaves_metadata_unset() {
374        let input = CreateSessionInput {
375            id: "session-7".to_string(),
376            project_id: None,
377            title: None,
378            title_generated: None,
379            system_prompt: None,
380            model: Some("gpt-5".to_string()),
381            model_ref: None,
382            reasoning_effort: None,
383            gold_config_json: None,
384            workspace_path: None,
385        };
386        let session = build_new_session(&input, &default_config());
387
388        assert!(session.workspace_path_meta().is_none());
389    }
390}