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