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