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, ChatWorkspaceFallbackPolicy};
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 prepare_chat_turn_from_authoritative_session_with_workspace_policy(
112 existing,
113 input,
114 global_default_prompt,
115 builtin_fallback_prompt,
116 ChatWorkspaceFallbackPolicy::Legacy,
117 )
118}
119
120pub fn prepare_chat_turn_from_authoritative_session_with_workspace_policy(
127 existing: Option<Session>,
128 input: ChatTurnInput,
129 global_default_prompt: &str,
130 builtin_fallback_prompt: &str,
131 workspace_fallback_policy: ChatWorkspaceFallbackPolicy,
132) -> Result<Session, ChatError> {
133 let (mut session, session_start_source) = match existing {
134 Some(session) => (session, "resume"),
135 None => (
136 Session::new(input.session_id.clone(), input.model.clone()),
137 "startup",
138 ),
139 };
140 match crate::project_context::ProjectContextResolver::session_project_identity(&session) {
141 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
142 return Err(ChatError::InvalidProjectIdentity { raw, message });
143 }
144 crate::project_context::SessionProjectIdentity::Assigned(actual)
145 if input.project_id.as_ref() != Some(&actual) =>
146 {
147 return Err(ChatError::ProjectIdentityConflict {
148 expected: input.project_id,
149 actual: Some(actual),
150 });
151 }
152 crate::project_context::SessionProjectIdentity::Unassigned
153 if session_start_source == "resume" && input.project_id.is_some() =>
154 {
155 return Err(ChatError::ProjectIdentityConflict {
156 expected: input.project_id,
157 actual: None,
158 });
159 }
160 crate::project_context::SessionProjectIdentity::Assigned(_)
161 | crate::project_context::SessionProjectIdentity::Unassigned => {}
162 }
163 session.metadata.insert(
164 SESSION_START_SOURCE_METADATA_KEY.to_string(),
165 session_start_source.to_string(),
166 );
167 if session_start_source == "startup" {
168 if let Some(project_id) = input.project_id.as_ref() {
169 session.set_project_id_meta(project_id.to_string());
170 }
171 }
172
173 let base_prompt = resolve_base_prompt(
175 &mut session,
176 input.system_prompt.as_deref(),
177 global_default_prompt,
178 builtin_fallback_prompt,
179 );
180
181 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
183 let enhance_prompt = session.enhance_prompt();
184
185 resolve_copilot_conclusion_with_options_enhancement(
187 &mut session,
188 input.copilot_conclusion_with_options_enhancement_enabled,
189 );
190
191 let allow_legacy_workspace_fallback = matches!(
193 crate::project_context::ProjectContextResolver::session_project_identity(&session),
194 crate::project_context::SessionProjectIdentity::Unassigned
195 );
196 let workspace_path = resolve_workspace_path_with_default(
197 &mut session,
198 input.workspace_path.as_deref(),
199 input.default_workspace_path.as_deref(),
200 &workspace_fallback_policy,
201 input.data_dir.as_deref(),
202 allow_legacy_workspace_fallback,
203 );
204
205 resolve_workflow_selection(
207 &mut session,
208 input.workflow_selection.as_ref(),
209 input.selected_skill_ids.as_deref(),
210 &input.message,
211 )?;
212 if let Some(opted_in) = input.orchestration_opt_in {
213 session.metadata.insert(
214 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
215 opted_in.to_string(),
216 );
217 }
218
219 let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
221 &base_prompt,
222 enhance_prompt.as_deref(),
223 workspace_path.as_deref(),
224 );
225
226 session.metadata.insert(
227 PROMPT_COMPOSER_VERSION_KEY.to_string(),
228 prompt_profile.version.to_string(),
229 );
230 session.metadata.insert(
231 PROMPT_FINGERPRINT_KEY.to_string(),
232 prompt_profile.fingerprint.clone(),
233 );
234 session.metadata.insert(
235 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
236 prompt_profile.component_flags_value(),
237 );
238 session.metadata.insert(
239 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
240 prompt_profile.component_lengths_value(),
241 );
242
243 session
245 .messages
246 .retain(|message| !matches!(message.role, Role::System));
247 session.messages.insert(0, Message::system(system_prompt));
248 refresh_prompt_snapshot(&mut session);
249
250 let request_model_ref = derive_model_ref(
252 input.model_ref.as_ref(),
253 input.provider.as_deref(),
254 Some(input.model.as_str()),
255 );
256 if let Some(model_ref) = request_model_ref.as_ref() {
257 persist_model_ref(&mut session, model_ref);
258 } else {
259 persist_legacy_model_provider(
260 &mut session,
261 Some(input.model.as_str()),
262 input.provider.as_deref(),
263 );
264 }
265
266 Ok(session)
267}
268
269pub fn resolve_base_prompt(
272 session: &mut Session,
273 base_prompt_from_request: Option<&str>,
274 global_default_template: &str,
275 builtin_fallback: &str,
276) -> String {
277 let resolved = base_prompt_from_request
278 .map(ToString::to_string)
279 .or_else(|| {
280 session
281 .metadata
282 .get(BASE_SYSTEM_PROMPT_KEY)
283 .map(String::as_str)
284 .map(str::trim)
285 .filter(|value| !value.is_empty())
286 .map(ToString::to_string)
287 })
288 .or_else(|| {
289 session
290 .messages
291 .iter()
292 .find(|message| matches!(message.role, Role::System))
293 .map(|message| message.content.trim().to_string())
294 .filter(|value| !value.is_empty())
295 })
296 .unwrap_or_else(|| {
297 let trimmed = global_default_template.trim();
298 if trimmed.is_empty() {
299 builtin_fallback.to_string()
300 } else {
301 trimmed.to_string()
302 }
303 });
304
305 session
306 .metadata
307 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
308 resolved
309}
310
311pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
312 if let Some(prompt) = enhance_prompt_from_request {
313 session.set_enhance_prompt(prompt);
314 } else {
315 session.clear_enhance_prompt();
316 }
317}
318
319pub fn resolve_copilot_conclusion_with_options_enhancement(
320 session: &mut Session,
321 enabled_from_request: Option<bool>,
322) {
323 if let Some(enabled) = enabled_from_request {
324 session
325 .metadata
326 .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
327 } else {
328 session.metadata.remove(COPILOT_CONCLUSION_KEY);
329 }
330}
331
332pub fn resolve_workspace_path(
333 session: &mut Session,
334 workspace_path_from_request: Option<&str>,
335 data_dir: Option<&Path>,
336) -> Option<String> {
337 resolve_workspace_path_with_default(
338 session,
339 workspace_path_from_request,
340 None,
341 &ChatWorkspaceFallbackPolicy::Legacy,
342 data_dir,
343 true,
344 )
345}
346
347fn resolve_workspace_path_with_default(
348 session: &mut Session,
349 workspace_path_from_request: Option<&str>,
350 default_workspace_path: Option<&str>,
351 fallback_policy: &ChatWorkspaceFallbackPolicy,
352 data_dir: Option<&Path>,
353 allow_legacy_fallback: bool,
354) -> Option<String> {
355 if let Some(path) = workspace_path_from_request {
356 session.set_workspace_path_meta(path);
357 session.metadata.insert(
358 crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
359 crate::project_context::WorkspaceSource::Explicit
360 .as_str()
361 .to_string(),
362 );
363 }
364
365 let resolved = workspace_path_from_request
366 .map(ToString::to_string)
367 .or_else(|| session.workspace_path_meta())
368 .or_else(|| {
369 allow_legacy_fallback
370 .then(|| default_workspace_path.map(ToString::to_string))
371 .flatten()
372 })
373 .or_else(|| {
374 allow_legacy_fallback
375 .then(|| {
376 resolve_workspace_fallback_with(fallback_policy, || {
377 resolve_default_workspace(data_dir)
378 })
379 })
380 .flatten()
381 });
382 if let Some(workspace) = resolved.as_ref() {
383 session.set_workspace_path_meta(workspace.clone());
388 }
389 resolved
390}
391
392fn resolve_workspace_fallback_with<L>(
393 fallback_policy: &ChatWorkspaceFallbackPolicy,
394 legacy_lookup: L,
395) -> Option<String>
396where
397 L: FnOnce() -> Option<String>,
398{
399 match fallback_policy {
400 ChatWorkspaceFallbackPolicy::Legacy => legacy_lookup(),
401 ChatWorkspaceFallbackPolicy::Authoritative {
402 session_fallback_path,
403 } => session_fallback_path.clone(),
404 }
405}
406
407fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
418 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
419 bamboo_agent_core::workspace_state::get_configured_default_workspace()
420 } else {
421 default_workspace_from_data_dir(data_dir)
422 };
423 configured.map(|path| path_to_display_string(&path))
424}
425
426fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
429 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
430}
431
432pub fn resolve_selected_skill_ids(
433 session: &mut Session,
434 selected_skill_ids_from_request: Option<&[String]>,
435 message: &str,
436) {
437 if let Some(request_ids) = selected_skill_ids_from_request {
438 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
439 persist_selected_skill_ids_metadata(session, normalized.as_deref());
440 return;
441 }
442
443 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
444 if let Some(ids) = from_hint.as_ref() {
445 persist_selected_skill_ids_metadata(session, Some(ids));
446 return;
447 }
448
449 session.clear_selected_skill_ids();
450}
451
452pub fn resolve_workflow_selection(
453 session: &mut Session,
454 workflow_selection: Option<&WorkflowSelection>,
455 selected_skill_ids_from_request: Option<&[String]>,
456 message: &str,
457) -> Result<(), ChatError> {
458 if let Some(selection) = workflow_selection {
459 let id = selection.id.trim();
460 if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
461 return Err(ChatError::InvalidWorkflowSelection(
462 "id must be non-empty, revision must be positive, and args must be an object"
463 .to_string(),
464 ));
465 }
466 let previous = session
467 .metadata
468 .get(WORKFLOW_SELECTION_METADATA_KEY)
469 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
470 let selection_changed = previous.as_ref() != Some(selection);
471 session.metadata.insert(
472 WORKFLOW_SELECTION_METADATA_KEY.to_string(),
473 serde_json::to_string(selection).map_err(|_| {
474 ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
475 })?,
476 );
477 persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
478 if selection_changed {
479 deactivate_active_workflow(session);
480 clear_skill_runtime_state(session);
481 }
482 return Ok(());
483 }
484
485 if selected_skill_ids_from_request.is_some() {
486 session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
489 deactivate_active_workflow(session);
490 resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
491 clear_skill_runtime_state(session);
492 return Ok(());
493 }
494
495 if let Some(active) = session
496 .metadata
497 .get(ACTIVE_WORKFLOW_METADATA_KEY)
498 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
499 .filter(|active| active.status == WorkflowActivationStatus::Active)
500 {
501 persist_selected_skill_ids_metadata(session, Some(&[active.id]));
502 return Ok(());
503 }
504
505 resolve_selected_skill_ids(session, None, message);
508 Ok(())
509}
510
511fn deactivate_active_workflow(session: &mut Session) {
512 if let Some(active) = session
513 .metadata
514 .get(ACTIVE_WORKFLOW_METADATA_KEY)
515 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
516 .filter(|active| active.status == WorkflowActivationStatus::Active)
517 {
518 session.metadata.insert(
519 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
520 serde_json::json!({
521 "type": "workflow.deactivated",
522 "workflow_id": active.id,
523 "revision": active.revision,
524 "deactivated_at": chrono::Utc::now(),
525 })
526 .to_string(),
527 );
528 }
529 session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
530 session
531 .metadata
532 .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
533}
534
535pub fn clear_skill_runtime_state(session: &mut Session) {
537 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
538 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
539}
540
541fn persist_selected_skill_ids_metadata(
542 session: &mut Session,
543 selected_skill_ids: Option<&[String]>,
544) {
545 match selected_skill_ids {
546 Some(ids) if !ids.is_empty() => {
547 session.set_selected_skill_ids(ids.to_vec());
548 }
549 _ => {
550 session.clear_selected_skill_ids();
551 }
552 }
553}
554
555#[derive(Debug, Clone, PartialEq, Eq)]
559pub enum GoalCommand {
560 Status,
562 Off,
564 Clear,
566 On,
568 SetPrompt(String),
570}
571
572pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
575 let trimmed = message.trim();
576 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
577 return None;
578 }
579 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
582 return None;
583 }
584
585 let arg = rest.trim().to_ascii_lowercase();
586
587 if arg.is_empty() {
588 return Some(GoalCommand::Status);
589 }
590
591 match arg.as_str() {
592 "status" => Some(GoalCommand::Status),
593 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
594 "clear" | "reset" => Some(GoalCommand::Clear),
595 "on" | "enable" | "enabled" => Some(GoalCommand::On),
596 _ => {
597 let prompt = trimmed
600 .strip_prefix("/goal")
601 .unwrap_or(trimmed)
602 .trim()
603 .to_string();
604 if prompt.is_empty() {
605 Some(GoalCommand::Status)
606 } else {
607 Some(GoalCommand::SetPrompt(prompt))
608 }
609 }
610 }
611}
612
613fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
614 const HINT_PREFIX: &str = "[User explicitly selected skill:";
615 let mut extracted = Vec::new();
616
617 for line in message.lines() {
618 let trimmed = line.trim();
619 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
620 continue;
621 }
622
623 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
624 continue;
625 };
626 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
627 let Some(close_paren_index) = id_segment.find(')') else {
628 continue;
629 };
630 let id = id_segment[..close_paren_index].trim();
631 if !id.is_empty() {
632 extracted.push(id.to_string());
633 }
634 }
635
636 extracted
637}
638
639#[derive(Debug, Clone, PartialEq, Eq)]
642struct PromptCompositionProfile {
643 version: &'static str,
644 fingerprint: String,
645 has_enhancement: bool,
646 has_workspace_context: bool,
647 has_env_context: bool,
648 base_len: usize,
649 enhancement_len: usize,
650 workspace_context_len: usize,
651 env_context_len: usize,
652 final_len: usize,
653}
654
655impl PromptCompositionProfile {
656 fn component_flags_value(&self) -> String {
657 format!(
658 "enhance={};workspace={};env={}",
659 self.has_enhancement as u8,
660 self.has_workspace_context as u8,
661 self.has_env_context as u8,
662 )
663 }
664
665 fn component_lengths_value(&self) -> String {
666 format!(
667 "base={};enhance={};workspace={};env={};final={}",
668 self.base_len,
669 self.enhancement_len,
670 self.workspace_context_len,
671 self.env_context_len,
672 self.final_len
673 )
674 }
675}
676
677fn build_prompt_fingerprint(
678 base_prompt: &str,
679 enhancement: Option<&str>,
680 workspace: Option<&str>,
681 env_context: Option<&str>,
682) -> String {
683 let mut hasher = Sha256::new();
684 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
685 hasher.update([0u8]);
686 hasher.update(base_prompt.as_bytes());
687 hasher.update([0u8]);
688 hasher.update(enhancement.unwrap_or_default().as_bytes());
689 hasher.update([0u8]);
690 hasher.update(workspace.unwrap_or_default().as_bytes());
691 hasher.update([0u8]);
692 hasher.update(env_context.unwrap_or_default().as_bytes());
693 hex::encode(hasher.finalize())
694}
695
696fn build_enhanced_system_prompt_with_profile(
697 base_prompt: &str,
698 enhance_prompt: Option<&str>,
699 workspace_path: Option<&str>,
700) -> (String, PromptCompositionProfile) {
701 let mut merged_prompt = base_prompt.to_string();
702
703 let enhancement = enhance_prompt
704 .map(str::trim)
705 .filter(|enhancement| !enhancement.is_empty())
706 .map(ToString::to_string);
707 if let Some(enhancement) = enhancement.as_ref() {
708 merged_prompt.push_str("\n\n");
709 merged_prompt.push_str(enhancement.as_str());
710 }
711
712 let workspace_context = workspace_path
713 .map(str::trim)
714 .filter(|workspace_path| !workspace_path.is_empty())
715 .and_then(build_workspace_prompt_context);
716 if let Some(workspace_context) = workspace_context.as_ref() {
717 merged_prompt.push_str("\n\n");
718 merged_prompt.push_str(workspace_context.as_str());
719 }
720
721 let env_context = build_env_prompt_context();
722 if let Some(env_context) = env_context.as_ref() {
723 merged_prompt.push_str("\n\n");
724 merged_prompt.push_str(env_context.as_str());
725 }
726
727 let profile = PromptCompositionProfile {
728 version: PROMPT_COMPOSER_VERSION,
729 fingerprint: build_prompt_fingerprint(
730 base_prompt,
731 enhancement.as_deref(),
732 workspace_context.as_deref(),
733 env_context.as_deref(),
734 ),
735 has_enhancement: enhancement.is_some(),
736 has_workspace_context: workspace_context.is_some(),
737 has_env_context: env_context.is_some(),
738 base_len: base_prompt.len(),
739 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
740 workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
741 env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
742 final_len: merged_prompt.len(),
743 };
744
745 (merged_prompt, profile)
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use crate::session_app::errors::{SessionLoadError, SessionSaveError};
752 use async_trait::async_trait;
753 use std::sync::atomic::{AtomicUsize, Ordering};
754 use std::sync::{Arc, Mutex};
755
756 struct InMemorySessionAccess;
757
758 struct ExistingSessionAccess(Session);
759
760 struct BarrierSessionAccess {
761 session: Mutex<Session>,
762 load_started: tokio::sync::Notify,
763 release_load: tokio::sync::Notify,
764 save_count: AtomicUsize,
765 }
766
767 #[async_trait]
768 impl SessionAccess for InMemorySessionAccess {
769 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
770 Ok(None)
771 }
772
773 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
774 Ok(Session::new(id, model))
775 }
776
777 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
778 Ok(None)
779 }
780
781 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
782 Ok(())
783 }
784
785 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
786 Ok(())
787 }
788 }
789
790 #[async_trait]
791 impl SessionAccess for ExistingSessionAccess {
792 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
793 Ok(Some(self.0.clone()))
794 }
795
796 async fn load_or_create(
797 &self,
798 _id: &str,
799 _model: &str,
800 ) -> Result<Session, SessionLoadError> {
801 panic!("existing chat session must not be recreated")
802 }
803
804 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
805 Ok(Some(self.0.clone()))
806 }
807
808 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
809 Ok(())
810 }
811
812 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
813 Ok(())
814 }
815 }
816
817 #[async_trait]
818 impl SessionAccess for BarrierSessionAccess {
819 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
820 Ok(Some(self.session.lock().expect("session lock").clone()))
821 }
822
823 async fn load_or_create(
824 &self,
825 _id: &str,
826 _model: &str,
827 ) -> Result<Session, SessionLoadError> {
828 panic!("barrier session already exists")
829 }
830
831 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
832 self.load_started.notify_one();
833 self.release_load.notified().await;
834 Ok(Some(self.session.lock().expect("session lock").clone()))
835 }
836
837 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
838 self.save_count.fetch_add(1, Ordering::SeqCst);
839 Ok(())
840 }
841
842 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
843 self.save_count.fetch_add(1, Ordering::SeqCst);
844 Ok(())
845 }
846 }
847
848 fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
849 super::super::types::ChatTurnInput {
850 session_id: "session-enhance".to_string(),
851 project_id: None,
852 model: "gpt-5".to_string(),
853 model_ref: None,
854 provider: None,
855 message: "hello".to_string(),
856 system_prompt: Some("Base prompt".to_string()),
857 enhance_prompt: enhance_prompt.map(ToString::to_string),
858 workspace_path: None,
859 default_workspace_path: None,
860 selected_skill_ids: None,
861 workflow_selection: None,
862 orchestration_opt_in: None,
863 copilot_conclusion_with_options_enhancement_enabled: None,
864 data_dir: None,
865 }
866 }
867
868 fn system_message_content(session: &Session) -> String {
869 session
870 .messages
871 .iter()
872 .find(|message| matches!(message.role, Role::System))
873 .map(|message| message.content.clone())
874 .expect("session should have a system message")
875 }
876
877 fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
878 ActiveWorkflow {
879 id: id.to_string(),
880 source: bamboo_skills::WorkflowSource::User,
881 revision,
882 kind: bamboo_skills::WorkflowKind::Instruction,
883 args: serde_json::json!({}),
884 invoked_by: bamboo_skills::WorkflowInvokedBy::User,
885 activated_at: chrono::Utc::now(),
886 status: WorkflowActivationStatus::Active,
887 diagnostic: None,
888 context_fingerprint: Some("fingerprint".to_string()),
889 dynamic_context: Vec::new(),
890 }
891 }
892
893 #[tokio::test]
894 async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
895 let startup = prepare_chat_turn(
896 &InMemorySessionAccess,
897 chat_turn_input(None),
898 "global",
899 "builtin",
900 )
901 .await
902 .expect("new turn");
903 assert_eq!(
904 startup
905 .metadata
906 .get(SESSION_START_SOURCE_METADATA_KEY)
907 .map(String::as_str),
908 Some("startup")
909 );
910
911 let existing = prepare_chat_turn(
912 &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
913 chat_turn_input(None),
914 "global",
915 "builtin",
916 )
917 .await
918 .expect("existing turn");
919 assert_eq!(
920 existing
921 .metadata
922 .get(SESSION_START_SOURCE_METADATA_KEY)
923 .map(String::as_str),
924 Some("resume")
925 );
926 }
927
928 #[tokio::test]
929 async fn prepare_chat_turn_rechecks_project_identity_after_preflight_race() {
930 let project_a =
931 bamboo_domain::ProjectId::parse("project-chat-a").expect("Project A identity");
932 let project_b =
933 bamboo_domain::ProjectId::parse("project-chat-b").expect("Project B identity");
934 let workspace_a = tempfile::tempdir().expect("workspace A");
935 let workspace_b = tempfile::tempdir().expect("workspace B");
936 let mut session = Session::new("chat-project-race", "gpt-5");
937 session.set_project_id_meta(project_a.to_string());
938 session.set_workspace_path_meta(workspace_a.path().to_string_lossy().into_owned());
939 let repo = Arc::new(BarrierSessionAccess {
940 session: Mutex::new(session),
941 load_started: tokio::sync::Notify::new(),
942 release_load: tokio::sync::Notify::new(),
943 save_count: AtomicUsize::new(0),
944 });
945 let mut input = chat_turn_input(None);
946 input.session_id = "chat-project-race".to_string();
947 input.project_id = Some(project_a.clone());
948 input.workspace_path = Some(workspace_a.path().to_string_lossy().into_owned());
949
950 let task_repo = repo.clone();
951 let task = tokio::spawn(async move {
952 prepare_chat_turn(task_repo.as_ref(), input, "global", "builtin").await
953 });
954 repo.load_started.notified().await;
955 {
956 let mut raced = repo.session.lock().expect("session lock");
957 raced.set_project_id_meta(project_b.to_string());
958 raced.set_workspace_path_meta(workspace_b.path().to_string_lossy().into_owned());
959 }
960 repo.release_load.notify_one();
961
962 let error = task
963 .await
964 .expect("chat task")
965 .expect_err("membership race must fail closed");
966 assert!(matches!(
967 error,
968 ChatError::ProjectIdentityConflict {
969 expected: Some(expected),
970 actual: Some(actual),
971 } if expected == project_a && actual == project_b
972 ));
973 assert_eq!(repo.save_count.load(Ordering::SeqCst), 0);
974 let persisted = repo.session.lock().expect("session lock");
975 assert_eq!(
976 persisted.workspace_path_meta().as_deref(),
977 Some(workspace_b.path().to_string_lossy().as_ref())
978 );
979 assert!(persisted.messages.is_empty());
980 assert!(!persisted
981 .metadata
982 .contains_key(SESSION_START_SOURCE_METADATA_KEY));
983 }
984
985 #[test]
986 fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
987 let mut session = Session::new("typed-selection", "model");
988 let selection = WorkflowSelection {
989 id: "review".to_string(),
990 source: bamboo_skills::WorkflowSource::User,
991 revision: 7,
992 args: serde_json::json!({"depth": "full"}),
993 };
994 resolve_workflow_selection(
995 &mut session,
996 Some(&selection),
997 Some(&["plan".to_string()]),
998 "use skill plan",
999 )
1000 .expect("typed selection");
1001 assert_eq!(
1002 session.selected_skill_ids(),
1003 Some(vec!["review".to_string()])
1004 );
1005 assert_eq!(
1006 session
1007 .metadata
1008 .get(WORKFLOW_SELECTION_METADATA_KEY)
1009 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
1010 Some(selection)
1011 );
1012 }
1013
1014 #[test]
1015 fn active_workflow_survives_turn_without_new_selection() {
1016 let mut session = Session::new("active-selection", "model");
1017 session.metadata.insert(
1018 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
1019 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
1020 );
1021 resolve_workflow_selection(&mut session, None, None, "use skill plan")
1022 .expect("preserve active");
1023 assert_eq!(
1024 session.selected_skill_ids(),
1025 Some(vec!["review".to_string()])
1026 );
1027 assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
1028 }
1029
1030 #[test]
1031 fn explicit_empty_legacy_selection_deactivates_active_workflow() {
1032 let mut session = Session::new("deactivate-selection", "model");
1033 session.metadata.insert(
1034 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
1035 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
1036 );
1037 resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
1038 .expect("deactivate");
1039 assert!(session.selected_skill_ids().is_none());
1040 assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
1041 assert!(session
1042 .metadata
1043 .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
1044 .is_some_and(|event| event.contains("workflow.deactivated")));
1045 }
1046
1047 #[tokio::test]
1050 async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
1051 let session = prepare_chat_turn(
1052 &InMemorySessionAccess,
1053 chat_turn_input(Some("Extra enhancement guidance")),
1054 "",
1055 "Builtin fallback",
1056 )
1057 .await
1058 .expect("prepare_chat_turn should succeed");
1059
1060 let system_prompt = system_message_content(&session);
1061 assert!(system_prompt.starts_with("Base prompt"));
1062 assert!(system_prompt.contains("Extra enhancement guidance"));
1063 assert_eq!(
1064 session.enhance_prompt().as_deref(),
1065 Some("Extra enhancement guidance")
1066 );
1067 assert!(session
1068 .metadata
1069 .get(PROMPT_COMPONENT_FLAGS_KEY)
1070 .is_some_and(|flags| flags.contains("enhance=1")));
1071 }
1072
1073 #[tokio::test]
1074 async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
1075 let session = prepare_chat_turn(
1076 &InMemorySessionAccess,
1077 chat_turn_input(None),
1078 "",
1079 "Builtin fallback",
1080 )
1081 .await
1082 .expect("prepare_chat_turn should succeed");
1083
1084 let system_prompt = system_message_content(&session);
1085 assert!(system_prompt.starts_with("Base prompt"));
1086 assert!(session.enhance_prompt().is_none());
1087 assert!(session
1088 .metadata
1089 .get(PROMPT_COMPONENT_FLAGS_KEY)
1090 .is_some_and(|flags| flags.contains("enhance=0")));
1091 }
1092
1093 #[test]
1094 fn authoritative_none_uses_session_fallback_during_prompt_composition() {
1095 let fixture = tempfile::tempdir().expect("fixture");
1096 let foreign_default = fixture.path().join("foreign-default");
1097 std::fs::create_dir_all(&foreign_default).expect("foreign default");
1098 std::fs::write(
1099 fixture.path().join("config.json"),
1100 serde_json::json!({
1101 "default_work_area": { "path": foreign_default.to_string_lossy() }
1102 })
1103 .to_string(),
1104 )
1105 .expect("write legacy config");
1106 let session_fallback = fixture
1107 .path()
1108 .join("request-state-root/workspaces/session-enhance");
1109 let session_fallback_display = path_to_display_string(&session_fallback);
1110 let mut input = chat_turn_input(None);
1111 input.data_dir = Some(fixture.path().to_path_buf());
1112
1113 let session = prepare_chat_turn_from_authoritative_session_with_workspace_policy(
1114 None,
1115 input,
1116 "global",
1117 "builtin",
1118 ChatWorkspaceFallbackPolicy::Authoritative {
1119 session_fallback_path: Some(session_fallback_display.clone()),
1120 },
1121 )
1122 .expect("authoritative turn");
1123
1124 assert_eq!(
1125 session.workspace_path_meta().as_deref(),
1126 Some(session_fallback_display.as_str())
1127 );
1128 let system_prompt = system_message_content(&session);
1129 assert!(
1130 system_prompt.contains(&session_fallback_display),
1131 "session fallback must participate in prepare-stage prompt composition"
1132 );
1133 assert!(
1134 !system_prompt.contains(foreign_default.to_string_lossy().as_ref()),
1135 "authoritative None must suppress the legacy configured default"
1136 );
1137 assert!(
1138 session
1139 .prompt_snapshot
1140 .as_ref()
1141 .and_then(|snapshot| snapshot.workspace_context.as_deref())
1142 .is_some_and(|context| context.contains(&session_fallback_display)),
1143 "prompt snapshot must retain the authoritative session fallback"
1144 );
1145 }
1146
1147 #[test]
1148 fn authoritative_none_without_session_fallback_skips_legacy_lookup() {
1149 let resolved = resolve_workspace_fallback_with(
1150 &ChatWorkspaceFallbackPolicy::Authoritative {
1151 session_fallback_path: None,
1152 },
1153 || panic!("authoritative None must not read a process-global or disk default"),
1154 );
1155
1156 assert_eq!(resolved, None);
1157 }
1158
1159 #[test]
1160 fn legacy_policy_preserves_non_server_default_workspace_lookup() {
1161 let fixture = tempfile::tempdir().expect("fixture");
1162 let configured_default = fixture.path().join("configured-default");
1163 std::fs::create_dir_all(&configured_default).expect("configured default");
1164 std::fs::write(
1165 fixture.path().join("config.json"),
1166 serde_json::json!({
1167 "default_work_area": { "path": configured_default.to_string_lossy() }
1168 })
1169 .to_string(),
1170 )
1171 .expect("write config");
1172 let mut input = chat_turn_input(None);
1173 input.data_dir = Some(fixture.path().to_path_buf());
1174
1175 let session =
1176 prepare_chat_turn_from_authoritative_session(None, input, "global", "builtin")
1177 .expect("legacy turn");
1178 let resolved = session
1179 .workspace_path_meta()
1180 .map(PathBuf::from)
1181 .expect("legacy configured default");
1182
1183 assert_eq!(
1184 resolved.canonicalize().expect("resolved canonical"),
1185 configured_default
1186 .canonicalize()
1187 .expect("configured canonical")
1188 );
1189 }
1190
1191 #[test]
1197 fn default_workspace_from_data_dir_reads_configured_work_area() {
1198 let temp = tempfile::tempdir().expect("temp dir");
1199 let workspace = temp.path().join("default-workspace");
1200 std::fs::create_dir_all(&workspace).expect("workspace dir");
1201 std::fs::write(
1202 temp.path().join("config.json"),
1203 serde_json::json!({
1204 "default_work_area": { "path": workspace.to_string_lossy() }
1205 })
1206 .to_string(),
1207 )
1208 .expect("write config.json");
1209
1210 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
1211 assert_eq!(
1215 resolved.canonicalize().unwrap(),
1216 workspace.canonicalize().unwrap()
1217 );
1218 }
1219
1220 #[test]
1221 fn default_workspace_from_data_dir_is_none_without_config() {
1222 let temp = tempfile::tempdir().expect("temp dir");
1223 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
1224 }
1225}