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 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 pub workspace_path: Option<String>,
32}
33
34pub 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
45pub 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
97pub 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
104pub 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
112pub 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 #[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}