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 title: Option<String>,
18 pub system_prompt: Option<String>,
19 pub model: Option<String>,
20 pub model_ref: Option<ProviderModelRef>,
21 pub reasoning_effort: Option<ReasoningEffort>,
22 pub gold_config_json: Option<String>,
23}
24
25pub struct CreateSessionConfig {
30 pub default_model: Option<String>,
31 pub default_reasoning_effort: Option<ReasoningEffort>,
32 pub global_default_prompt: String,
33 pub builtin_fallback_prompt: &'static str,
34}
35
36pub fn build_new_session(input: &CreateSessionInput, config: &CreateSessionConfig) -> Session {
38 let model = input
39 .model_ref
40 .as_ref()
41 .map(|model_ref| model_ref.model.clone())
42 .unwrap_or_else(|| resolve_model(input.model.as_deref(), config.default_model.as_deref()));
43 let mut session = Session::new(input.id.clone(), model);
44 if let Some(model_ref) = input.model_ref.as_ref() {
45 persist_model_ref(&mut session, model_ref);
46 } else {
47 persist_legacy_model_provider(&mut session, input.model.as_deref(), None);
48 }
49 session.reasoning_effort =
50 resolve_reasoning_effort(input.reasoning_effort, config.default_reasoning_effort);
51 if let Some(gold_config_json) = trimmed_non_empty(input.gold_config_json.as_deref()) {
52 session
53 .metadata
54 .insert(GOLD_CONFIG_METADATA_KEY.to_string(), gold_config_json);
55 }
56
57 if let Some(title) = trimmed_non_empty(input.title.as_deref()) {
58 session.title = title;
59 }
60 let explicit_prompt = trimmed_non_empty(input.system_prompt.as_deref());
61 let has_explicit_prompt = explicit_prompt.is_some();
62 let base_prompt = explicit_prompt.unwrap_or_else(|| {
63 let trimmed = config.global_default_prompt.trim();
64 if trimmed.is_empty() {
65 config.builtin_fallback_prompt.to_string()
66 } else {
67 trimmed.to_string()
68 }
69 });
70 session
71 .metadata
72 .insert("base_system_prompt".to_string(), base_prompt.clone());
73
74 if has_explicit_prompt {
75 session.add_message(Message::system(base_prompt));
76 crate::runner::refresh_prompt_snapshot(&mut session);
77 }
78
79 session
80}
81
82pub fn resolve_model(request_model: Option<&str>, config_model: Option<&str>) -> String {
84 trimmed_non_empty(request_model)
85 .or_else(|| config_model.map(ToString::to_string))
86 .unwrap_or_else(|| "unknown".to_string())
87}
88
89pub fn resolve_reasoning_effort(
91 request_effort: Option<ReasoningEffort>,
92 config_effort: Option<ReasoningEffort>,
93) -> Option<ReasoningEffort> {
94 request_effort.or(config_effort)
95}
96
97pub fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
99 value
100 .map(str::trim)
101 .filter(|value| !value.is_empty())
102 .map(ToString::to_string)
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 const BUILTIN_FALLBACK: &str = "You are a helpful assistant.";
110
111 fn default_config() -> CreateSessionConfig {
112 CreateSessionConfig {
113 default_model: None,
114 default_reasoning_effort: None,
115 global_default_prompt: "Global fallback".to_string(),
116 builtin_fallback_prompt: BUILTIN_FALLBACK,
117 }
118 }
119
120 #[test]
121 fn resolve_model_uses_request_model_when_present() {
122 assert_eq!(resolve_model(Some(" gpt-5 "), None), "gpt-5");
123 }
124
125 #[test]
126 fn resolve_model_falls_back_to_config() {
127 assert_eq!(resolve_model(None, Some("gpt-4")), "gpt-4");
128 }
129
130 #[test]
131 fn resolve_model_falls_back_to_unknown() {
132 assert_eq!(resolve_model(None, None), "unknown");
133 }
134
135 #[test]
136 fn resolve_model_ignores_blank_request() {
137 assert_eq!(resolve_model(Some(" "), Some("gpt-4")), "gpt-4");
138 }
139
140 #[test]
141 fn resolve_reasoning_effort_prefers_request() {
142 assert_eq!(
143 resolve_reasoning_effort(Some(ReasoningEffort::High), Some(ReasoningEffort::Low)),
144 Some(ReasoningEffort::High)
145 );
146 }
147
148 #[test]
149 fn resolve_reasoning_effort_falls_back_to_config() {
150 assert_eq!(
151 resolve_reasoning_effort(None, Some(ReasoningEffort::Medium)),
152 Some(ReasoningEffort::Medium)
153 );
154 }
155
156 #[test]
157 fn build_new_session_applies_title_and_system_prompt() {
158 let input = CreateSessionInput {
159 id: "session-1".to_string(),
160 title: Some(" Sprint Session ".to_string()),
161 system_prompt: Some(" You are helpful ".to_string()),
162 model: Some("gpt-5".to_string()),
163 model_ref: None,
164 reasoning_effort: Some(ReasoningEffort::High),
165 gold_config_json: None,
166 };
167 let session = build_new_session(&input, &default_config());
168
169 assert_eq!(session.title, "Sprint Session");
170 assert_eq!(
171 session
172 .metadata
173 .get("base_system_prompt")
174 .map(String::as_str),
175 Some("You are helpful")
176 );
177 assert_eq!(session.reasoning_effort, Some(ReasoningEffort::High));
178 assert_eq!(
179 session.messages.first().map(|m| m.content.as_str()),
180 Some("You are helpful")
181 );
182 }
183
184 #[test]
185 fn build_new_session_uses_global_default_when_no_explicit_prompt() {
186 let input = CreateSessionInput {
187 id: "session-2".to_string(),
188 title: None,
189 system_prompt: None,
190 model: Some("gpt-5".to_string()),
191 model_ref: None,
192 reasoning_effort: None,
193 gold_config_json: None,
194 };
195 let session = build_new_session(&input, &default_config());
196
197 assert_eq!(
198 session
199 .metadata
200 .get("base_system_prompt")
201 .map(String::as_str),
202 Some("Global fallback")
203 );
204 assert!(session.messages.is_empty());
205 }
206
207 #[test]
208 fn build_new_session_uses_builtin_fallback_when_global_is_empty() {
209 let config = CreateSessionConfig {
210 global_default_prompt: " ".to_string(),
211 ..default_config()
212 };
213 let input = CreateSessionInput {
214 id: "session-3".to_string(),
215 title: None,
216 system_prompt: None,
217 model: Some("gpt-5".to_string()),
218 model_ref: None,
219 reasoning_effort: None,
220 gold_config_json: None,
221 };
222 let session = build_new_session(&input, &config);
223
224 assert_eq!(
225 session
226 .metadata
227 .get("base_system_prompt")
228 .map(String::as_str),
229 Some(BUILTIN_FALLBACK)
230 );
231 }
232
233 #[test]
234 fn build_new_session_with_explicit_prompt_generates_snapshot() {
235 let input = CreateSessionInput {
236 id: "session-4".to_string(),
237 title: None,
238 system_prompt: Some("Custom prompt".to_string()),
239 model: Some("gpt-5".to_string()),
240 model_ref: None,
241 reasoning_effort: None,
242 gold_config_json: None,
243 };
244 let session = build_new_session(&input, &default_config());
245
246 let snapshot =
247 crate::runner::read_prompt_snapshot(&session).expect("prompt snapshot should exist");
248 assert_eq!(snapshot.base_system_prompt, "Custom prompt");
249 assert_eq!(snapshot.effective_system_prompt, "Custom prompt");
250 }
251
252 #[test]
253 fn build_new_session_with_model_ref_persists_bare_model_and_provider_metadata() {
254 let input = CreateSessionInput {
255 id: "session-5".to_string(),
256 title: None,
257 system_prompt: None,
258 model: Some("ignored-compat-model".to_string()),
259 model_ref: Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet")),
260 reasoning_effort: None,
261 gold_config_json: None,
262 };
263 let session = build_new_session(&input, &default_config());
264
265 assert_eq!(session.model, "claude-3-7-sonnet");
266 assert_eq!(
267 session.model_ref,
268 Some(ProviderModelRef::new("anthropic", "claude-3-7-sonnet"))
269 );
270 assert_eq!(
271 session.metadata.get("provider_name").map(String::as_str),
272 Some("anthropic")
273 );
274 }
275}