1use 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 bamboo_skills::{
10 ActiveWorkflow, WorkflowActivationStatus, WorkflowSelection, ACTIVE_WORKFLOW_METADATA_KEY,
11 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY, WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,
12 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY, WORKFLOW_SELECTION_METADATA_KEY,
13};
14use sha2::{Digest, Sha256};
15use std::path::{Path, PathBuf};
16
17use super::errors::ChatError;
18use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
19use super::repository::SessionAccess;
20use super::types::ChatTurnInput;
21
22const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
24const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
25const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
26const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
27const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
28const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
29const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
30const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
31
32const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
33pub const SESSION_START_SOURCE_METADATA_KEY: &str = "runtime.session_start_source";
34
35pub async fn prepare_chat_turn(
43 repo: &dyn SessionAccess,
44 input: ChatTurnInput,
45 global_default_prompt: &str,
46 builtin_fallback_prompt: &str,
47) -> Result<Session, ChatError> {
48 prepare_chat_turn_inner(
49 repo,
50 input,
51 global_default_prompt,
52 builtin_fallback_prompt,
53 true,
54 )
55 .await
56}
57
58pub async fn prepare_chat_turn_unpersisted(
65 repo: &dyn SessionAccess,
66 input: ChatTurnInput,
67 global_default_prompt: &str,
68 builtin_fallback_prompt: &str,
69) -> Result<Session, ChatError> {
70 prepare_chat_turn_inner(
71 repo,
72 input,
73 global_default_prompt,
74 builtin_fallback_prompt,
75 false,
76 )
77 .await
78}
79
80async fn prepare_chat_turn_inner(
81 repo: &dyn SessionAccess,
82 input: ChatTurnInput,
83 global_default_prompt: &str,
84 builtin_fallback_prompt: &str,
85 persist: bool,
86) -> Result<Session, ChatError> {
87 let existing = repo.load_merged(&input.session_id).await?;
88 let mut session = prepare_chat_turn_from_authoritative_session(
89 existing,
90 input,
91 global_default_prompt,
92 builtin_fallback_prompt,
93 )?;
94 if persist {
95 repo.save_and_cache(&mut session).await?;
96 }
97 Ok(session)
98}
99
100pub fn prepare_chat_turn_from_authoritative_session(
106 existing: Option<Session>,
107 input: ChatTurnInput,
108 global_default_prompt: &str,
109 builtin_fallback_prompt: &str,
110) -> Result<Session, ChatError> {
111 let (mut session, session_start_source) = match existing {
112 Some(session) => (session, "resume"),
113 None => (
114 Session::new(input.session_id.clone(), input.model.clone()),
115 "startup",
116 ),
117 };
118 match crate::project_context::ProjectContextResolver::session_project_identity(&session) {
119 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
120 return Err(ChatError::InvalidProjectIdentity { raw, message });
121 }
122 crate::project_context::SessionProjectIdentity::Assigned(actual)
123 if input.project_id.as_ref() != Some(&actual) =>
124 {
125 return Err(ChatError::ProjectIdentityConflict {
126 expected: input.project_id,
127 actual: Some(actual),
128 });
129 }
130 crate::project_context::SessionProjectIdentity::Unassigned
131 if session_start_source == "resume" && input.project_id.is_some() =>
132 {
133 return Err(ChatError::ProjectIdentityConflict {
134 expected: input.project_id,
135 actual: None,
136 });
137 }
138 crate::project_context::SessionProjectIdentity::Assigned(_)
139 | crate::project_context::SessionProjectIdentity::Unassigned => {}
140 }
141 session.metadata.insert(
142 SESSION_START_SOURCE_METADATA_KEY.to_string(),
143 session_start_source.to_string(),
144 );
145 if session_start_source == "startup" {
146 if let Some(project_id) = input.project_id.as_ref() {
147 session.set_project_id_meta(project_id.to_string());
148 }
149 }
150
151 let base_prompt = resolve_base_prompt(
153 &mut session,
154 input.system_prompt.as_deref(),
155 global_default_prompt,
156 builtin_fallback_prompt,
157 );
158
159 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
161 let enhance_prompt = session.enhance_prompt();
162
163 resolve_copilot_conclusion_with_options_enhancement(
165 &mut session,
166 input.copilot_conclusion_with_options_enhancement_enabled,
167 );
168
169 let workspace_path = resolve_workspace_path_with_default(
171 &mut session,
172 input.workspace_path.as_deref(),
173 input.default_workspace_path.as_deref(),
174 input.data_dir.as_deref(),
175 );
176
177 resolve_workflow_selection(
179 &mut session,
180 input.workflow_selection.as_ref(),
181 input.selected_skill_ids.as_deref(),
182 &input.message,
183 )?;
184 if let Some(opted_in) = input.orchestration_opt_in {
185 session.metadata.insert(
186 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
187 opted_in.to_string(),
188 );
189 }
190
191 let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
193 &base_prompt,
194 enhance_prompt.as_deref(),
195 workspace_path.as_deref(),
196 );
197
198 session.metadata.insert(
199 PROMPT_COMPOSER_VERSION_KEY.to_string(),
200 prompt_profile.version.to_string(),
201 );
202 session.metadata.insert(
203 PROMPT_FINGERPRINT_KEY.to_string(),
204 prompt_profile.fingerprint.clone(),
205 );
206 session.metadata.insert(
207 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
208 prompt_profile.component_flags_value(),
209 );
210 session.metadata.insert(
211 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
212 prompt_profile.component_lengths_value(),
213 );
214
215 session
217 .messages
218 .retain(|message| !matches!(message.role, Role::System));
219 session.messages.insert(0, Message::system(system_prompt));
220 refresh_prompt_snapshot(&mut session);
221
222 let request_model_ref = derive_model_ref(
224 input.model_ref.as_ref(),
225 input.provider.as_deref(),
226 Some(input.model.as_str()),
227 );
228 if let Some(model_ref) = request_model_ref.as_ref() {
229 persist_model_ref(&mut session, model_ref);
230 } else {
231 persist_legacy_model_provider(
232 &mut session,
233 Some(input.model.as_str()),
234 input.provider.as_deref(),
235 );
236 }
237
238 Ok(session)
239}
240
241pub fn resolve_base_prompt(
244 session: &mut Session,
245 base_prompt_from_request: Option<&str>,
246 global_default_template: &str,
247 builtin_fallback: &str,
248) -> String {
249 let resolved = base_prompt_from_request
250 .map(ToString::to_string)
251 .or_else(|| {
252 session
253 .metadata
254 .get(BASE_SYSTEM_PROMPT_KEY)
255 .map(String::as_str)
256 .map(str::trim)
257 .filter(|value| !value.is_empty())
258 .map(ToString::to_string)
259 })
260 .or_else(|| {
261 session
262 .messages
263 .iter()
264 .find(|message| matches!(message.role, Role::System))
265 .map(|message| message.content.trim().to_string())
266 .filter(|value| !value.is_empty())
267 })
268 .unwrap_or_else(|| {
269 let trimmed = global_default_template.trim();
270 if trimmed.is_empty() {
271 builtin_fallback.to_string()
272 } else {
273 trimmed.to_string()
274 }
275 });
276
277 session
278 .metadata
279 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
280 resolved
281}
282
283pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
284 if let Some(prompt) = enhance_prompt_from_request {
285 session.set_enhance_prompt(prompt);
286 } else {
287 session.clear_enhance_prompt();
288 }
289}
290
291pub fn resolve_copilot_conclusion_with_options_enhancement(
292 session: &mut Session,
293 enabled_from_request: Option<bool>,
294) {
295 if let Some(enabled) = enabled_from_request {
296 session
297 .metadata
298 .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
299 } else {
300 session.metadata.remove(COPILOT_CONCLUSION_KEY);
301 }
302}
303
304pub fn resolve_workspace_path(
305 session: &mut Session,
306 workspace_path_from_request: Option<&str>,
307 data_dir: Option<&Path>,
308) -> Option<String> {
309 resolve_workspace_path_with_default(session, workspace_path_from_request, None, data_dir)
310}
311
312fn resolve_workspace_path_with_default(
313 session: &mut Session,
314 workspace_path_from_request: Option<&str>,
315 default_workspace_path: Option<&str>,
316 data_dir: Option<&Path>,
317) -> Option<String> {
318 if let Some(path) = workspace_path_from_request {
319 session.set_workspace_path_meta(path);
320 }
321
322 let resolved = workspace_path_from_request
323 .map(ToString::to_string)
324 .or_else(|| session.workspace_path_meta())
325 .or_else(|| default_workspace_path.map(ToString::to_string))
326 .or_else(|| resolve_default_workspace(data_dir));
327 if let Some(workspace) = resolved.as_ref() {
328 session.set_workspace_path_meta(workspace.clone());
333 }
334 resolved
335}
336
337fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
348 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
349 bamboo_agent_core::workspace_state::get_configured_default_workspace()
350 } else {
351 default_workspace_from_data_dir(data_dir)
352 };
353 configured.map(|path| path_to_display_string(&path))
354}
355
356fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
359 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
360}
361
362pub fn resolve_selected_skill_ids(
363 session: &mut Session,
364 selected_skill_ids_from_request: Option<&[String]>,
365 message: &str,
366) {
367 if let Some(request_ids) = selected_skill_ids_from_request {
368 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
369 persist_selected_skill_ids_metadata(session, normalized.as_deref());
370 return;
371 }
372
373 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
374 if let Some(ids) = from_hint.as_ref() {
375 persist_selected_skill_ids_metadata(session, Some(ids));
376 return;
377 }
378
379 session.clear_selected_skill_ids();
380}
381
382pub fn resolve_workflow_selection(
383 session: &mut Session,
384 workflow_selection: Option<&WorkflowSelection>,
385 selected_skill_ids_from_request: Option<&[String]>,
386 message: &str,
387) -> Result<(), ChatError> {
388 if let Some(selection) = workflow_selection {
389 let id = selection.id.trim();
390 if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
391 return Err(ChatError::InvalidWorkflowSelection(
392 "id must be non-empty, revision must be positive, and args must be an object"
393 .to_string(),
394 ));
395 }
396 let previous = session
397 .metadata
398 .get(WORKFLOW_SELECTION_METADATA_KEY)
399 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
400 let selection_changed = previous.as_ref() != Some(selection);
401 session.metadata.insert(
402 WORKFLOW_SELECTION_METADATA_KEY.to_string(),
403 serde_json::to_string(selection).map_err(|_| {
404 ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
405 })?,
406 );
407 persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
408 if selection_changed {
409 deactivate_active_workflow(session);
410 clear_skill_runtime_state(session);
411 }
412 return Ok(());
413 }
414
415 if selected_skill_ids_from_request.is_some() {
416 session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
419 deactivate_active_workflow(session);
420 resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
421 clear_skill_runtime_state(session);
422 return Ok(());
423 }
424
425 if let Some(active) = session
426 .metadata
427 .get(ACTIVE_WORKFLOW_METADATA_KEY)
428 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
429 .filter(|active| active.status == WorkflowActivationStatus::Active)
430 {
431 persist_selected_skill_ids_metadata(session, Some(&[active.id]));
432 return Ok(());
433 }
434
435 resolve_selected_skill_ids(session, None, message);
438 Ok(())
439}
440
441fn deactivate_active_workflow(session: &mut Session) {
442 if let Some(active) = session
443 .metadata
444 .get(ACTIVE_WORKFLOW_METADATA_KEY)
445 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
446 .filter(|active| active.status == WorkflowActivationStatus::Active)
447 {
448 session.metadata.insert(
449 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
450 serde_json::json!({
451 "type": "workflow.deactivated",
452 "workflow_id": active.id,
453 "revision": active.revision,
454 "deactivated_at": chrono::Utc::now(),
455 })
456 .to_string(),
457 );
458 }
459 session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
460 session
461 .metadata
462 .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
463}
464
465pub fn clear_skill_runtime_state(session: &mut Session) {
467 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
468 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
469}
470
471fn persist_selected_skill_ids_metadata(
472 session: &mut Session,
473 selected_skill_ids: Option<&[String]>,
474) {
475 match selected_skill_ids {
476 Some(ids) if !ids.is_empty() => {
477 session.set_selected_skill_ids(ids.to_vec());
478 }
479 _ => {
480 session.clear_selected_skill_ids();
481 }
482 }
483}
484
485#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum GoalCommand {
490 Status,
492 Off,
494 Clear,
496 On,
498 SetPrompt(String),
500}
501
502pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
505 let trimmed = message.trim();
506 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
507 return None;
508 }
509 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
512 return None;
513 }
514
515 let arg = rest.trim().to_ascii_lowercase();
516
517 if arg.is_empty() {
518 return Some(GoalCommand::Status);
519 }
520
521 match arg.as_str() {
522 "status" => Some(GoalCommand::Status),
523 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
524 "clear" | "reset" => Some(GoalCommand::Clear),
525 "on" | "enable" | "enabled" => Some(GoalCommand::On),
526 _ => {
527 let prompt = trimmed
530 .strip_prefix("/goal")
531 .unwrap_or(trimmed)
532 .trim()
533 .to_string();
534 if prompt.is_empty() {
535 Some(GoalCommand::Status)
536 } else {
537 Some(GoalCommand::SetPrompt(prompt))
538 }
539 }
540 }
541}
542
543fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
544 const HINT_PREFIX: &str = "[User explicitly selected skill:";
545 let mut extracted = Vec::new();
546
547 for line in message.lines() {
548 let trimmed = line.trim();
549 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
550 continue;
551 }
552
553 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
554 continue;
555 };
556 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
557 let Some(close_paren_index) = id_segment.find(')') else {
558 continue;
559 };
560 let id = id_segment[..close_paren_index].trim();
561 if !id.is_empty() {
562 extracted.push(id.to_string());
563 }
564 }
565
566 extracted
567}
568
569#[derive(Debug, Clone, PartialEq, Eq)]
572struct PromptCompositionProfile {
573 version: &'static str,
574 fingerprint: String,
575 has_enhancement: bool,
576 has_workspace_context: bool,
577 has_env_context: bool,
578 base_len: usize,
579 enhancement_len: usize,
580 workspace_context_len: usize,
581 env_context_len: usize,
582 final_len: usize,
583}
584
585impl PromptCompositionProfile {
586 fn component_flags_value(&self) -> String {
587 format!(
588 "enhance={};workspace={};env={}",
589 self.has_enhancement as u8,
590 self.has_workspace_context as u8,
591 self.has_env_context as u8,
592 )
593 }
594
595 fn component_lengths_value(&self) -> String {
596 format!(
597 "base={};enhance={};workspace={};env={};final={}",
598 self.base_len,
599 self.enhancement_len,
600 self.workspace_context_len,
601 self.env_context_len,
602 self.final_len
603 )
604 }
605}
606
607fn build_prompt_fingerprint(
608 base_prompt: &str,
609 enhancement: Option<&str>,
610 workspace: Option<&str>,
611 env_context: Option<&str>,
612) -> String {
613 let mut hasher = Sha256::new();
614 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
615 hasher.update([0u8]);
616 hasher.update(base_prompt.as_bytes());
617 hasher.update([0u8]);
618 hasher.update(enhancement.unwrap_or_default().as_bytes());
619 hasher.update([0u8]);
620 hasher.update(workspace.unwrap_or_default().as_bytes());
621 hasher.update([0u8]);
622 hasher.update(env_context.unwrap_or_default().as_bytes());
623 hex::encode(hasher.finalize())
624}
625
626fn build_enhanced_system_prompt_with_profile(
627 base_prompt: &str,
628 enhance_prompt: Option<&str>,
629 workspace_path: Option<&str>,
630) -> (String, PromptCompositionProfile) {
631 let mut merged_prompt = base_prompt.to_string();
632
633 let enhancement = enhance_prompt
634 .map(str::trim)
635 .filter(|enhancement| !enhancement.is_empty())
636 .map(ToString::to_string);
637 if let Some(enhancement) = enhancement.as_ref() {
638 merged_prompt.push_str("\n\n");
639 merged_prompt.push_str(enhancement.as_str());
640 }
641
642 let workspace_context = workspace_path
643 .map(str::trim)
644 .filter(|workspace_path| !workspace_path.is_empty())
645 .and_then(build_workspace_prompt_context);
646 if let Some(workspace_context) = workspace_context.as_ref() {
647 merged_prompt.push_str("\n\n");
648 merged_prompt.push_str(workspace_context.as_str());
649 }
650
651 let env_context = build_env_prompt_context();
652 if let Some(env_context) = env_context.as_ref() {
653 merged_prompt.push_str("\n\n");
654 merged_prompt.push_str(env_context.as_str());
655 }
656
657 let profile = PromptCompositionProfile {
658 version: PROMPT_COMPOSER_VERSION,
659 fingerprint: build_prompt_fingerprint(
660 base_prompt,
661 enhancement.as_deref(),
662 workspace_context.as_deref(),
663 env_context.as_deref(),
664 ),
665 has_enhancement: enhancement.is_some(),
666 has_workspace_context: workspace_context.is_some(),
667 has_env_context: env_context.is_some(),
668 base_len: base_prompt.len(),
669 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
670 workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
671 env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
672 final_len: merged_prompt.len(),
673 };
674
675 (merged_prompt, profile)
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use crate::session_app::errors::{SessionLoadError, SessionSaveError};
682 use async_trait::async_trait;
683 use std::sync::atomic::{AtomicUsize, Ordering};
684 use std::sync::{Arc, Mutex};
685
686 struct InMemorySessionAccess;
687
688 struct ExistingSessionAccess(Session);
689
690 struct BarrierSessionAccess {
691 session: Mutex<Session>,
692 load_started: tokio::sync::Notify,
693 release_load: tokio::sync::Notify,
694 save_count: AtomicUsize,
695 }
696
697 #[async_trait]
698 impl SessionAccess for InMemorySessionAccess {
699 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
700 Ok(None)
701 }
702
703 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
704 Ok(Session::new(id, model))
705 }
706
707 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
708 Ok(None)
709 }
710
711 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
712 Ok(())
713 }
714
715 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
716 Ok(())
717 }
718 }
719
720 #[async_trait]
721 impl SessionAccess for ExistingSessionAccess {
722 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
723 Ok(Some(self.0.clone()))
724 }
725
726 async fn load_or_create(
727 &self,
728 _id: &str,
729 _model: &str,
730 ) -> Result<Session, SessionLoadError> {
731 panic!("existing chat session must not be recreated")
732 }
733
734 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
735 Ok(Some(self.0.clone()))
736 }
737
738 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
739 Ok(())
740 }
741
742 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
743 Ok(())
744 }
745 }
746
747 #[async_trait]
748 impl SessionAccess for BarrierSessionAccess {
749 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
750 Ok(Some(self.session.lock().expect("session lock").clone()))
751 }
752
753 async fn load_or_create(
754 &self,
755 _id: &str,
756 _model: &str,
757 ) -> Result<Session, SessionLoadError> {
758 panic!("barrier session already exists")
759 }
760
761 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
762 self.load_started.notify_one();
763 self.release_load.notified().await;
764 Ok(Some(self.session.lock().expect("session lock").clone()))
765 }
766
767 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
768 self.save_count.fetch_add(1, Ordering::SeqCst);
769 Ok(())
770 }
771
772 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
773 self.save_count.fetch_add(1, Ordering::SeqCst);
774 Ok(())
775 }
776 }
777
778 fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
779 super::super::types::ChatTurnInput {
780 session_id: "session-enhance".to_string(),
781 project_id: None,
782 model: "gpt-5".to_string(),
783 model_ref: None,
784 provider: None,
785 message: "hello".to_string(),
786 system_prompt: Some("Base prompt".to_string()),
787 enhance_prompt: enhance_prompt.map(ToString::to_string),
788 workspace_path: None,
789 default_workspace_path: None,
790 selected_skill_ids: None,
791 workflow_selection: None,
792 orchestration_opt_in: None,
793 copilot_conclusion_with_options_enhancement_enabled: None,
794 data_dir: None,
795 }
796 }
797
798 fn system_message_content(session: &Session) -> String {
799 session
800 .messages
801 .iter()
802 .find(|message| matches!(message.role, Role::System))
803 .map(|message| message.content.clone())
804 .expect("session should have a system message")
805 }
806
807 fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
808 ActiveWorkflow {
809 id: id.to_string(),
810 source: bamboo_skills::WorkflowSource::User,
811 revision,
812 kind: bamboo_skills::WorkflowKind::Instruction,
813 args: serde_json::json!({}),
814 invoked_by: bamboo_skills::WorkflowInvokedBy::User,
815 activated_at: chrono::Utc::now(),
816 status: WorkflowActivationStatus::Active,
817 diagnostic: None,
818 context_fingerprint: Some("fingerprint".to_string()),
819 dynamic_context: Vec::new(),
820 }
821 }
822
823 #[tokio::test]
824 async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
825 let startup = prepare_chat_turn(
826 &InMemorySessionAccess,
827 chat_turn_input(None),
828 "global",
829 "builtin",
830 )
831 .await
832 .expect("new turn");
833 assert_eq!(
834 startup
835 .metadata
836 .get(SESSION_START_SOURCE_METADATA_KEY)
837 .map(String::as_str),
838 Some("startup")
839 );
840
841 let existing = prepare_chat_turn(
842 &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
843 chat_turn_input(None),
844 "global",
845 "builtin",
846 )
847 .await
848 .expect("existing turn");
849 assert_eq!(
850 existing
851 .metadata
852 .get(SESSION_START_SOURCE_METADATA_KEY)
853 .map(String::as_str),
854 Some("resume")
855 );
856 }
857
858 #[tokio::test]
859 async fn prepare_chat_turn_rechecks_project_identity_after_preflight_race() {
860 let project_a =
861 bamboo_domain::ProjectId::parse("project-chat-a").expect("Project A identity");
862 let project_b =
863 bamboo_domain::ProjectId::parse("project-chat-b").expect("Project B identity");
864 let workspace_a = tempfile::tempdir().expect("workspace A");
865 let workspace_b = tempfile::tempdir().expect("workspace B");
866 let mut session = Session::new("chat-project-race", "gpt-5");
867 session.set_project_id_meta(project_a.to_string());
868 session.set_workspace_path_meta(workspace_a.path().to_string_lossy().into_owned());
869 let repo = Arc::new(BarrierSessionAccess {
870 session: Mutex::new(session),
871 load_started: tokio::sync::Notify::new(),
872 release_load: tokio::sync::Notify::new(),
873 save_count: AtomicUsize::new(0),
874 });
875 let mut input = chat_turn_input(None);
876 input.session_id = "chat-project-race".to_string();
877 input.project_id = Some(project_a.clone());
878 input.workspace_path = Some(workspace_a.path().to_string_lossy().into_owned());
879
880 let task_repo = repo.clone();
881 let task = tokio::spawn(async move {
882 prepare_chat_turn(task_repo.as_ref(), input, "global", "builtin").await
883 });
884 repo.load_started.notified().await;
885 {
886 let mut raced = repo.session.lock().expect("session lock");
887 raced.set_project_id_meta(project_b.to_string());
888 raced.set_workspace_path_meta(workspace_b.path().to_string_lossy().into_owned());
889 }
890 repo.release_load.notify_one();
891
892 let error = task
893 .await
894 .expect("chat task")
895 .expect_err("membership race must fail closed");
896 assert!(matches!(
897 error,
898 ChatError::ProjectIdentityConflict {
899 expected: Some(expected),
900 actual: Some(actual),
901 } if expected == project_a && actual == project_b
902 ));
903 assert_eq!(repo.save_count.load(Ordering::SeqCst), 0);
904 let persisted = repo.session.lock().expect("session lock");
905 assert_eq!(
906 persisted.workspace_path_meta().as_deref(),
907 Some(workspace_b.path().to_string_lossy().as_ref())
908 );
909 assert!(persisted.messages.is_empty());
910 assert!(!persisted
911 .metadata
912 .contains_key(SESSION_START_SOURCE_METADATA_KEY));
913 }
914
915 #[test]
916 fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
917 let mut session = Session::new("typed-selection", "model");
918 let selection = WorkflowSelection {
919 id: "review".to_string(),
920 source: bamboo_skills::WorkflowSource::User,
921 revision: 7,
922 args: serde_json::json!({"depth": "full"}),
923 };
924 resolve_workflow_selection(
925 &mut session,
926 Some(&selection),
927 Some(&["plan".to_string()]),
928 "use skill plan",
929 )
930 .expect("typed selection");
931 assert_eq!(
932 session.selected_skill_ids(),
933 Some(vec!["review".to_string()])
934 );
935 assert_eq!(
936 session
937 .metadata
938 .get(WORKFLOW_SELECTION_METADATA_KEY)
939 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
940 Some(selection)
941 );
942 }
943
944 #[test]
945 fn active_workflow_survives_turn_without_new_selection() {
946 let mut session = Session::new("active-selection", "model");
947 session.metadata.insert(
948 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
949 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
950 );
951 resolve_workflow_selection(&mut session, None, None, "use skill plan")
952 .expect("preserve active");
953 assert_eq!(
954 session.selected_skill_ids(),
955 Some(vec!["review".to_string()])
956 );
957 assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
958 }
959
960 #[test]
961 fn explicit_empty_legacy_selection_deactivates_active_workflow() {
962 let mut session = Session::new("deactivate-selection", "model");
963 session.metadata.insert(
964 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
965 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
966 );
967 resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
968 .expect("deactivate");
969 assert!(session.selected_skill_ids().is_none());
970 assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
971 assert!(session
972 .metadata
973 .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
974 .is_some_and(|event| event.contains("workflow.deactivated")));
975 }
976
977 #[tokio::test]
980 async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
981 let session = prepare_chat_turn(
982 &InMemorySessionAccess,
983 chat_turn_input(Some("Extra enhancement guidance")),
984 "",
985 "Builtin fallback",
986 )
987 .await
988 .expect("prepare_chat_turn should succeed");
989
990 let system_prompt = system_message_content(&session);
991 assert!(system_prompt.starts_with("Base prompt"));
992 assert!(system_prompt.contains("Extra enhancement guidance"));
993 assert_eq!(
994 session.enhance_prompt().as_deref(),
995 Some("Extra enhancement guidance")
996 );
997 assert!(session
998 .metadata
999 .get(PROMPT_COMPONENT_FLAGS_KEY)
1000 .is_some_and(|flags| flags.contains("enhance=1")));
1001 }
1002
1003 #[tokio::test]
1004 async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
1005 let session = prepare_chat_turn(
1006 &InMemorySessionAccess,
1007 chat_turn_input(None),
1008 "",
1009 "Builtin fallback",
1010 )
1011 .await
1012 .expect("prepare_chat_turn should succeed");
1013
1014 let system_prompt = system_message_content(&session);
1015 assert!(system_prompt.starts_with("Base prompt"));
1016 assert!(session.enhance_prompt().is_none());
1017 assert!(session
1018 .metadata
1019 .get(PROMPT_COMPONENT_FLAGS_KEY)
1020 .is_some_and(|flags| flags.contains("enhance=0")));
1021 }
1022
1023 #[test]
1029 fn default_workspace_from_data_dir_reads_configured_work_area() {
1030 let temp = tempfile::tempdir().expect("temp dir");
1031 let workspace = temp.path().join("default-workspace");
1032 std::fs::create_dir_all(&workspace).expect("workspace dir");
1033 std::fs::write(
1034 temp.path().join("config.json"),
1035 serde_json::json!({
1036 "default_work_area": { "path": workspace.to_string_lossy() }
1037 })
1038 .to_string(),
1039 )
1040 .expect("write config.json");
1041
1042 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
1043 assert_eq!(
1047 resolved.canonicalize().unwrap(),
1048 workspace.canonicalize().unwrap()
1049 );
1050 }
1051
1052 #[test]
1053 fn default_workspace_from_data_dir_is_none_without_config() {
1054 let temp = tempfile::tempdir().expect("temp dir");
1055 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
1056 }
1057}