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    /// Initial session-scoped permission mode, already resolved by the caller
37    /// (explicit request value wins over the durable permission-policy
38    /// default). `None` leaves the runtime default (Default) untouched so
39    /// callers without permission context keep the old behavior.
40    pub permission_mode: Option<bamboo_domain::SessionPermissionMode>,
41}
42
43/// Configuration defaults for session creation.
44///
45/// Captured from server `Config` as plain values so the crate stays
46/// decoupled from `bamboo-infrastructure-config`.
47pub struct CreateSessionConfig {
48    pub default_model: Option<String>,
49    pub default_reasoning_effort: Option<ReasoningEffort>,
50    pub global_default_prompt: String,
51    pub builtin_fallback_prompt: &'static str,
52}
53
54/// Build a new session from request input and config defaults.
55pub fn build_new_session(input: &CreateSessionInput, config: &CreateSessionConfig) -> Session {
56    let model = input
57        .model_ref
58        .as_ref()
59        .map(|model_ref| model_ref.model.clone())
60        .unwrap_or_else(|| resolve_model(input.model.as_deref(), config.default_model.as_deref()));
61    let mut session = Session::new(input.id.clone(), model);
62    if let Some(model_ref) = input.model_ref.as_ref() {
63        persist_model_ref(&mut session, model_ref);
64    } else {
65        persist_legacy_model_provider(&mut session, input.model.as_deref(), None);
66    }
67    session.reasoning_effort = resolve_reasoning_effort(
68        input.reasoning_effort,
69        input
70            .model_ref
71            .as_ref()
72            .and_then(|model_ref| model_ref.reasoning_effort)
73            .or(config.default_reasoning_effort),
74    );
75    if let Some(gold_config_json) = trimmed_non_empty(input.gold_config_json.as_deref()) {
76        session
77            .metadata
78            .insert(GOLD_CONFIG_METADATA_KEY.to_string(), gold_config_json);
79    }
80    if let Some(workspace_path) = trimmed_non_empty(input.workspace_path.as_deref()) {
81        session.set_workspace_path_meta(workspace_path);
82        session.metadata.insert(
83            crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
84            crate::project_context::WorkspaceSource::Explicit
85                .as_str()
86                .to_string(),
87        );
88        session.metadata.insert(
89            crate::project_context::WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
90            crate::project_context::WorkspaceBindingStatus::Unregistered
91                .as_str()
92                .to_string(),
93        );
94    }
95    if let Some(project_id) = input.project_id.as_ref() {
96        session.set_project_id_meta(project_id.to_string());
97    }
98    // Stamp the resolved initial permission mode exactly once at creation.
99    // `set_permission_mode` keeps the typed mode and legacy boolean mirror
100    // consistent; later changes flow through the CAS-guarded PATCH contract.
101    if let Some(mode) = input.permission_mode {
102        session
103            .agent_runtime_state
104            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
105            .set_permission_mode(mode);
106    }
107
108    let explicit_title = trimmed_non_empty(input.title.as_deref());
109    if let Some(title) = explicit_title.as_ref() {
110        session.title = title.clone();
111    }
112    session.title_generated = input
113        .title_generated
114        .unwrap_or_else(|| explicit_title.is_some());
115    let explicit_prompt = trimmed_non_empty(input.system_prompt.as_deref());
116    let has_explicit_prompt = explicit_prompt.is_some();
117    let base_prompt = explicit_prompt.unwrap_or_else(|| {
118        let trimmed = config.global_default_prompt.trim();
119        if trimmed.is_empty() {
120            config.builtin_fallback_prompt.to_string()
121        } else {
122            trimmed.to_string()
123        }
124    });
125    session
126        .metadata
127        .insert("base_system_prompt".to_string(), base_prompt.clone());
128
129    if has_explicit_prompt {
130        session.add_message(Message::system(base_prompt));
131        crate::runner::refresh_prompt_snapshot(&mut session);
132    }
133
134    session
135}
136
137/// Resolve the model from request → config → fallback.
138pub fn resolve_model(request_model: Option<&str>, config_model: Option<&str>) -> String {
139    trimmed_non_empty(request_model)
140        .or_else(|| config_model.map(ToString::to_string))
141        .unwrap_or_else(|| "unknown".to_string())
142}
143
144/// Resolve reasoning effort from request → config.
145pub fn resolve_reasoning_effort(
146    request_effort: Option<ReasoningEffort>,
147    config_effort: Option<ReasoningEffort>,
148) -> Option<ReasoningEffort> {
149    request_effort.or(config_effort)
150}
151
152/// Trim whitespace and return `None` for empty strings.
153pub fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
154    value
155        .map(str::trim)
156        .filter(|value| !value.is_empty())
157        .map(ToString::to_string)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    const BUILTIN_FALLBACK: &str = "You are a helpful assistant.";
165
166    fn default_config() -> CreateSessionConfig {
167        CreateSessionConfig {
168            default_model: None,
169            default_reasoning_effort: None,
170            global_default_prompt: "Global fallback".to_string(),
171            builtin_fallback_prompt: BUILTIN_FALLBACK,
172        }
173    }
174
175    #[test]
176    fn resolve_model_uses_request_model_when_present() {
177        assert_eq!(resolve_model(Some("  gpt-5  "), None), "gpt-5");
178    }
179
180    #[test]
181    fn resolve_model_falls_back_to_config() {
182        assert_eq!(resolve_model(None, Some("gpt-4")), "gpt-4");
183    }
184
185    #[test]
186    fn resolve_model_falls_back_to_unknown() {
187        assert_eq!(resolve_model(None, None), "unknown");
188    }
189
190    #[test]
191    fn resolve_model_ignores_blank_request() {
192        assert_eq!(resolve_model(Some("   "), Some("gpt-4")), "gpt-4");
193    }
194
195    #[test]
196    fn resolve_reasoning_effort_prefers_request() {
197        assert_eq!(
198            resolve_reasoning_effort(Some(ReasoningEffort::High), Some(ReasoningEffort::Low)),
199            Some(ReasoningEffort::High)
200        );
201    }
202
203    #[test]
204    fn resolve_reasoning_effort_falls_back_to_config() {
205        assert_eq!(
206            resolve_reasoning_effort(None, Some(ReasoningEffort::Medium)),
207            Some(ReasoningEffort::Medium)
208        );
209    }
210
211    #[test]
212    fn build_new_session_applies_title_and_system_prompt() {
213        let input = CreateSessionInput {
214            id: "session-1".to_string(),
215            project_id: None,
216            title: Some("  Sprint Session  ".to_string()),
217            title_generated: None,
218            system_prompt: Some("  You are helpful  ".to_string()),
219            model: Some("gpt-5".to_string()),
220            model_ref: None,
221            reasoning_effort: Some(ReasoningEffort::High),
222            gold_config_json: None,
223            workspace_path: None,
224            permission_mode: None,
225        };
226        let session = build_new_session(&input, &default_config());
227
228        assert_eq!(session.title, "Sprint Session");
229        assert!(session.title_generated);
230        assert_eq!(
231            session
232                .metadata
233                .get("base_system_prompt")
234                .map(String::as_str),
235            Some("You are helpful")
236        );
237        assert_eq!(session.reasoning_effort, Some(ReasoningEffort::High));
238        assert_eq!(
239            session.messages.first().map(|m| m.content.as_str()),
240            Some("You are helpful")
241        );
242    }
243
244    #[test]
245    fn build_new_session_keeps_explicit_placeholder_title_pending() {
246        let input = CreateSessionInput {
247            id: "session-placeholder".to_string(),
248            project_id: None,
249            title: Some("Any UI Placeholder".to_string()),
250            title_generated: Some(false),
251            system_prompt: None,
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            permission_mode: None,
258        };
259
260        let session = build_new_session(&input, &default_config());
261        assert_eq!(session.title, "Any UI Placeholder");
262        assert!(!session.title_generated);
263    }
264
265    #[test]
266    fn build_new_session_uses_global_default_when_no_explicit_prompt() {
267        let input = CreateSessionInput {
268            id: "session-2".to_string(),
269            project_id: None,
270            title: None,
271            title_generated: None,
272            system_prompt: None,
273            model: Some("gpt-5".to_string()),
274            model_ref: None,
275            reasoning_effort: None,
276            gold_config_json: None,
277            workspace_path: None,
278            permission_mode: None,
279        };
280        let session = build_new_session(&input, &default_config());
281
282        assert!(!session.title_generated);
283        assert_eq!(
284            session
285                .metadata
286                .get("base_system_prompt")
287                .map(String::as_str),
288            Some("Global fallback")
289        );
290        assert!(session.messages.is_empty());
291    }
292
293    #[test]
294    fn build_new_session_uses_builtin_fallback_when_global_is_empty() {
295        let config = CreateSessionConfig {
296            global_default_prompt: "   ".to_string(),
297            ..default_config()
298        };
299        let input = CreateSessionInput {
300            id: "session-3".to_string(),
301            project_id: None,
302            title: None,
303            title_generated: None,
304            system_prompt: None,
305            model: Some("gpt-5".to_string()),
306            model_ref: None,
307            reasoning_effort: None,
308            gold_config_json: None,
309            workspace_path: None,
310            permission_mode: None,
311        };
312        let session = build_new_session(&input, &config);
313
314        assert_eq!(
315            session
316                .metadata
317                .get("base_system_prompt")
318                .map(String::as_str),
319            Some(BUILTIN_FALLBACK)
320        );
321    }
322
323    #[test]
324    fn build_new_session_with_explicit_prompt_generates_snapshot() {
325        let input = CreateSessionInput {
326            id: "session-4".to_string(),
327            project_id: None,
328            title: None,
329            title_generated: None,
330            system_prompt: Some("Custom prompt".to_string()),
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            permission_mode: None,
337        };
338        let session = build_new_session(&input, &default_config());
339
340        let snapshot =
341            crate::runner::read_prompt_snapshot(&session).expect("prompt snapshot should exist");
342        assert_eq!(snapshot.base_system_prompt, "Custom prompt");
343        assert_eq!(snapshot.effective_system_prompt, "Custom prompt");
344    }
345
346    #[test]
347    fn build_new_session_with_model_ref_persists_bare_model_and_provider_metadata() {
348        let input = CreateSessionInput {
349            id: "session-5".to_string(),
350            project_id: None,
351            title: None,
352            title_generated: None,
353            system_prompt: None,
354            model: Some("ignored-compat-model".to_string()),
355            model_ref: Some(
356                ProviderModelRef::new("anthropic", "claude-3-7-sonnet")
357                    .with_reasoning_effort(ReasoningEffort::High),
358            ),
359            reasoning_effort: None,
360            gold_config_json: None,
361            workspace_path: None,
362            permission_mode: None,
363        };
364        let session = build_new_session(&input, &default_config());
365
366        assert_eq!(session.model, "claude-3-7-sonnet");
367        assert_eq!(
368            session.model_ref,
369            Some(
370                ProviderModelRef::new("anthropic", "claude-3-7-sonnet")
371                    .with_reasoning_effort(ReasoningEffort::High)
372            )
373        );
374        assert_eq!(session.reasoning_effort, Some(ReasoningEffort::High));
375        assert_eq!(
376            session.metadata.get("provider_name").map(String::as_str),
377            Some("anthropic")
378        );
379    }
380
381    /// #480: `POST /sessions` gets the same `workspace_path` semantics as
382    /// `POST /chat`'s `ChatTurnInput.workspace_path` — set at session creation.
383    #[test]
384    fn build_new_session_with_workspace_path_sets_workspace_metadata() {
385        let input = CreateSessionInput {
386            id: "session-6".to_string(),
387            project_id: None,
388            title: None,
389            title_generated: None,
390            system_prompt: None,
391            model: Some("gpt-5".to_string()),
392            model_ref: None,
393            reasoning_effort: None,
394            gold_config_json: None,
395            workspace_path: Some("  /tmp/my-workspace  ".to_string()),
396            permission_mode: None,
397        };
398        let session = build_new_session(&input, &default_config());
399
400        assert_eq!(
401            session.workspace_path_meta().as_deref(),
402            Some("/tmp/my-workspace")
403        );
404    }
405
406    #[test]
407    fn build_new_session_without_workspace_path_leaves_metadata_unset() {
408        let input = CreateSessionInput {
409            id: "session-7".to_string(),
410            project_id: None,
411            title: None,
412            title_generated: None,
413            system_prompt: None,
414            model: Some("gpt-5".to_string()),
415            model_ref: None,
416            reasoning_effort: None,
417            gold_config_json: None,
418            workspace_path: None,
419            permission_mode: None,
420        };
421        let session = build_new_session(&input, &default_config());
422
423        assert!(session.workspace_path_meta().is_none());
424    }
425
426    #[test]
427    fn build_new_session_stamps_permission_mode_with_legacy_mirror() {
428        let input = CreateSessionInput {
429            id: "session-permission".to_string(),
430            project_id: None,
431            title: None,
432            title_generated: None,
433            system_prompt: None,
434            model: Some("gpt-5".to_string()),
435            model_ref: None,
436            reasoning_effort: None,
437            gold_config_json: None,
438            workspace_path: None,
439            permission_mode: Some(bamboo_domain::SessionPermissionMode::Auto),
440        };
441        let session = build_new_session(&input, &default_config());
442        let runtime = session.agent_runtime_state.as_ref().expect("runtime state");
443        assert_eq!(
444            runtime.effective_permission_mode(),
445            bamboo_domain::SessionPermissionMode::Auto
446        );
447        // The legacy boolean mirror keeps old clients conservative.
448        assert!(runtime.bypass_permissions);
449
450        // None leaves the runtime state untouched — no state is created and
451        // persisted sessions stay byte-compatible with the pre-field format.
452        let unset = build_new_session(
453            &CreateSessionInput {
454                id: "session-permission-unset".to_string(),
455                project_id: None,
456                title: None,
457                title_generated: None,
458                system_prompt: None,
459                model: Some("gpt-5".to_string()),
460                model_ref: None,
461                reasoning_effort: None,
462                gold_config_json: None,
463                workspace_path: None,
464                permission_mode: None,
465            },
466            &default_config(),
467        );
468        assert!(unset.agent_runtime_state.is_none());
469    }
470}