bamboo_engine/session_app/
session_create.rs1use 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
14pub struct CreateSessionInput {
16 pub id: String,
17 pub project_id: Option<bamboo_domain::ProjectId>,
19 pub title: Option<String>,
20 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 pub workspace_path: Option<String>,
36}
37
38pub 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
49pub 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
117pub 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
124pub 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
132pub 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 #[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}