bamboo_engine/session_app/
chat.rs1use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
4use crate::runner::refresh_prompt_snapshot;
5use bamboo_agent_core::{Role, Session};
6use bamboo_config::paths::path_to_display_string;
7use bamboo_domain::Message;
8use bamboo_skills::selection::normalize_selected_skill_ids;
9use sha2::{Digest, Sha256};
10use std::path::{Path, PathBuf};
11
12use super::errors::ChatError;
13use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
14use super::repository::SessionAccess;
15use super::types::ChatTurnInput;
16
17const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
19const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
20const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
21const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
22const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
23const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
24const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
25const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
26
27const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
28
29pub async fn prepare_chat_turn(
37 repo: &dyn SessionAccess,
38 input: ChatTurnInput,
39 global_default_prompt: &str,
40 builtin_fallback_prompt: &str,
41) -> Result<Session, ChatError> {
42 let mut session = repo.load_or_create(&input.session_id, &input.model).await?;
43
44 let base_prompt = resolve_base_prompt(
46 &mut session,
47 input.system_prompt.as_deref(),
48 global_default_prompt,
49 builtin_fallback_prompt,
50 );
51
52 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
54 let enhance_prompt = session.enhance_prompt();
55
56 resolve_copilot_conclusion_with_options_enhancement(
58 &mut session,
59 input.copilot_conclusion_with_options_enhancement_enabled,
60 );
61
62 let workspace_path = resolve_workspace_path(
64 &mut session,
65 input.workspace_path.as_deref(),
66 input.data_dir.as_deref(),
67 );
68
69 resolve_selected_skill_ids(
71 &mut session,
72 input.selected_skill_ids.as_deref(),
73 &input.message,
74 );
75
76 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
78 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
79
80 let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
82 &base_prompt,
83 enhance_prompt.as_deref(),
84 workspace_path.as_deref(),
85 );
86
87 session.metadata.insert(
88 PROMPT_COMPOSER_VERSION_KEY.to_string(),
89 prompt_profile.version.to_string(),
90 );
91 session.metadata.insert(
92 PROMPT_FINGERPRINT_KEY.to_string(),
93 prompt_profile.fingerprint.clone(),
94 );
95 session.metadata.insert(
96 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
97 prompt_profile.component_flags_value(),
98 );
99 session.metadata.insert(
100 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
101 prompt_profile.component_lengths_value(),
102 );
103
104 session
106 .messages
107 .retain(|message| !matches!(message.role, Role::System));
108 session.messages.insert(0, Message::system(system_prompt));
109 refresh_prompt_snapshot(&mut session);
110
111 let request_model_ref = derive_model_ref(
113 input.model_ref.as_ref(),
114 input.provider.as_deref(),
115 Some(input.model.as_str()),
116 );
117 if let Some(model_ref) = request_model_ref.as_ref() {
118 persist_model_ref(&mut session, model_ref);
119 } else {
120 persist_legacy_model_provider(
121 &mut session,
122 Some(input.model.as_str()),
123 input.provider.as_deref(),
124 );
125 }
126
127 repo.save_and_cache(&mut session).await?;
129
130 Ok(session)
131}
132
133pub fn resolve_base_prompt(
136 session: &mut Session,
137 base_prompt_from_request: Option<&str>,
138 global_default_template: &str,
139 builtin_fallback: &str,
140) -> String {
141 let resolved = base_prompt_from_request
142 .map(ToString::to_string)
143 .or_else(|| {
144 session
145 .metadata
146 .get(BASE_SYSTEM_PROMPT_KEY)
147 .map(String::as_str)
148 .map(str::trim)
149 .filter(|value| !value.is_empty())
150 .map(ToString::to_string)
151 })
152 .or_else(|| {
153 session
154 .messages
155 .iter()
156 .find(|message| matches!(message.role, Role::System))
157 .map(|message| message.content.trim().to_string())
158 .filter(|value| !value.is_empty())
159 })
160 .unwrap_or_else(|| {
161 let trimmed = global_default_template.trim();
162 if trimmed.is_empty() {
163 builtin_fallback.to_string()
164 } else {
165 trimmed.to_string()
166 }
167 });
168
169 session
170 .metadata
171 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
172 resolved
173}
174
175pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
176 if let Some(prompt) = enhance_prompt_from_request {
177 session.set_enhance_prompt(prompt);
178 } else {
179 session.clear_enhance_prompt();
180 }
181}
182
183pub fn resolve_copilot_conclusion_with_options_enhancement(
184 session: &mut Session,
185 enabled_from_request: Option<bool>,
186) {
187 if let Some(enabled) = enabled_from_request {
188 session
189 .metadata
190 .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
191 } else {
192 session.metadata.remove(COPILOT_CONCLUSION_KEY);
193 }
194}
195
196pub fn resolve_workspace_path(
197 session: &mut Session,
198 workspace_path_from_request: Option<&str>,
199 data_dir: Option<&Path>,
200) -> Option<String> {
201 if let Some(path) = workspace_path_from_request {
202 session.set_workspace_path_meta(path);
203 }
204
205 workspace_path_from_request
206 .map(ToString::to_string)
207 .or_else(|| session.workspace_path_meta())
208 .or_else(|| resolve_default_workspace(data_dir))
209}
210
211fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
222 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
223 bamboo_agent_core::workspace_state::get_configured_default_workspace()
224 } else {
225 default_workspace_from_data_dir(data_dir)
226 };
227 configured.map(|path| path_to_display_string(&path))
228}
229
230fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
233 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
234}
235
236pub fn resolve_selected_skill_ids(
237 session: &mut Session,
238 selected_skill_ids_from_request: Option<&[String]>,
239 message: &str,
240) {
241 if let Some(request_ids) = selected_skill_ids_from_request {
242 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
243 persist_selected_skill_ids_metadata(session, normalized.as_deref());
244 return;
245 }
246
247 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
248 if let Some(ids) = from_hint.as_ref() {
249 persist_selected_skill_ids_metadata(session, Some(ids));
250 return;
251 }
252
253 session.clear_selected_skill_ids();
254}
255
256pub fn clear_skill_runtime_state(session: &mut Session) {
258 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
259 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
260}
261
262fn persist_selected_skill_ids_metadata(
263 session: &mut Session,
264 selected_skill_ids: Option<&[String]>,
265) {
266 match selected_skill_ids {
267 Some(ids) if !ids.is_empty() => {
268 session.set_selected_skill_ids(ids.to_vec());
269 }
270 _ => {
271 session.clear_selected_skill_ids();
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum GoalCommand {
281 Status,
283 Off,
285 Clear,
287 On,
289 SetPrompt(String),
291}
292
293pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
296 let trimmed = message.trim();
297 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
298 return None;
299 }
300 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
303 return None;
304 }
305
306 let arg = rest.trim().to_ascii_lowercase();
307
308 if arg.is_empty() {
309 return Some(GoalCommand::Status);
310 }
311
312 match arg.as_str() {
313 "status" => Some(GoalCommand::Status),
314 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
315 "clear" | "reset" => Some(GoalCommand::Clear),
316 "on" | "enable" | "enabled" => Some(GoalCommand::On),
317 _ => {
318 let prompt = trimmed
321 .strip_prefix("/goal")
322 .unwrap_or(trimmed)
323 .trim()
324 .to_string();
325 if prompt.is_empty() {
326 Some(GoalCommand::Status)
327 } else {
328 Some(GoalCommand::SetPrompt(prompt))
329 }
330 }
331 }
332}
333
334fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
335 const HINT_PREFIX: &str = "[User explicitly selected skill:";
336 let mut extracted = Vec::new();
337
338 for line in message.lines() {
339 let trimmed = line.trim();
340 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
341 continue;
342 }
343
344 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
345 continue;
346 };
347 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
348 let Some(close_paren_index) = id_segment.find(')') else {
349 continue;
350 };
351 let id = id_segment[..close_paren_index].trim();
352 if !id.is_empty() {
353 extracted.push(id.to_string());
354 }
355 }
356
357 extracted
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
363struct PromptCompositionProfile {
364 version: &'static str,
365 fingerprint: String,
366 has_enhancement: bool,
367 has_workspace_context: bool,
368 has_env_context: bool,
369 base_len: usize,
370 enhancement_len: usize,
371 workspace_context_len: usize,
372 env_context_len: usize,
373 final_len: usize,
374}
375
376impl PromptCompositionProfile {
377 fn component_flags_value(&self) -> String {
378 format!(
379 "enhance={};workspace={};env={}",
380 self.has_enhancement as u8,
381 self.has_workspace_context as u8,
382 self.has_env_context as u8,
383 )
384 }
385
386 fn component_lengths_value(&self) -> String {
387 format!(
388 "base={};enhance={};workspace={};env={};final={}",
389 self.base_len,
390 self.enhancement_len,
391 self.workspace_context_len,
392 self.env_context_len,
393 self.final_len
394 )
395 }
396}
397
398fn build_prompt_fingerprint(
399 base_prompt: &str,
400 enhancement: Option<&str>,
401 workspace: Option<&str>,
402 env_context: Option<&str>,
403) -> String {
404 let mut hasher = Sha256::new();
405 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
406 hasher.update([0u8]);
407 hasher.update(base_prompt.as_bytes());
408 hasher.update([0u8]);
409 hasher.update(enhancement.unwrap_or_default().as_bytes());
410 hasher.update([0u8]);
411 hasher.update(workspace.unwrap_or_default().as_bytes());
412 hasher.update([0u8]);
413 hasher.update(env_context.unwrap_or_default().as_bytes());
414 hex::encode(hasher.finalize())
415}
416
417fn build_enhanced_system_prompt_with_profile(
418 base_prompt: &str,
419 enhance_prompt: Option<&str>,
420 workspace_path: Option<&str>,
421) -> (String, PromptCompositionProfile) {
422 let mut merged_prompt = base_prompt.to_string();
423
424 let enhancement = enhance_prompt
425 .map(str::trim)
426 .filter(|enhancement| !enhancement.is_empty())
427 .map(ToString::to_string);
428 if let Some(enhancement) = enhancement.as_ref() {
429 merged_prompt.push_str("\n\n");
430 merged_prompt.push_str(enhancement.as_str());
431 }
432
433 let workspace_context = workspace_path
434 .map(str::trim)
435 .filter(|workspace_path| !workspace_path.is_empty())
436 .and_then(build_workspace_prompt_context);
437 if let Some(workspace_context) = workspace_context.as_ref() {
438 merged_prompt.push_str("\n\n");
439 merged_prompt.push_str(workspace_context.as_str());
440 }
441
442 let env_context = build_env_prompt_context();
443 if let Some(env_context) = env_context.as_ref() {
444 merged_prompt.push_str("\n\n");
445 merged_prompt.push_str(env_context.as_str());
446 }
447
448 let profile = PromptCompositionProfile {
449 version: PROMPT_COMPOSER_VERSION,
450 fingerprint: build_prompt_fingerprint(
451 base_prompt,
452 enhancement.as_deref(),
453 workspace_context.as_deref(),
454 env_context.as_deref(),
455 ),
456 has_enhancement: enhancement.is_some(),
457 has_workspace_context: workspace_context.is_some(),
458 has_env_context: env_context.is_some(),
459 base_len: base_prompt.len(),
460 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
461 workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
462 env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
463 final_len: merged_prompt.len(),
464 };
465
466 (merged_prompt, profile)
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::session_app::errors::{SessionLoadError, SessionSaveError};
473 use async_trait::async_trait;
474
475 struct InMemorySessionAccess;
476
477 #[async_trait]
478 impl SessionAccess for InMemorySessionAccess {
479 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
480 Ok(None)
481 }
482
483 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
484 Ok(Session::new(id, model))
485 }
486
487 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
488 Ok(None)
489 }
490
491 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
492 Ok(())
493 }
494
495 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
496 Ok(())
497 }
498 }
499
500 fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
501 super::super::types::ChatTurnInput {
502 session_id: "session-enhance".to_string(),
503 model: "gpt-5".to_string(),
504 model_ref: None,
505 provider: None,
506 message: "hello".to_string(),
507 system_prompt: Some("Base prompt".to_string()),
508 enhance_prompt: enhance_prompt.map(ToString::to_string),
509 workspace_path: None,
510 selected_skill_ids: None,
511 copilot_conclusion_with_options_enhancement_enabled: None,
512 data_dir: None,
513 }
514 }
515
516 fn system_message_content(session: &Session) -> String {
517 session
518 .messages
519 .iter()
520 .find(|message| matches!(message.role, Role::System))
521 .map(|message| message.content.clone())
522 .expect("session should have a system message")
523 }
524
525 #[tokio::test]
528 async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
529 let session = prepare_chat_turn(
530 &InMemorySessionAccess,
531 chat_turn_input(Some("Extra enhancement guidance")),
532 "",
533 "Builtin fallback",
534 )
535 .await
536 .expect("prepare_chat_turn should succeed");
537
538 let system_prompt = system_message_content(&session);
539 assert!(system_prompt.starts_with("Base prompt"));
540 assert!(system_prompt.contains("Extra enhancement guidance"));
541 assert_eq!(
542 session.enhance_prompt().as_deref(),
543 Some("Extra enhancement guidance")
544 );
545 assert!(session
546 .metadata
547 .get(PROMPT_COMPONENT_FLAGS_KEY)
548 .is_some_and(|flags| flags.contains("enhance=1")));
549 }
550
551 #[tokio::test]
552 async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
553 let session = prepare_chat_turn(
554 &InMemorySessionAccess,
555 chat_turn_input(None),
556 "",
557 "Builtin fallback",
558 )
559 .await
560 .expect("prepare_chat_turn should succeed");
561
562 let system_prompt = system_message_content(&session);
563 assert!(system_prompt.starts_with("Base prompt"));
564 assert!(session.enhance_prompt().is_none());
565 assert!(session
566 .metadata
567 .get(PROMPT_COMPONENT_FLAGS_KEY)
568 .is_some_and(|flags| flags.contains("enhance=0")));
569 }
570
571 #[test]
577 fn default_workspace_from_data_dir_reads_configured_work_area() {
578 let temp = tempfile::tempdir().expect("temp dir");
579 let workspace = temp.path().join("default-workspace");
580 std::fs::create_dir_all(&workspace).expect("workspace dir");
581 std::fs::write(
582 temp.path().join("config.json"),
583 serde_json::json!({
584 "default_work_area": { "path": workspace.to_string_lossy() }
585 })
586 .to_string(),
587 )
588 .expect("write config.json");
589
590 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
591 assert_eq!(
595 resolved.canonicalize().unwrap(),
596 workspace.canonicalize().unwrap()
597 );
598 }
599
600 #[test]
601 fn default_workspace_from_data_dir_is_none_without_config() {
602 let temp = tempfile::tempdir().expect("temp dir");
603 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
604 }
605}