1use 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 pub permission_mode: Option<bamboo_domain::SessionPermissionMode>,
41}
42
43pub 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
54pub 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 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
137pub 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
144pub 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
152pub 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 #[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 assert!(runtime.bypass_permissions);
449
450 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}