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_CATALOG_DIAGNOSTIC_METADATA_KEY, WORKFLOW_CONTEXT_CACHE_METADATA_KEY,
13 WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY, WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY,
14 WORKFLOW_SELECTION_METADATA_KEY,
15};
16use sha2::{Digest, Sha256};
17use std::path::{Path, PathBuf};
18
19use super::errors::ChatError;
20use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
21use super::repository::SessionAccess;
22use super::types::{ChatTurnInput, ChatWorkspaceFallbackPolicy};
23
24const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
26const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
27const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
28const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
29const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
30const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
31const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
32
33const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v3";
34pub const SESSION_START_SOURCE_METADATA_KEY: &str = "runtime.session_start_source";
35
36pub async fn prepare_chat_turn(
44 repo: &dyn SessionAccess,
45 input: ChatTurnInput,
46 global_default_prompt: &str,
47 builtin_fallback_prompt: &str,
48) -> Result<Session, ChatError> {
49 prepare_chat_turn_inner(
50 repo,
51 input,
52 global_default_prompt,
53 builtin_fallback_prompt,
54 true,
55 )
56 .await
57}
58
59pub async fn prepare_chat_turn_unpersisted(
66 repo: &dyn SessionAccess,
67 input: ChatTurnInput,
68 global_default_prompt: &str,
69 builtin_fallback_prompt: &str,
70) -> Result<Session, ChatError> {
71 prepare_chat_turn_inner(
72 repo,
73 input,
74 global_default_prompt,
75 builtin_fallback_prompt,
76 false,
77 )
78 .await
79}
80
81async fn prepare_chat_turn_inner(
82 repo: &dyn SessionAccess,
83 input: ChatTurnInput,
84 global_default_prompt: &str,
85 builtin_fallback_prompt: &str,
86 persist: bool,
87) -> Result<Session, ChatError> {
88 let existing = repo.load_merged(&input.session_id).await?;
89 let mut session = prepare_chat_turn_from_authoritative_session(
90 existing,
91 input,
92 global_default_prompt,
93 builtin_fallback_prompt,
94 )?;
95 if persist {
96 repo.save_and_cache(&mut session).await?;
97 }
98 Ok(session)
99}
100
101pub fn prepare_chat_turn_from_authoritative_session(
107 existing: Option<Session>,
108 input: ChatTurnInput,
109 global_default_prompt: &str,
110 builtin_fallback_prompt: &str,
111) -> Result<Session, ChatError> {
112 prepare_chat_turn_from_authoritative_session_with_workspace_policy(
113 existing,
114 input,
115 global_default_prompt,
116 builtin_fallback_prompt,
117 ChatWorkspaceFallbackPolicy::Legacy,
118 )
119}
120
121pub fn prepare_chat_turn_from_authoritative_session_with_workspace_policy(
128 existing: Option<Session>,
129 input: ChatTurnInput,
130 global_default_prompt: &str,
131 builtin_fallback_prompt: &str,
132 workspace_fallback_policy: ChatWorkspaceFallbackPolicy,
133) -> Result<Session, ChatError> {
134 let (mut session, session_start_source) = match existing {
135 Some(session) => (session, "resume"),
136 None => (
137 Session::new(input.session_id.clone(), input.model.clone()),
138 "startup",
139 ),
140 };
141 match crate::project_context::ProjectContextResolver::session_project_identity(&session) {
142 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
143 return Err(ChatError::InvalidProjectIdentity { raw, message });
144 }
145 crate::project_context::SessionProjectIdentity::Assigned(actual)
146 if input.project_id.as_ref() != Some(&actual) =>
147 {
148 return Err(ChatError::ProjectIdentityConflict {
149 expected: input.project_id,
150 actual: Some(actual),
151 });
152 }
153 crate::project_context::SessionProjectIdentity::Unassigned
154 if session_start_source == "resume" && input.project_id.is_some() =>
155 {
156 return Err(ChatError::ProjectIdentityConflict {
157 expected: input.project_id,
158 actual: None,
159 });
160 }
161 crate::project_context::SessionProjectIdentity::Assigned(_)
162 | crate::project_context::SessionProjectIdentity::Unassigned => {}
163 }
164 crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(&mut session);
165 session.metadata.insert(
166 SESSION_START_SOURCE_METADATA_KEY.to_string(),
167 session_start_source.to_string(),
168 );
169 if session_start_source == "startup" {
170 if let Some(project_id) = input.project_id.as_ref() {
171 session.set_project_id_meta(project_id.to_string());
172 }
173 session.reasoning_effort = input.reasoning_effort;
174 if let Some(mode) = input.permission_mode {
178 session
179 .agent_runtime_state
180 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
181 .set_permission_mode(mode);
182 }
183 }
184
185 let base_prompt = resolve_base_prompt(
187 &mut session,
188 input.system_prompt.as_deref(),
189 global_default_prompt,
190 builtin_fallback_prompt,
191 );
192
193 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
195 let enhance_prompt = session.enhance_prompt();
196 if let Some(enabled) = input.root_orchestration_prompt {
197 if session.kind == bamboo_domain::SessionKind::Root && session.parent_session_id.is_none() {
200 session.set_root_orchestration_prompt_enabled(enabled);
201 }
202 }
203
204 let allow_legacy_workspace_fallback = matches!(
206 crate::project_context::ProjectContextResolver::session_project_identity(&session),
207 crate::project_context::SessionProjectIdentity::Unassigned
208 );
209 let workspace_path = resolve_workspace_path_with_default(
210 &mut session,
211 input.workspace_path.as_deref(),
212 input.default_workspace_path.as_deref(),
213 &workspace_fallback_policy,
214 input.data_dir.as_deref(),
215 allow_legacy_workspace_fallback,
216 );
217
218 resolve_workflow_selection(
220 &mut session,
221 input.workflow_selection.as_ref(),
222 input.selected_skill_ids.as_deref(),
223 &input.message,
224 )?;
225 if let Some(enabled) = input.root_orchestration_only {
226 session.set_root_orchestration_only(enabled)?;
227 }
228 if session.model_orchestration_only_enabled() {
229 session.validate_root_orchestration_compatibility()?;
230 let pending = session
233 .metadata
234 .contains_key(WORKFLOW_SELECTION_METADATA_KEY);
235 let active = session
236 .metadata
237 .get(ACTIVE_WORKFLOW_METADATA_KEY)
238 .is_some_and(|raw| {
239 serde_json::from_str::<ActiveWorkflow>(raw)
240 .map(|workflow| workflow.status != WorkflowActivationStatus::Deactivated)
241 .unwrap_or(true)
242 });
243 if pending || active {
244 return Err(bamboo_domain::RootToolAuthorityError::WorkflowSelected.into());
245 }
246 }
247 if let Some(opted_in) = input.orchestration_opt_in {
248 session.metadata.insert(
249 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
250 opted_in.to_string(),
251 );
252 }
253
254 let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
256 &base_prompt,
257 enhance_prompt.as_deref(),
258 workspace_path.as_deref(),
259 );
260
261 session.metadata.insert(
262 PROMPT_COMPOSER_VERSION_KEY.to_string(),
263 prompt_profile.version.to_string(),
264 );
265 session.metadata.insert(
266 PROMPT_FINGERPRINT_KEY.to_string(),
267 prompt_profile.fingerprint.clone(),
268 );
269 session.metadata.insert(
270 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
271 prompt_profile.component_flags_value(),
272 );
273 session.metadata.insert(
274 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
275 prompt_profile.component_lengths_value(),
276 );
277
278 let system_messages = session
282 .messages
283 .iter()
284 .filter(|message| matches!(message.role, Role::System))
285 .collect::<Vec<_>>();
286 let system_is_current = system_messages.len() == 1
287 && system_messages[0].content == system_prompt
288 && session
289 .messages
290 .first()
291 .is_some_and(|message| matches!(message.role, Role::System));
292 if !system_is_current {
293 session
294 .messages
295 .retain(|message| !matches!(message.role, Role::System));
296 session.messages.insert(0, Message::system(system_prompt));
297 }
298 refresh_prompt_snapshot(&mut session);
299
300 let request_model_ref = derive_model_ref(
302 input.model_ref.as_ref(),
303 input.provider.as_deref(),
304 Some(input.model.as_str()),
305 );
306 if let Some(model_ref) = request_model_ref.as_ref() {
307 persist_model_ref(&mut session, model_ref);
308 } else {
309 persist_legacy_model_provider(
310 &mut session,
311 Some(input.model.as_str()),
312 input.provider.as_deref(),
313 );
314 }
315
316 Ok(session)
317}
318
319pub fn resolve_base_prompt(
322 session: &mut Session,
323 base_prompt_from_request: Option<&str>,
324 global_default_template: &str,
325 builtin_fallback: &str,
326) -> String {
327 let resolved = base_prompt_from_request
328 .map(ToString::to_string)
329 .or_else(|| {
330 session
331 .metadata
332 .get(BASE_SYSTEM_PROMPT_KEY)
333 .map(String::as_str)
334 .map(str::trim)
335 .filter(|value| !value.is_empty())
336 .map(ToString::to_string)
337 })
338 .or_else(|| {
339 session
340 .messages
341 .iter()
342 .find(|message| matches!(message.role, Role::System))
343 .map(|message| message.content.trim().to_string())
344 .filter(|value| !value.is_empty())
345 })
346 .unwrap_or_else(|| {
347 let trimmed = global_default_template.trim();
348 if trimmed.is_empty() {
349 builtin_fallback.to_string()
350 } else {
351 trimmed.to_string()
352 }
353 });
354 let resolved =
355 crate::runtime::runner::session_setup::prompt_setup::normalize_base_prompt(&resolved);
356
357 session
358 .metadata
359 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
360 resolved
361}
362
363pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
364 if let Some(prompt) = enhance_prompt_from_request {
365 session.set_enhance_prompt(prompt);
366 } else {
367 session.clear_enhance_prompt();
368 }
369}
370
371pub fn resolve_workspace_path(
372 session: &mut Session,
373 workspace_path_from_request: Option<&str>,
374 data_dir: Option<&Path>,
375) -> Option<String> {
376 resolve_workspace_path_with_default(
377 session,
378 workspace_path_from_request,
379 None,
380 &ChatWorkspaceFallbackPolicy::Legacy,
381 data_dir,
382 true,
383 )
384}
385
386fn resolve_workspace_path_with_default(
387 session: &mut Session,
388 workspace_path_from_request: Option<&str>,
389 default_workspace_path: Option<&str>,
390 fallback_policy: &ChatWorkspaceFallbackPolicy,
391 data_dir: Option<&Path>,
392 allow_legacy_fallback: bool,
393) -> Option<String> {
394 let explicit = workspace_path_from_request
395 .map(str::trim)
396 .filter(|value| !value.is_empty())
397 .map(ToString::to_string);
398 let persisted = session.workspace_path_meta();
399 let configured_default = allow_legacy_fallback
400 .then(|| {
401 default_workspace_path
402 .map(str::trim)
403 .filter(|value| !value.is_empty())
404 .map(ToString::to_string)
405 })
406 .flatten();
407 let fallback = (explicit.is_none() && persisted.is_none() && configured_default.is_none())
408 .then(|| {
409 allow_legacy_fallback
410 .then(|| {
411 resolve_workspace_fallback_with(fallback_policy, || {
412 resolve_default_workspace(data_dir)
413 })
414 })
415 .flatten()
416 })
417 .flatten();
418 let (resolved, source, binding_reset) = if let Some(workspace) = explicit {
419 (
420 Some(workspace),
421 crate::project_context::WorkspaceSource::Explicit,
422 true,
423 )
424 } else if let Some(workspace) = persisted {
425 (
426 Some(workspace),
427 crate::project_context::WorkspaceSource::from_metadata(
428 session
429 .metadata
430 .get(crate::project_context::WORKSPACE_SOURCE_METADATA_KEY)
431 .map(String::as_str),
432 ),
433 false,
434 )
435 } else if let Some(workspace) = configured_default {
436 (
437 Some(workspace),
438 crate::project_context::WorkspaceSource::Session,
439 true,
440 )
441 } else {
442 (
443 fallback,
444 crate::project_context::WorkspaceSource::Session,
445 true,
446 )
447 };
448 if let Some(workspace) = resolved.as_ref() {
449 session.set_workspace_path_meta(workspace.clone());
454 session.metadata.insert(
455 crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
456 source.as_str().to_string(),
457 );
458 if binding_reset {
459 session.metadata.insert(
460 crate::project_context::WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
461 crate::project_context::WorkspaceBindingStatus::Unregistered
462 .as_str()
463 .to_string(),
464 );
465 }
466 }
467 resolved
468}
469
470fn resolve_workspace_fallback_with<L>(
471 fallback_policy: &ChatWorkspaceFallbackPolicy,
472 legacy_lookup: L,
473) -> Option<String>
474where
475 L: FnOnce() -> Option<String>,
476{
477 match fallback_policy {
478 ChatWorkspaceFallbackPolicy::Legacy => legacy_lookup(),
479 ChatWorkspaceFallbackPolicy::Authoritative {
480 session_fallback_path,
481 } => session_fallback_path.clone(),
482 }
483}
484
485fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
496 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
497 bamboo_agent_core::workspace_state::get_configured_default_workspace()
498 } else {
499 default_workspace_from_data_dir(data_dir)
500 };
501 configured.map(|path| path_to_display_string(&path))
502}
503
504fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
507 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
508}
509
510pub fn resolve_selected_skill_ids(
511 session: &mut Session,
512 selected_skill_ids_from_request: Option<&[String]>,
513 message: &str,
514) {
515 if let Some(request_ids) = selected_skill_ids_from_request {
516 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
517 persist_selected_skill_ids_metadata(session, normalized.as_deref());
518 return;
519 }
520
521 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
522 if let Some(ids) = from_hint.as_ref() {
523 persist_selected_skill_ids_metadata(session, Some(ids));
524 return;
525 }
526
527 session.clear_selected_skill_ids();
528}
529
530pub fn resolve_workflow_selection(
531 session: &mut Session,
532 workflow_selection: Option<&WorkflowSelection>,
533 selected_skill_ids_from_request: Option<&[String]>,
534 message: &str,
535) -> Result<(), ChatError> {
536 if let Some(selection) = workflow_selection {
537 let id = selection.id.trim();
538 if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
539 return Err(ChatError::InvalidWorkflowSelection(
540 "id must be non-empty, revision must be positive, and args must be an object"
541 .to_string(),
542 ));
543 }
544 let previous = session
545 .metadata
546 .get(WORKFLOW_SELECTION_METADATA_KEY)
547 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
548 let selection_changed = previous.as_ref() != Some(selection);
549 session.metadata.insert(
550 WORKFLOW_SELECTION_METADATA_KEY.to_string(),
551 serde_json::to_string(selection).map_err(|_| {
552 ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
553 })?,
554 );
555 persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
556 if selection_changed {
557 deactivate_active_workflow(session);
558 clear_skill_runtime_state(session);
559 }
560 return Ok(());
561 }
562
563 if selected_skill_ids_from_request.is_some() {
564 session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
567 deactivate_active_workflow(session);
568 resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
569 clear_skill_runtime_state(session);
570 return Ok(());
571 }
572
573 if let Some(pending) = session
578 .metadata
579 .get(WORKFLOW_SELECTION_METADATA_KEY)
580 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok())
581 .filter(|_| {
582 session
583 .metadata
584 .get(bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTION_SOURCE_KEY)
585 .is_some_and(|source| source == "explicit")
586 && session.metadata.contains_key(
587 bamboo_skills::runtime_metadata::SKILL_RUNTIME_PINNED_SNAPSHOT_KEY,
588 )
589 })
590 {
591 persist_selected_skill_ids_metadata(session, Some(&[pending.id]));
592 return Ok(());
593 }
594
595 if let Some(active) = session
596 .metadata
597 .get(ACTIVE_WORKFLOW_METADATA_KEY)
598 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
599 .filter(|active| active.status == WorkflowActivationStatus::Active)
600 {
601 persist_selected_skill_ids_metadata(session, Some(&[active.id]));
602 return Ok(());
603 }
604
605 resolve_selected_skill_ids(session, None, message);
608 Ok(())
609}
610
611fn deactivate_active_workflow(session: &mut Session) {
612 if let Some(active) = session
613 .metadata
614 .get(ACTIVE_WORKFLOW_METADATA_KEY)
615 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
616 .filter(|active| active.status == WorkflowActivationStatus::Active)
617 {
618 session.metadata.insert(
619 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
620 serde_json::json!({
621 "type": "workflow.deactivated",
622 "workflow_id": active.id,
623 "revision": active.revision,
624 "deactivated_at": chrono::Utc::now(),
625 })
626 .to_string(),
627 );
628 }
629 session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
630 session
631 .metadata
632 .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
633}
634
635pub fn clear_skill_runtime_state(session: &mut Session) {
637 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
638 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
639}
640
641pub fn clear_workflow_authority_for_resource_scope_change(session: &mut Session) {
645 let identity = session
646 .metadata
647 .get(ACTIVE_WORKFLOW_METADATA_KEY)
648 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
649 .map(|active| (active.id, active.revision))
650 .or_else(|| {
651 session
652 .metadata
653 .get(WORKFLOW_SELECTION_METADATA_KEY)
654 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok())
655 .map(|selection| (selection.id, selection.revision))
656 });
657 session.metadata.retain(|key, _| {
658 !key.starts_with("skill_runtime_")
659 && !matches!(
660 key.as_str(),
661 ACTIVE_WORKFLOW_METADATA_KEY
662 | ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY
663 | WORKFLOW_SELECTION_METADATA_KEY
664 | WORKFLOW_ACTIVATION_EVENT_METADATA_KEY
665 | WORKFLOW_CONTEXT_CACHE_METADATA_KEY
666 | WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY
667 | WORKFLOW_CATALOG_DIAGNOSTIC_METADATA_KEY
668 | "skill.context"
669 )
670 });
671 if let Some((workflow_id, revision)) = identity {
672 session.metadata.insert(
673 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
674 serde_json::json!({
675 "type": "workflow.deactivated",
676 "workflow_id": workflow_id,
677 "revision": revision,
678 "reason": "resource_scope_changed",
679 "deactivated_at": chrono::Utc::now(),
680 })
681 .to_string(),
682 );
683 }
684}
685
686fn persist_selected_skill_ids_metadata(
687 session: &mut Session,
688 selected_skill_ids: Option<&[String]>,
689) {
690 match selected_skill_ids {
691 Some(ids) if !ids.is_empty() => {
692 session.set_selected_skill_ids(ids.to_vec());
693 }
694 _ => {
695 session.clear_selected_skill_ids();
696 }
697 }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
704pub enum GoalCommand {
705 Status,
707 Off,
709 Clear,
711 On,
713 SetPrompt(String),
715}
716
717pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
720 let trimmed = message.trim();
721 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
722 return None;
723 }
724 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
727 return None;
728 }
729
730 let arg = rest.trim().to_ascii_lowercase();
731
732 if arg.is_empty() {
733 return Some(GoalCommand::Status);
734 }
735
736 match arg.as_str() {
737 "status" => Some(GoalCommand::Status),
738 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
739 "clear" | "reset" => Some(GoalCommand::Clear),
740 "on" | "enable" | "enabled" => Some(GoalCommand::On),
741 _ => {
742 let prompt = trimmed
745 .strip_prefix("/goal")
746 .unwrap_or(trimmed)
747 .trim()
748 .to_string();
749 if prompt.is_empty() {
750 Some(GoalCommand::Status)
751 } else {
752 Some(GoalCommand::SetPrompt(prompt))
753 }
754 }
755 }
756}
757
758fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
759 const HINT_PREFIX: &str = "[User explicitly selected skill:";
760 let mut extracted = Vec::new();
761
762 for line in message.lines() {
763 let trimmed = line.trim();
764 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
765 continue;
766 }
767
768 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
769 continue;
770 };
771 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
772 let Some(close_paren_index) = id_segment.find(')') else {
773 continue;
774 };
775 let id = id_segment[..close_paren_index].trim();
776 if !id.is_empty() {
777 extracted.push(id.to_string());
778 }
779 }
780
781 extracted
782}
783
784#[derive(Debug, Clone, PartialEq, Eq)]
787struct PromptCompositionProfile {
788 version: &'static str,
789 fingerprint: String,
790 has_enhancement: bool,
791 has_workspace_context: bool,
792 has_env_context: bool,
793 base_len: usize,
794 enhancement_len: usize,
795 workspace_context_len: usize,
796 env_context_len: usize,
797 final_len: usize,
798}
799
800impl PromptCompositionProfile {
801 fn component_flags_value(&self) -> String {
802 format!(
803 "enhance={};workspace={};env={}",
804 self.has_enhancement as u8,
805 self.has_workspace_context as u8,
806 self.has_env_context as u8,
807 )
808 }
809
810 fn component_lengths_value(&self) -> String {
811 format!(
812 "base={};enhance={};workspace={};env={};final={}",
813 self.base_len,
814 self.enhancement_len,
815 self.workspace_context_len,
816 self.env_context_len,
817 self.final_len
818 )
819 }
820}
821
822fn build_prompt_fingerprint(base_prompt: &str, enhancement: Option<&str>) -> String {
823 let mut hasher = Sha256::new();
824 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
825 hasher.update([0u8]);
826 hasher.update(base_prompt.as_bytes());
827 hasher.update([0u8]);
828 hasher.update(enhancement.unwrap_or_default().as_bytes());
829 hex::encode(hasher.finalize())
830}
831
832fn build_enhanced_system_prompt_with_profile(
833 base_prompt: &str,
834 enhance_prompt: Option<&str>,
835 workspace_path: Option<&str>,
836) -> (String, PromptCompositionProfile) {
837 let mut merged_prompt = base_prompt.to_string();
838
839 let enhancement = enhance_prompt
840 .map(str::trim)
841 .filter(|enhancement| !enhancement.is_empty())
842 .map(ToString::to_string);
843 if let Some(enhancement) = enhancement.as_ref() {
844 merged_prompt.push_str("\n\n");
845 merged_prompt.push_str(enhancement.as_str());
846 }
847
848 let has_workspace_context = workspace_path
849 .map(str::trim)
850 .is_some_and(|workspace_path| !workspace_path.is_empty());
851
852 let has_env_context = build_env_prompt_context().is_some();
856
857 let profile = PromptCompositionProfile {
858 version: PROMPT_COMPOSER_VERSION,
859 fingerprint: build_prompt_fingerprint(base_prompt, enhancement.as_deref()),
860 has_enhancement: enhancement.is_some(),
861 has_workspace_context,
862 has_env_context,
863 base_len: base_prompt.len(),
864 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
865 workspace_context_len: 0,
866 env_context_len: 0,
867 final_len: merged_prompt.len(),
868 };
869
870 (merged_prompt, profile)
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use crate::session_app::errors::{SessionLoadError, SessionSaveError};
877 use async_trait::async_trait;
878 use std::sync::atomic::{AtomicUsize, Ordering};
879 use std::sync::{Arc, Mutex};
880
881 struct InMemorySessionAccess;
882
883 struct ExistingSessionAccess(Session);
884
885 struct BarrierSessionAccess {
886 session: Mutex<Session>,
887 load_started: tokio::sync::Notify,
888 release_load: tokio::sync::Notify,
889 save_count: AtomicUsize,
890 }
891
892 #[async_trait]
893 impl SessionAccess for InMemorySessionAccess {
894 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
895 Ok(None)
896 }
897
898 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
899 Ok(Session::new(id, model))
900 }
901
902 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
903 Ok(None)
904 }
905
906 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
907 Ok(())
908 }
909
910 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
911 Ok(())
912 }
913 }
914
915 #[async_trait]
916 impl SessionAccess for ExistingSessionAccess {
917 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
918 Ok(Some(self.0.clone()))
919 }
920
921 async fn load_or_create(
922 &self,
923 _id: &str,
924 _model: &str,
925 ) -> Result<Session, SessionLoadError> {
926 panic!("existing chat session must not be recreated")
927 }
928
929 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
930 Ok(Some(self.0.clone()))
931 }
932
933 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
934 Ok(())
935 }
936
937 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
938 Ok(())
939 }
940 }
941
942 #[async_trait]
943 impl SessionAccess for BarrierSessionAccess {
944 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
945 Ok(Some(self.session.lock().expect("session lock").clone()))
946 }
947
948 async fn load_or_create(
949 &self,
950 _id: &str,
951 _model: &str,
952 ) -> Result<Session, SessionLoadError> {
953 panic!("barrier session already exists")
954 }
955
956 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
957 self.load_started.notify_one();
958 self.release_load.notified().await;
959 Ok(Some(self.session.lock().expect("session lock").clone()))
960 }
961
962 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
963 self.save_count.fetch_add(1, Ordering::SeqCst);
964 Ok(())
965 }
966
967 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
968 self.save_count.fetch_add(1, Ordering::SeqCst);
969 Ok(())
970 }
971 }
972
973 fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
974 super::super::types::ChatTurnInput {
975 session_id: "session-enhance".to_string(),
976 project_id: None,
977 model: "gpt-5".to_string(),
978 model_ref: None,
979 provider: None,
980 reasoning_effort: None,
981 message: "hello".to_string(),
982 system_prompt: Some("Base prompt".to_string()),
983 enhance_prompt: enhance_prompt.map(ToString::to_string),
984 root_orchestration_prompt: None,
985 root_orchestration_only: None,
986 workspace_path: None,
987 permission_mode: None,
988 default_workspace_path: None,
989 selected_skill_ids: None,
990 workflow_selection: None,
991 orchestration_opt_in: None,
992 data_dir: None,
993 }
994 }
995
996 fn system_message_content(session: &Session) -> String {
997 session
998 .messages
999 .iter()
1000 .find(|message| matches!(message.role, Role::System))
1001 .map(|message| message.content.clone())
1002 .expect("session should have a system message")
1003 }
1004
1005 #[test]
1006 fn new_chat_session_persists_explicit_reasoning_profile() {
1007 let mut input = chat_turn_input(None);
1008 input.reasoning_effort = Some(bamboo_domain::ReasoningEffort::Xhigh);
1009
1010 let session = prepare_chat_turn_from_authoritative_session(
1011 None,
1012 input,
1013 "Global prompt",
1014 "Builtin prompt",
1015 )
1016 .expect("new chat session");
1017
1018 assert_eq!(
1019 session.reasoning_effort,
1020 Some(bamboo_domain::ReasoningEffort::Xhigh)
1021 );
1022 }
1023
1024 fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
1025 ActiveWorkflow {
1026 id: id.to_string(),
1027 source: bamboo_skills::WorkflowSource::User,
1028 revision,
1029 kind: bamboo_skills::WorkflowKind::Instruction,
1030 args: serde_json::json!({}),
1031 invoked_by: bamboo_skills::WorkflowInvokedBy::User,
1032 activated_at: chrono::Utc::now(),
1033 status: WorkflowActivationStatus::Active,
1034 diagnostic: None,
1035 context_fingerprint: Some("fingerprint".to_string()),
1036 dynamic_context: Vec::new(),
1037 }
1038 }
1039
1040 #[tokio::test]
1041 async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
1042 let startup = prepare_chat_turn(
1043 &InMemorySessionAccess,
1044 chat_turn_input(None),
1045 "global",
1046 "builtin",
1047 )
1048 .await
1049 .expect("new turn");
1050 assert_eq!(
1051 startup
1052 .metadata
1053 .get(SESSION_START_SOURCE_METADATA_KEY)
1054 .map(String::as_str),
1055 Some("startup")
1056 );
1057
1058 let existing = prepare_chat_turn(
1059 &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
1060 chat_turn_input(None),
1061 "global",
1062 "builtin",
1063 )
1064 .await
1065 .expect("existing turn");
1066 assert_eq!(
1067 existing
1068 .metadata
1069 .get(SESSION_START_SOURCE_METADATA_KEY)
1070 .map(String::as_str),
1071 Some("resume")
1072 );
1073 }
1074
1075 #[tokio::test]
1076 async fn prepare_chat_turn_rechecks_project_identity_after_preflight_race() {
1077 let project_a =
1078 bamboo_domain::ProjectId::parse("project-chat-a").expect("Project A identity");
1079 let project_b =
1080 bamboo_domain::ProjectId::parse("project-chat-b").expect("Project B identity");
1081 let workspace_a = tempfile::tempdir().expect("workspace A");
1082 let workspace_b = tempfile::tempdir().expect("workspace B");
1083 let mut session = Session::new("chat-project-race", "gpt-5");
1084 session.set_project_id_meta(project_a.to_string());
1085 session.set_workspace_path_meta(workspace_a.path().to_string_lossy().into_owned());
1086 let repo = Arc::new(BarrierSessionAccess {
1087 session: Mutex::new(session),
1088 load_started: tokio::sync::Notify::new(),
1089 release_load: tokio::sync::Notify::new(),
1090 save_count: AtomicUsize::new(0),
1091 });
1092 let mut input = chat_turn_input(None);
1093 input.session_id = "chat-project-race".to_string();
1094 input.project_id = Some(project_a.clone());
1095 input.workspace_path = Some(workspace_a.path().to_string_lossy().into_owned());
1096
1097 let task_repo = repo.clone();
1098 let task = tokio::spawn(async move {
1099 prepare_chat_turn(task_repo.as_ref(), input, "global", "builtin").await
1100 });
1101 repo.load_started.notified().await;
1102 {
1103 let mut raced = repo.session.lock().expect("session lock");
1104 raced.set_project_id_meta(project_b.to_string());
1105 raced.set_workspace_path_meta(workspace_b.path().to_string_lossy().into_owned());
1106 }
1107 repo.release_load.notify_one();
1108
1109 let error = task
1110 .await
1111 .expect("chat task")
1112 .expect_err("membership race must fail closed");
1113 assert!(matches!(
1114 error,
1115 ChatError::ProjectIdentityConflict {
1116 expected: Some(expected),
1117 actual: Some(actual),
1118 } if expected == project_a && actual == project_b
1119 ));
1120 assert_eq!(repo.save_count.load(Ordering::SeqCst), 0);
1121 let persisted = repo.session.lock().expect("session lock");
1122 assert_eq!(
1123 persisted.workspace_path_meta().as_deref(),
1124 Some(workspace_b.path().to_string_lossy().as_ref())
1125 );
1126 assert!(persisted.messages.is_empty());
1127 assert!(!persisted
1128 .metadata
1129 .contains_key(SESSION_START_SOURCE_METADATA_KEY));
1130 }
1131
1132 #[test]
1133 fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
1134 let mut session = Session::new("typed-selection", "model");
1135 let selection = WorkflowSelection {
1136 id: "review".to_string(),
1137 source: bamboo_skills::WorkflowSource::User,
1138 revision: 7,
1139 args: serde_json::json!({"depth": "full"}),
1140 };
1141 resolve_workflow_selection(
1142 &mut session,
1143 Some(&selection),
1144 Some(&["plan".to_string()]),
1145 "use skill plan",
1146 )
1147 .expect("typed selection");
1148 assert_eq!(
1149 session.selected_skill_ids(),
1150 Some(vec!["review".to_string()])
1151 );
1152 assert_eq!(
1153 session
1154 .metadata
1155 .get(WORKFLOW_SELECTION_METADATA_KEY)
1156 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
1157 Some(selection)
1158 );
1159 }
1160
1161 #[test]
1162 fn active_workflow_survives_turn_without_new_selection() {
1163 let mut session = Session::new("active-selection", "model");
1164 session.metadata.insert(
1165 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
1166 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
1167 );
1168 resolve_workflow_selection(&mut session, None, None, "use skill plan")
1169 .expect("preserve active");
1170 assert_eq!(
1171 session.selected_skill_ids(),
1172 Some(vec!["review".to_string()])
1173 );
1174 assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
1175 }
1176
1177 #[test]
1178 fn resource_scope_cleanup_removes_recovery_bytes_without_widening_skill_ceiling() {
1179 let mut session = Session::new("scope-cleanup", "model");
1180 session.set_selected_skill_ids(vec!["review".into()]);
1181 session.add_message(Message::user("Keep the prior request"));
1182 session.add_message(Message::assistant("Keep the prior result", None));
1183 let history = session.messages.clone();
1184 session
1185 .metadata
1186 .insert("permission_mode".into(), "default".into());
1187 session.metadata.insert("skill_mode".into(), "code".into());
1188 session
1189 .metadata
1190 .insert("workflow.run_ids.v1".into(), r#"["run-1"]"#.into());
1191 session.metadata.insert(
1192 ACTIVE_WORKFLOW_METADATA_KEY.into(),
1193 serde_json::to_string(&active_workflow("review", 7)).unwrap(),
1194 );
1195 for key in [
1196 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY,
1197 WORKFLOW_CONTEXT_CACHE_METADATA_KEY,
1198 WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY,
1199 "skill_runtime_pinned_snapshot_v1",
1200 "skill_runtime_last_load_summary",
1201 "skill.context",
1202 ] {
1203 session
1204 .metadata
1205 .insert(key.into(), "old private bytes".into());
1206 }
1207
1208 clear_workflow_authority_for_resource_scope_change(&mut session);
1209
1210 let restored: Session = serde_json::from_str(&serde_json::to_string(&session).unwrap())
1211 .expect("existing session format remains restart-compatible");
1212 assert_eq!(restored.selected_skill_ids(), Some(vec!["review".into()]));
1213 assert_eq!(
1214 serde_json::to_value(&restored.messages).unwrap(),
1215 serde_json::to_value(&history).unwrap()
1216 );
1217 assert_eq!(restored.metadata["permission_mode"], "default");
1218 assert_eq!(restored.metadata["skill_mode"], "code");
1219 assert_eq!(restored.metadata["workflow.run_ids.v1"], r#"["run-1"]"#);
1220 assert!(!serde_json::to_string(&restored)
1221 .unwrap()
1222 .contains("old private bytes"));
1223 let event: serde_json::Value =
1224 serde_json::from_str(&restored.metadata[WORKFLOW_ACTIVATION_EVENT_METADATA_KEY])
1225 .unwrap();
1226 assert_eq!(event["type"], "workflow.deactivated");
1227 assert_eq!(event["reason"], "resource_scope_changed");
1228 assert_eq!(event["workflow_id"], "review");
1229 }
1230
1231 #[test]
1232 fn resource_scope_cleanup_removes_pending_or_malformed_legacy_authority() {
1233 for selection in [
1234 serde_json::to_string(&WorkflowSelection {
1235 id: "review".into(),
1236 source: bamboo_skills::WorkflowSource::Builtin,
1237 revision: 7,
1238 args: serde_json::json!({}),
1239 })
1240 .unwrap(),
1241 "malformed".into(),
1242 ] {
1243 let mut session = Session::new("scope-pending", "model");
1244 session
1245 .metadata
1246 .insert(WORKFLOW_SELECTION_METADATA_KEY.into(), selection);
1247 session
1248 .metadata
1249 .insert("skill_runtime_pinned_snapshot_v1".into(), "old pin".into());
1250 clear_workflow_authority_for_resource_scope_change(&mut session);
1251 assert!(!session
1252 .metadata
1253 .contains_key(WORKFLOW_SELECTION_METADATA_KEY));
1254 assert!(!session
1255 .metadata
1256 .contains_key("skill_runtime_pinned_snapshot_v1"));
1257 }
1258 }
1259
1260 #[test]
1261 fn pending_typed_workflow_survives_an_ordinary_chat_before_execute() {
1262 let mut session = Session::new("pending-selection", "model");
1263 let selection = WorkflowSelection {
1264 id: "review".to_string(),
1265 source: bamboo_skills::WorkflowSource::Builtin,
1266 revision: 7,
1267 args: serde_json::json!({}),
1268 };
1269 session.metadata.insert(
1270 WORKFLOW_SELECTION_METADATA_KEY.to_string(),
1271 serde_json::to_string(&selection).expect("selection json"),
1272 );
1273 session.metadata.insert(
1274 bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTION_SOURCE_KEY.to_string(),
1275 "explicit".to_string(),
1276 );
1277 session.metadata.insert(
1278 bamboo_skills::runtime_metadata::SKILL_RUNTIME_PINNED_SNAPSHOT_KEY.to_string(),
1279 "opaque durable snapshot".to_string(),
1280 );
1281
1282 resolve_workflow_selection(&mut session, None, None, "one more detail")
1283 .expect("retain pending selection");
1284
1285 assert_eq!(
1286 session.selected_skill_ids(),
1287 Some(vec!["review".to_string()])
1288 );
1289 assert_eq!(
1290 session
1291 .metadata
1292 .get(WORKFLOW_SELECTION_METADATA_KEY)
1293 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
1294 Some(selection)
1295 );
1296 assert!(session
1297 .metadata
1298 .contains_key(bamboo_skills::runtime_metadata::SKILL_RUNTIME_PINNED_SNAPSHOT_KEY));
1299 }
1300
1301 #[test]
1302 fn explicit_empty_legacy_selection_deactivates_active_workflow() {
1303 let mut session = Session::new("deactivate-selection", "model");
1304 session.metadata.insert(
1305 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
1306 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
1307 );
1308 resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
1309 .expect("deactivate");
1310 assert!(session.selected_skill_ids().is_none());
1311 assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
1312 assert!(session
1313 .metadata
1314 .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
1315 .is_some_and(|event| event.contains("workflow.deactivated")));
1316 }
1317
1318 #[tokio::test]
1321 async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
1322 let session = prepare_chat_turn(
1323 &InMemorySessionAccess,
1324 chat_turn_input(Some("Extra enhancement guidance")),
1325 "",
1326 "Builtin fallback",
1327 )
1328 .await
1329 .expect("prepare_chat_turn should succeed");
1330
1331 let system_prompt = system_message_content(&session);
1332 assert!(system_prompt.starts_with("Base prompt"));
1333 assert!(system_prompt.contains("Extra enhancement guidance"));
1334 assert!(!system_prompt.contains(crate::runtime::context::PROJECT_CONTEXT_START_MARKER));
1335 assert!(!system_prompt.contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1336 assert!(!system_prompt
1337 .contains(crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER));
1338 assert!(!system_prompt.contains(crate::runtime::context::ENV_CONTEXT_START_MARKER));
1339 assert!(!system_prompt.contains("BAMBOO_SKILL_CONTEXT_START"));
1340 assert!(!system_prompt.contains("BAMBOO_TOOL_GUIDE_START"));
1341 assert_eq!(
1342 session.enhance_prompt().as_deref(),
1343 Some("Extra enhancement guidance")
1344 );
1345 assert!(session
1346 .metadata
1347 .get(PROMPT_COMPONENT_FLAGS_KEY)
1348 .is_some_and(|flags| flags.contains("enhance=1")));
1349 }
1350
1351 #[tokio::test]
1352 async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
1353 let session = prepare_chat_turn(
1354 &InMemorySessionAccess,
1355 chat_turn_input(None),
1356 "",
1357 "Builtin fallback",
1358 )
1359 .await
1360 .expect("prepare_chat_turn should succeed");
1361
1362 let system_prompt = system_message_content(&session);
1363 assert!(system_prompt.starts_with("Base prompt"));
1364 assert!(session.enhance_prompt().is_none());
1365 assert!(session
1366 .metadata
1367 .get(PROMPT_COMPONENT_FLAGS_KEY)
1368 .is_some_and(|flags| flags.contains("enhance=0")));
1369 }
1370
1371 #[test]
1372 fn root_orchestration_selection_survives_omitted_resume_and_can_be_cleared() {
1373 let mut start = chat_turn_input(None);
1374 start.root_orchestration_prompt = Some(true);
1375 let started =
1376 prepare_chat_turn_from_authoritative_session(None, start, "", "Builtin fallback")
1377 .expect("start opted-in root");
1378 assert!(started.root_orchestration_prompt_enabled());
1379
1380 let reloaded = serde_json::from_str(&serde_json::to_string(&started).unwrap())
1381 .expect("reload persisted session");
1382 let resumed = prepare_chat_turn_from_authoritative_session(
1383 Some(reloaded),
1384 chat_turn_input(None),
1385 "",
1386 "Builtin fallback",
1387 )
1388 .expect("resume without changing selection");
1389 assert!(resumed.root_orchestration_prompt_enabled());
1390
1391 let mut disable = chat_turn_input(None);
1392 disable.root_orchestration_prompt = Some(false);
1393 let disabled = prepare_chat_turn_from_authoritative_session(
1394 Some(resumed),
1395 disable,
1396 "",
1397 "Builtin fallback",
1398 )
1399 .expect("disable root guidance");
1400 assert!(!disabled.root_orchestration_prompt_enabled());
1401 }
1402
1403 #[test]
1404 fn root_tool_selection_persists_on_resume_and_explicit_disable() {
1405 let mut start = chat_turn_input(None);
1406 start.root_orchestration_only = Some(true);
1407 let selected =
1408 prepare_chat_turn_from_authoritative_session(None, start, "", "Builtin fallback")
1409 .expect("select Root tool authority");
1410 assert!(selected.root_orchestration_only_enabled());
1411 assert!(!selected.root_orchestration_prompt_enabled());
1412 assert_eq!(selected.root_tool_authority_revision, 1);
1413
1414 let reloaded: Session = serde_json::from_str(&serde_json::to_string(&selected).unwrap())
1415 .expect("reload durable selection");
1416 let resumed = prepare_chat_turn_from_authoritative_session(
1417 Some(reloaded),
1418 chat_turn_input(None),
1419 "",
1420 "Builtin fallback",
1421 )
1422 .expect("omission preserves selection");
1423 assert!(resumed.root_orchestration_only_enabled());
1424 assert_eq!(resumed.root_tool_authority_revision, 1);
1425
1426 let mut disable = chat_turn_input(None);
1427 disable.root_orchestration_only = Some(false);
1428 let disabled = prepare_chat_turn_from_authoritative_session(
1429 Some(resumed),
1430 disable,
1431 "",
1432 "Builtin fallback",
1433 )
1434 .expect("explicit disable");
1435 assert!(!disabled.root_orchestration_only_enabled());
1436 assert_eq!(disabled.root_tool_authority_revision, 2);
1437
1438 let child = Session::new_child_of("root-child", &disabled, "model", "child");
1439 for enabled in [false, true] {
1440 let mut input = chat_turn_input(None);
1441 input.session_id = child.id.clone();
1442 input.root_orchestration_only = Some(enabled);
1443 assert!(matches!(
1444 prepare_chat_turn_from_authoritative_session(
1445 Some(child.clone()),
1446 input,
1447 "",
1448 "Builtin fallback"
1449 ),
1450 Err(ChatError::RootToolAuthority(
1451 bamboo_domain::RootToolAuthorityError::NotRoot
1452 ))
1453 ));
1454 }
1455 }
1456
1457 #[test]
1458 fn root_tool_selection_rejects_workflow_and_plan_before_persistence() {
1459 let mut selected = Session::new("session-enhance", "model");
1460 selected.set_root_orchestration_only(true).unwrap();
1461 let mut skill_request = chat_turn_input(None);
1462 skill_request.selected_skill_ids = Some(vec!["review".into()]);
1463 assert!(matches!(
1464 prepare_chat_turn_from_authoritative_session(
1465 Some(selected.clone()),
1466 skill_request,
1467 "",
1468 "Builtin fallback"
1469 ),
1470 Err(ChatError::RootToolAuthority(
1471 bamboo_domain::RootToolAuthorityError::WorkflowSelected
1472 ))
1473 ));
1474
1475 let selection = WorkflowSelection {
1476 id: "review".into(),
1477 source: bamboo_skills::WorkflowSource::Builtin,
1478 revision: 1,
1479 args: serde_json::json!({}),
1480 };
1481 let mut workflow_request = chat_turn_input(None);
1482 workflow_request.workflow_selection = Some(selection.clone());
1483 assert!(matches!(
1484 prepare_chat_turn_from_authoritative_session(
1485 Some(selected.clone()),
1486 workflow_request,
1487 "",
1488 "Builtin fallback"
1489 ),
1490 Err(ChatError::RootToolAuthority(
1491 bamboo_domain::RootToolAuthorityError::WorkflowSelected
1492 ))
1493 ));
1494
1495 let mut disable_and_select = chat_turn_input(None);
1496 disable_and_select.root_orchestration_only = Some(false);
1497 disable_and_select.workflow_selection = Some(selection);
1498 let switched = prepare_chat_turn_from_authoritative_session(
1499 Some(selected),
1500 disable_and_select,
1501 "",
1502 "Builtin fallback",
1503 )
1504 .expect("explicit disable permits a Workflow selection");
1505 assert!(!switched.root_orchestration_only_enabled());
1506 assert_eq!(switched.selected_skill_ids(), Some(vec!["review".into()]));
1507
1508 let mut malformed = Session::new("session-enhance", "model");
1509 malformed
1510 .metadata
1511 .insert(WORKFLOW_SELECTION_METADATA_KEY.into(), "{".into());
1512 let mut enable = chat_turn_input(None);
1513 enable.root_orchestration_only = Some(true);
1514 assert!(matches!(
1515 prepare_chat_turn_from_authoritative_session(
1516 Some(malformed),
1517 enable,
1518 "",
1519 "Builtin fallback"
1520 ),
1521 Err(ChatError::RootToolAuthority(
1522 bamboo_domain::RootToolAuthorityError::WorkflowSelected
1523 ))
1524 ));
1525
1526 let mut legacy_plan = Session::new("session-enhance", "model");
1527 legacy_plan.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState {
1528 plan_mode: Some(bamboo_domain::PlanModeState {
1529 entered_at: chrono::Utc::now(),
1530 pre_permission_mode: "default".into(),
1531 plan_file_path: None,
1532 status: bamboo_domain::PlanModeStatus::Exploring,
1533 }),
1534 ..bamboo_domain::AgentRuntimeState::default()
1535 });
1536 let mut enable = chat_turn_input(None);
1537 enable.root_orchestration_only = Some(true);
1538 assert!(matches!(
1539 prepare_chat_turn_from_authoritative_session(
1540 Some(legacy_plan),
1541 enable,
1542 "",
1543 "Builtin fallback"
1544 ),
1545 Err(ChatError::RootToolAuthority(
1546 bamboo_domain::RootToolAuthorityError::LegacyPlanActive
1547 ))
1548 ));
1549 }
1550
1551 #[test]
1552 fn supervisor_delegation_first_rejects_execution_workflow_selection_in_both_modes() {
1553 use bamboo_domain::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
1554 for ultra in [false, true] {
1555 let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
1556 supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
1557 incarnation_id: uuid::Uuid::new_v4(),
1558 };
1559 supervisor.set_root_orchestration_only(ultra).unwrap();
1560 let mut input = chat_turn_input(None);
1561 input.session_id = supervisor.id.clone();
1562 let prepared = prepare_chat_turn_from_authoritative_session(
1563 Some(supervisor.clone()),
1564 input,
1565 "",
1566 "base",
1567 )
1568 .unwrap();
1569 assert_eq!(
1570 prepared.root_thinking_mode(),
1571 supervisor.root_thinking_mode()
1572 );
1573 assert!(prepared.model_orchestration_only_enabled());
1574 let mut input = chat_turn_input(None);
1575 input.session_id = supervisor.id.clone();
1576 input.selected_skill_ids = Some(vec!["implementation-workflow".into()]);
1577 assert!(matches!(
1578 prepare_chat_turn_from_authoritative_session(Some(supervisor), input, "", "base"),
1579 Err(ChatError::RootToolAuthority(
1580 bamboo_domain::RootToolAuthorityError::WorkflowSelected
1581 ))
1582 ));
1583 }
1584 }
1585
1586 #[test]
1587 fn workspace_switch_preserves_system_identity_and_prompt_fingerprint() {
1588 let first_workspace = "/workspace/first-private";
1589 let second_workspace = "/workspace/second-private";
1590 let mut first_input = chat_turn_input(Some("Stable enhancement"));
1591 first_input.workspace_path = Some(first_workspace.to_string());
1592 let first = prepare_chat_turn_from_authoritative_session(
1593 None,
1594 first_input,
1595 "Global prompt",
1596 "Builtin prompt",
1597 )
1598 .expect("first workspace turn");
1599 let first_system = first
1600 .messages
1601 .iter()
1602 .find(|message| matches!(message.role, Role::System))
1603 .expect("first system message")
1604 .clone();
1605 let first_fingerprint = first
1606 .metadata
1607 .get(PROMPT_FINGERPRINT_KEY)
1608 .expect("first prompt fingerprint")
1609 .clone();
1610
1611 let mut switched_input = chat_turn_input(Some("Stable enhancement"));
1612 switched_input.workspace_path = Some(second_workspace.to_string());
1613 let switched = prepare_chat_turn_from_authoritative_session(
1614 Some(first),
1615 switched_input,
1616 "Global prompt",
1617 "Builtin prompt",
1618 )
1619 .expect("switched workspace turn");
1620 let switched_system = switched
1621 .messages
1622 .iter()
1623 .find(|message| matches!(message.role, Role::System))
1624 .expect("switched system message");
1625
1626 assert_eq!(switched_system.id, first_system.id);
1627 assert_eq!(switched_system.content, first_system.content);
1628 assert_eq!(
1629 switched.metadata.get(PROMPT_FINGERPRINT_KEY),
1630 Some(&first_fingerprint)
1631 );
1632 assert_eq!(
1633 switched.workspace_path_meta().as_deref(),
1634 Some(second_workspace)
1635 );
1636 assert!(!switched_system.content.contains(first_workspace));
1637 assert!(!switched_system.content.contains(second_workspace));
1638 assert!(!switched_system
1639 .content
1640 .contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1641 assert!(switched
1642 .prompt_snapshot
1643 .as_ref()
1644 .and_then(|snapshot| snapshot.workspace_context.as_deref())
1645 .is_some_and(|context| context.contains(second_workspace)));
1646 }
1647
1648 #[test]
1649 fn authoritative_none_uses_session_fallback_during_prompt_composition() {
1650 let fixture = tempfile::tempdir().expect("fixture");
1651 let foreign_default = fixture.path().join("foreign-default");
1652 std::fs::create_dir_all(&foreign_default).expect("foreign default");
1653 std::fs::write(
1654 fixture.path().join("config.json"),
1655 serde_json::json!({
1656 "default_work_area": { "path": foreign_default.to_string_lossy() }
1657 })
1658 .to_string(),
1659 )
1660 .expect("write legacy config");
1661 let session_fallback = fixture
1662 .path()
1663 .join("request-state-root/workspaces/session-enhance");
1664 let session_fallback_display = path_to_display_string(&session_fallback);
1665 let mut input = chat_turn_input(None);
1666 input.data_dir = Some(fixture.path().to_path_buf());
1667
1668 let session = prepare_chat_turn_from_authoritative_session_with_workspace_policy(
1669 None,
1670 input,
1671 "global",
1672 "builtin",
1673 ChatWorkspaceFallbackPolicy::Authoritative {
1674 session_fallback_path: Some(session_fallback_display.clone()),
1675 },
1676 )
1677 .expect("authoritative turn");
1678
1679 assert_eq!(
1680 session.workspace_path_meta().as_deref(),
1681 Some(session_fallback_display.as_str())
1682 );
1683 let system_prompt = system_message_content(&session);
1684 assert!(
1685 !system_prompt.contains(&session_fallback_display),
1686 "workspace path must stay out of the persisted System message"
1687 );
1688 assert!(
1689 !system_prompt.contains(foreign_default.to_string_lossy().as_ref()),
1690 "authoritative None must suppress the legacy configured default"
1691 );
1692 assert!(
1693 session
1694 .prompt_snapshot
1695 .as_ref()
1696 .and_then(|snapshot| snapshot.workspace_context.as_deref())
1697 .is_some_and(|context| context.contains(&session_fallback_display)),
1698 "prompt snapshot must retain the authoritative session fallback"
1699 );
1700 }
1701
1702 #[test]
1703 fn authoritative_none_without_session_fallback_skips_legacy_lookup() {
1704 let resolved = resolve_workspace_fallback_with(
1705 &ChatWorkspaceFallbackPolicy::Authoritative {
1706 session_fallback_path: None,
1707 },
1708 || panic!("authoritative None must not read a process-global or disk default"),
1709 );
1710
1711 assert_eq!(resolved, None);
1712 }
1713
1714 #[test]
1715 fn legacy_policy_preserves_non_server_default_workspace_lookup() {
1716 let fixture = tempfile::tempdir().expect("fixture");
1717 let configured_default = fixture.path().join("configured-default");
1718 std::fs::create_dir_all(&configured_default).expect("configured default");
1719 std::fs::write(
1720 fixture.path().join("config.json"),
1721 serde_json::json!({
1722 "default_work_area": { "path": configured_default.to_string_lossy() }
1723 })
1724 .to_string(),
1725 )
1726 .expect("write config");
1727 let mut input = chat_turn_input(None);
1728 input.data_dir = Some(fixture.path().to_path_buf());
1729
1730 let session =
1731 prepare_chat_turn_from_authoritative_session(None, input, "global", "builtin")
1732 .expect("legacy turn");
1733 let resolved = session
1734 .workspace_path_meta()
1735 .map(PathBuf::from)
1736 .expect("legacy configured default");
1737
1738 assert_eq!(
1739 resolved.canonicalize().expect("resolved canonical"),
1740 configured_default
1741 .canonicalize()
1742 .expect("configured canonical")
1743 );
1744 assert_eq!(
1745 session
1746 .metadata
1747 .get(crate::project_context::WORKSPACE_SOURCE_METADATA_KEY)
1748 .map(String::as_str),
1749 Some(crate::project_context::WorkspaceSource::Session.as_str())
1750 );
1751 assert!(session
1752 .prompt_snapshot
1753 .as_ref()
1754 .and_then(|snapshot| snapshot.workspace_context.as_deref())
1755 .is_some_and(|context| context.contains("Workspace source: session")));
1756 }
1757
1758 #[test]
1764 fn default_workspace_from_data_dir_reads_configured_work_area() {
1765 let temp = tempfile::tempdir().expect("temp dir");
1766 let workspace = temp.path().join("default-workspace");
1767 std::fs::create_dir_all(&workspace).expect("workspace dir");
1768 std::fs::write(
1769 temp.path().join("config.json"),
1770 serde_json::json!({
1771 "default_work_area": { "path": workspace.to_string_lossy() }
1772 })
1773 .to_string(),
1774 )
1775 .expect("write config.json");
1776
1777 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
1778 assert_eq!(
1782 resolved.canonicalize().unwrap(),
1783 workspace.canonicalize().unwrap()
1784 );
1785 }
1786
1787 #[test]
1788 fn default_workspace_from_data_dir_is_none_without_config() {
1789 let temp = tempfile::tempdir().expect("temp dir");
1790 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
1791 }
1792}