1use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
4use crate::runner::refresh_prompt_snapshot;
5use bamboo_agent_core::{Role, Session};
6use bamboo_config::paths::path_to_display_string;
7use bamboo_domain::Message;
8use bamboo_skills::selection::normalize_selected_skill_ids;
9use bamboo_skills::{
10 ActiveWorkflow, WorkflowActivationStatus, WorkflowSelection, ACTIVE_WORKFLOW_METADATA_KEY,
11 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY, WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,
12 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY, WORKFLOW_SELECTION_METADATA_KEY,
13};
14use sha2::{Digest, Sha256};
15use std::path::{Path, PathBuf};
16
17use super::errors::ChatError;
18use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
19use super::repository::SessionAccess;
20use super::types::ChatTurnInput;
21
22const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
24const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
25const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
26const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
27const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
28const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
29const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
30const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
31
32const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
33pub const SESSION_START_SOURCE_METADATA_KEY: &str = "runtime.session_start_source";
34
35pub async fn prepare_chat_turn(
43 repo: &dyn SessionAccess,
44 input: ChatTurnInput,
45 global_default_prompt: &str,
46 builtin_fallback_prompt: &str,
47) -> Result<Session, ChatError> {
48 let (mut session, session_start_source) = match repo.load_merged(&input.session_id).await? {
49 Some(session) => (session, "resume"),
50 None => (
51 repo.load_or_create(&input.session_id, &input.model).await?,
52 "startup",
53 ),
54 };
55 session.metadata.insert(
56 SESSION_START_SOURCE_METADATA_KEY.to_string(),
57 session_start_source.to_string(),
58 );
59
60 let base_prompt = resolve_base_prompt(
62 &mut session,
63 input.system_prompt.as_deref(),
64 global_default_prompt,
65 builtin_fallback_prompt,
66 );
67
68 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
70 let enhance_prompt = session.enhance_prompt();
71
72 resolve_copilot_conclusion_with_options_enhancement(
74 &mut session,
75 input.copilot_conclusion_with_options_enhancement_enabled,
76 );
77
78 let workspace_path = resolve_workspace_path(
80 &mut session,
81 input.workspace_path.as_deref(),
82 input.data_dir.as_deref(),
83 );
84
85 resolve_workflow_selection(
87 &mut session,
88 input.workflow_selection.as_ref(),
89 input.selected_skill_ids.as_deref(),
90 &input.message,
91 )?;
92 if let Some(opted_in) = input.orchestration_opt_in {
93 session.metadata.insert(
94 WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
95 opted_in.to_string(),
96 );
97 }
98
99 let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
101 &base_prompt,
102 enhance_prompt.as_deref(),
103 workspace_path.as_deref(),
104 );
105
106 session.metadata.insert(
107 PROMPT_COMPOSER_VERSION_KEY.to_string(),
108 prompt_profile.version.to_string(),
109 );
110 session.metadata.insert(
111 PROMPT_FINGERPRINT_KEY.to_string(),
112 prompt_profile.fingerprint.clone(),
113 );
114 session.metadata.insert(
115 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
116 prompt_profile.component_flags_value(),
117 );
118 session.metadata.insert(
119 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
120 prompt_profile.component_lengths_value(),
121 );
122
123 session
125 .messages
126 .retain(|message| !matches!(message.role, Role::System));
127 session.messages.insert(0, Message::system(system_prompt));
128 refresh_prompt_snapshot(&mut session);
129
130 let request_model_ref = derive_model_ref(
132 input.model_ref.as_ref(),
133 input.provider.as_deref(),
134 Some(input.model.as_str()),
135 );
136 if let Some(model_ref) = request_model_ref.as_ref() {
137 persist_model_ref(&mut session, model_ref);
138 } else {
139 persist_legacy_model_provider(
140 &mut session,
141 Some(input.model.as_str()),
142 input.provider.as_deref(),
143 );
144 }
145
146 repo.save_and_cache(&mut session).await?;
148
149 Ok(session)
150}
151
152pub fn resolve_base_prompt(
155 session: &mut Session,
156 base_prompt_from_request: Option<&str>,
157 global_default_template: &str,
158 builtin_fallback: &str,
159) -> String {
160 let resolved = base_prompt_from_request
161 .map(ToString::to_string)
162 .or_else(|| {
163 session
164 .metadata
165 .get(BASE_SYSTEM_PROMPT_KEY)
166 .map(String::as_str)
167 .map(str::trim)
168 .filter(|value| !value.is_empty())
169 .map(ToString::to_string)
170 })
171 .or_else(|| {
172 session
173 .messages
174 .iter()
175 .find(|message| matches!(message.role, Role::System))
176 .map(|message| message.content.trim().to_string())
177 .filter(|value| !value.is_empty())
178 })
179 .unwrap_or_else(|| {
180 let trimmed = global_default_template.trim();
181 if trimmed.is_empty() {
182 builtin_fallback.to_string()
183 } else {
184 trimmed.to_string()
185 }
186 });
187
188 session
189 .metadata
190 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
191 resolved
192}
193
194pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
195 if let Some(prompt) = enhance_prompt_from_request {
196 session.set_enhance_prompt(prompt);
197 } else {
198 session.clear_enhance_prompt();
199 }
200}
201
202pub fn resolve_copilot_conclusion_with_options_enhancement(
203 session: &mut Session,
204 enabled_from_request: Option<bool>,
205) {
206 if let Some(enabled) = enabled_from_request {
207 session
208 .metadata
209 .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
210 } else {
211 session.metadata.remove(COPILOT_CONCLUSION_KEY);
212 }
213}
214
215pub fn resolve_workspace_path(
216 session: &mut Session,
217 workspace_path_from_request: Option<&str>,
218 data_dir: Option<&Path>,
219) -> Option<String> {
220 if let Some(path) = workspace_path_from_request {
221 session.set_workspace_path_meta(path);
222 }
223
224 workspace_path_from_request
225 .map(ToString::to_string)
226 .or_else(|| session.workspace_path_meta())
227 .or_else(|| resolve_default_workspace(data_dir))
228}
229
230fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
241 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
242 bamboo_agent_core::workspace_state::get_configured_default_workspace()
243 } else {
244 default_workspace_from_data_dir(data_dir)
245 };
246 configured.map(|path| path_to_display_string(&path))
247}
248
249fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
252 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
253}
254
255pub fn resolve_selected_skill_ids(
256 session: &mut Session,
257 selected_skill_ids_from_request: Option<&[String]>,
258 message: &str,
259) {
260 if let Some(request_ids) = selected_skill_ids_from_request {
261 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
262 persist_selected_skill_ids_metadata(session, normalized.as_deref());
263 return;
264 }
265
266 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
267 if let Some(ids) = from_hint.as_ref() {
268 persist_selected_skill_ids_metadata(session, Some(ids));
269 return;
270 }
271
272 session.clear_selected_skill_ids();
273}
274
275pub fn resolve_workflow_selection(
276 session: &mut Session,
277 workflow_selection: Option<&WorkflowSelection>,
278 selected_skill_ids_from_request: Option<&[String]>,
279 message: &str,
280) -> Result<(), ChatError> {
281 if let Some(selection) = workflow_selection {
282 let id = selection.id.trim();
283 if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
284 return Err(ChatError::InvalidWorkflowSelection(
285 "id must be non-empty, revision must be positive, and args must be an object"
286 .to_string(),
287 ));
288 }
289 let previous = session
290 .metadata
291 .get(WORKFLOW_SELECTION_METADATA_KEY)
292 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
293 let selection_changed = previous.as_ref() != Some(selection);
294 session.metadata.insert(
295 WORKFLOW_SELECTION_METADATA_KEY.to_string(),
296 serde_json::to_string(selection).map_err(|_| {
297 ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
298 })?,
299 );
300 persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
301 if selection_changed {
302 deactivate_active_workflow(session);
303 clear_skill_runtime_state(session);
304 }
305 return Ok(());
306 }
307
308 if selected_skill_ids_from_request.is_some() {
309 session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
312 deactivate_active_workflow(session);
313 resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
314 clear_skill_runtime_state(session);
315 return Ok(());
316 }
317
318 if let Some(active) = session
319 .metadata
320 .get(ACTIVE_WORKFLOW_METADATA_KEY)
321 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
322 .filter(|active| active.status == WorkflowActivationStatus::Active)
323 {
324 persist_selected_skill_ids_metadata(session, Some(&[active.id]));
325 return Ok(());
326 }
327
328 resolve_selected_skill_ids(session, None, message);
331 Ok(())
332}
333
334fn deactivate_active_workflow(session: &mut Session) {
335 if let Some(active) = session
336 .metadata
337 .get(ACTIVE_WORKFLOW_METADATA_KEY)
338 .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
339 .filter(|active| active.status == WorkflowActivationStatus::Active)
340 {
341 session.metadata.insert(
342 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
343 serde_json::json!({
344 "type": "workflow.deactivated",
345 "workflow_id": active.id,
346 "revision": active.revision,
347 "deactivated_at": chrono::Utc::now(),
348 })
349 .to_string(),
350 );
351 }
352 session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
353 session
354 .metadata
355 .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
356}
357
358pub fn clear_skill_runtime_state(session: &mut Session) {
360 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
361 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
362}
363
364fn persist_selected_skill_ids_metadata(
365 session: &mut Session,
366 selected_skill_ids: Option<&[String]>,
367) {
368 match selected_skill_ids {
369 Some(ids) if !ids.is_empty() => {
370 session.set_selected_skill_ids(ids.to_vec());
371 }
372 _ => {
373 session.clear_selected_skill_ids();
374 }
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum GoalCommand {
383 Status,
385 Off,
387 Clear,
389 On,
391 SetPrompt(String),
393}
394
395pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
398 let trimmed = message.trim();
399 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
400 return None;
401 }
402 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
405 return None;
406 }
407
408 let arg = rest.trim().to_ascii_lowercase();
409
410 if arg.is_empty() {
411 return Some(GoalCommand::Status);
412 }
413
414 match arg.as_str() {
415 "status" => Some(GoalCommand::Status),
416 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
417 "clear" | "reset" => Some(GoalCommand::Clear),
418 "on" | "enable" | "enabled" => Some(GoalCommand::On),
419 _ => {
420 let prompt = trimmed
423 .strip_prefix("/goal")
424 .unwrap_or(trimmed)
425 .trim()
426 .to_string();
427 if prompt.is_empty() {
428 Some(GoalCommand::Status)
429 } else {
430 Some(GoalCommand::SetPrompt(prompt))
431 }
432 }
433 }
434}
435
436fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
437 const HINT_PREFIX: &str = "[User explicitly selected skill:";
438 let mut extracted = Vec::new();
439
440 for line in message.lines() {
441 let trimmed = line.trim();
442 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
443 continue;
444 }
445
446 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
447 continue;
448 };
449 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
450 let Some(close_paren_index) = id_segment.find(')') else {
451 continue;
452 };
453 let id = id_segment[..close_paren_index].trim();
454 if !id.is_empty() {
455 extracted.push(id.to_string());
456 }
457 }
458
459 extracted
460}
461
462#[derive(Debug, Clone, PartialEq, Eq)]
465struct PromptCompositionProfile {
466 version: &'static str,
467 fingerprint: String,
468 has_enhancement: bool,
469 has_workspace_context: bool,
470 has_env_context: bool,
471 base_len: usize,
472 enhancement_len: usize,
473 workspace_context_len: usize,
474 env_context_len: usize,
475 final_len: usize,
476}
477
478impl PromptCompositionProfile {
479 fn component_flags_value(&self) -> String {
480 format!(
481 "enhance={};workspace={};env={}",
482 self.has_enhancement as u8,
483 self.has_workspace_context as u8,
484 self.has_env_context as u8,
485 )
486 }
487
488 fn component_lengths_value(&self) -> String {
489 format!(
490 "base={};enhance={};workspace={};env={};final={}",
491 self.base_len,
492 self.enhancement_len,
493 self.workspace_context_len,
494 self.env_context_len,
495 self.final_len
496 )
497 }
498}
499
500fn build_prompt_fingerprint(
501 base_prompt: &str,
502 enhancement: Option<&str>,
503 workspace: Option<&str>,
504 env_context: Option<&str>,
505) -> String {
506 let mut hasher = Sha256::new();
507 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
508 hasher.update([0u8]);
509 hasher.update(base_prompt.as_bytes());
510 hasher.update([0u8]);
511 hasher.update(enhancement.unwrap_or_default().as_bytes());
512 hasher.update([0u8]);
513 hasher.update(workspace.unwrap_or_default().as_bytes());
514 hasher.update([0u8]);
515 hasher.update(env_context.unwrap_or_default().as_bytes());
516 hex::encode(hasher.finalize())
517}
518
519fn build_enhanced_system_prompt_with_profile(
520 base_prompt: &str,
521 enhance_prompt: Option<&str>,
522 workspace_path: Option<&str>,
523) -> (String, PromptCompositionProfile) {
524 let mut merged_prompt = base_prompt.to_string();
525
526 let enhancement = enhance_prompt
527 .map(str::trim)
528 .filter(|enhancement| !enhancement.is_empty())
529 .map(ToString::to_string);
530 if let Some(enhancement) = enhancement.as_ref() {
531 merged_prompt.push_str("\n\n");
532 merged_prompt.push_str(enhancement.as_str());
533 }
534
535 let workspace_context = workspace_path
536 .map(str::trim)
537 .filter(|workspace_path| !workspace_path.is_empty())
538 .and_then(build_workspace_prompt_context);
539 if let Some(workspace_context) = workspace_context.as_ref() {
540 merged_prompt.push_str("\n\n");
541 merged_prompt.push_str(workspace_context.as_str());
542 }
543
544 let env_context = build_env_prompt_context();
545 if let Some(env_context) = env_context.as_ref() {
546 merged_prompt.push_str("\n\n");
547 merged_prompt.push_str(env_context.as_str());
548 }
549
550 let profile = PromptCompositionProfile {
551 version: PROMPT_COMPOSER_VERSION,
552 fingerprint: build_prompt_fingerprint(
553 base_prompt,
554 enhancement.as_deref(),
555 workspace_context.as_deref(),
556 env_context.as_deref(),
557 ),
558 has_enhancement: enhancement.is_some(),
559 has_workspace_context: workspace_context.is_some(),
560 has_env_context: env_context.is_some(),
561 base_len: base_prompt.len(),
562 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
563 workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
564 env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
565 final_len: merged_prompt.len(),
566 };
567
568 (merged_prompt, profile)
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::session_app::errors::{SessionLoadError, SessionSaveError};
575 use async_trait::async_trait;
576
577 struct InMemorySessionAccess;
578
579 struct ExistingSessionAccess(Session);
580
581 #[async_trait]
582 impl SessionAccess for InMemorySessionAccess {
583 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
584 Ok(None)
585 }
586
587 async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
588 Ok(Session::new(id, model))
589 }
590
591 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
592 Ok(None)
593 }
594
595 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
596 Ok(())
597 }
598
599 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
600 Ok(())
601 }
602 }
603
604 #[async_trait]
605 impl SessionAccess for ExistingSessionAccess {
606 async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
607 Ok(Some(self.0.clone()))
608 }
609
610 async fn load_or_create(
611 &self,
612 _id: &str,
613 _model: &str,
614 ) -> Result<Session, SessionLoadError> {
615 panic!("existing chat session must not be recreated")
616 }
617
618 async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
619 Ok(Some(self.0.clone()))
620 }
621
622 async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
623 Ok(())
624 }
625
626 async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
627 Ok(())
628 }
629 }
630
631 fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
632 super::super::types::ChatTurnInput {
633 session_id: "session-enhance".to_string(),
634 model: "gpt-5".to_string(),
635 model_ref: None,
636 provider: None,
637 message: "hello".to_string(),
638 system_prompt: Some("Base prompt".to_string()),
639 enhance_prompt: enhance_prompt.map(ToString::to_string),
640 workspace_path: None,
641 selected_skill_ids: None,
642 workflow_selection: None,
643 orchestration_opt_in: None,
644 copilot_conclusion_with_options_enhancement_enabled: None,
645 data_dir: None,
646 }
647 }
648
649 fn system_message_content(session: &Session) -> String {
650 session
651 .messages
652 .iter()
653 .find(|message| matches!(message.role, Role::System))
654 .map(|message| message.content.clone())
655 .expect("session should have a system message")
656 }
657
658 fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
659 ActiveWorkflow {
660 id: id.to_string(),
661 source: bamboo_skills::WorkflowSource::User,
662 revision,
663 kind: bamboo_skills::WorkflowKind::Instruction,
664 args: serde_json::json!({}),
665 invoked_by: bamboo_skills::WorkflowInvokedBy::User,
666 activated_at: chrono::Utc::now(),
667 status: WorkflowActivationStatus::Active,
668 diagnostic: None,
669 context_fingerprint: Some("fingerprint".to_string()),
670 dynamic_context: Vec::new(),
671 }
672 }
673
674 #[tokio::test]
675 async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
676 let startup = prepare_chat_turn(
677 &InMemorySessionAccess,
678 chat_turn_input(None),
679 "global",
680 "builtin",
681 )
682 .await
683 .expect("new turn");
684 assert_eq!(
685 startup
686 .metadata
687 .get(SESSION_START_SOURCE_METADATA_KEY)
688 .map(String::as_str),
689 Some("startup")
690 );
691
692 let existing = prepare_chat_turn(
693 &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
694 chat_turn_input(None),
695 "global",
696 "builtin",
697 )
698 .await
699 .expect("existing turn");
700 assert_eq!(
701 existing
702 .metadata
703 .get(SESSION_START_SOURCE_METADATA_KEY)
704 .map(String::as_str),
705 Some("resume")
706 );
707 }
708
709 #[test]
710 fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
711 let mut session = Session::new("typed-selection", "model");
712 let selection = WorkflowSelection {
713 id: "review".to_string(),
714 source: bamboo_skills::WorkflowSource::User,
715 revision: 7,
716 args: serde_json::json!({"depth": "full"}),
717 };
718 resolve_workflow_selection(
719 &mut session,
720 Some(&selection),
721 Some(&["plan".to_string()]),
722 "use skill plan",
723 )
724 .expect("typed selection");
725 assert_eq!(
726 session.selected_skill_ids(),
727 Some(vec!["review".to_string()])
728 );
729 assert_eq!(
730 session
731 .metadata
732 .get(WORKFLOW_SELECTION_METADATA_KEY)
733 .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
734 Some(selection)
735 );
736 }
737
738 #[test]
739 fn active_workflow_survives_turn_without_new_selection() {
740 let mut session = Session::new("active-selection", "model");
741 session.metadata.insert(
742 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
743 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
744 );
745 resolve_workflow_selection(&mut session, None, None, "use skill plan")
746 .expect("preserve active");
747 assert_eq!(
748 session.selected_skill_ids(),
749 Some(vec!["review".to_string()])
750 );
751 assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
752 }
753
754 #[test]
755 fn explicit_empty_legacy_selection_deactivates_active_workflow() {
756 let mut session = Session::new("deactivate-selection", "model");
757 session.metadata.insert(
758 ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
759 serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
760 );
761 resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
762 .expect("deactivate");
763 assert!(session.selected_skill_ids().is_none());
764 assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
765 assert!(session
766 .metadata
767 .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
768 .is_some_and(|event| event.contains("workflow.deactivated")));
769 }
770
771 #[tokio::test]
774 async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
775 let session = prepare_chat_turn(
776 &InMemorySessionAccess,
777 chat_turn_input(Some("Extra enhancement guidance")),
778 "",
779 "Builtin fallback",
780 )
781 .await
782 .expect("prepare_chat_turn should succeed");
783
784 let system_prompt = system_message_content(&session);
785 assert!(system_prompt.starts_with("Base prompt"));
786 assert!(system_prompt.contains("Extra enhancement guidance"));
787 assert_eq!(
788 session.enhance_prompt().as_deref(),
789 Some("Extra enhancement guidance")
790 );
791 assert!(session
792 .metadata
793 .get(PROMPT_COMPONENT_FLAGS_KEY)
794 .is_some_and(|flags| flags.contains("enhance=1")));
795 }
796
797 #[tokio::test]
798 async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
799 let session = prepare_chat_turn(
800 &InMemorySessionAccess,
801 chat_turn_input(None),
802 "",
803 "Builtin fallback",
804 )
805 .await
806 .expect("prepare_chat_turn should succeed");
807
808 let system_prompt = system_message_content(&session);
809 assert!(system_prompt.starts_with("Base prompt"));
810 assert!(session.enhance_prompt().is_none());
811 assert!(session
812 .metadata
813 .get(PROMPT_COMPONENT_FLAGS_KEY)
814 .is_some_and(|flags| flags.contains("enhance=0")));
815 }
816
817 #[test]
823 fn default_workspace_from_data_dir_reads_configured_work_area() {
824 let temp = tempfile::tempdir().expect("temp dir");
825 let workspace = temp.path().join("default-workspace");
826 std::fs::create_dir_all(&workspace).expect("workspace dir");
827 std::fs::write(
828 temp.path().join("config.json"),
829 serde_json::json!({
830 "default_work_area": { "path": workspace.to_string_lossy() }
831 })
832 .to_string(),
833 )
834 .expect("write config.json");
835
836 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
837 assert_eq!(
841 resolved.canonicalize().unwrap(),
842 workspace.canonicalize().unwrap()
843 );
844 }
845
846 #[test]
847 fn default_workspace_from_data_dir_is_none_without_config() {
848 let temp = tempfile::tempdir().expect("temp dir");
849 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
850 }
851}