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 }
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
105pub 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
112pub 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
120pub 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 #[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}