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