bamboo_engine/session_app/
execution_prep.rs1use std::path::Path;
19
20use bamboo_agent_core::{AgentError, Message, Role, Session};
21
22use crate::project_context::{
23 ProjectContextResolver, SessionProjectIdentity, WorkspaceBindingStatus, WorkspaceSource,
24 WORKSPACE_BINDING_STATUS_METADATA_KEY, WORKSPACE_SOURCE_METADATA_KEY,
25};
26
27#[derive(Debug, Clone, Copy)]
33pub struct ResolvedExecutionWorkspace<'a> {
34 pub path: &'a Path,
35 pub source: WorkspaceSource,
36 pub binding_status: WorkspaceBindingStatus,
37}
38
39pub fn publish_resolved_workspace_for_execution(
47 session: &mut Session,
48 workspace: ResolvedExecutionWorkspace<'_>,
49 workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
50 publication_source: &str,
51) {
52 let final_workspace = workspace_resolver.publish_resolved_workspace(
53 &session.id,
54 workspace.path.to_path_buf(),
55 publication_source,
56 );
57 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
58 &final_workspace,
59 ));
60 session.metadata.insert(
61 WORKSPACE_SOURCE_METADATA_KEY.to_string(),
62 workspace.source.as_str().to_string(),
63 );
64 session.metadata.insert(
65 WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
66 workspace.binding_status.as_str().to_string(),
67 );
68}
69
70pub async fn prepare_external_session_for_execution(
85 session: &mut Session,
86 project_context_resolver: Option<&ProjectContextResolver>,
87) -> Result<(), AgentError> {
88 match ProjectContextResolver::session_project_identity(session) {
93 SessionProjectIdentity::Invalid { raw, message } => {
94 return Err(AgentError::ProjectContext(format!(
95 "session carries an invalid Project identity '{raw}': {message}"
96 )));
97 }
98 SessionProjectIdentity::Assigned(project_id) if project_context_resolver.is_none() => {
99 return Err(AgentError::ProjectContext(format!(
100 "assigned Project '{project_id}' requires a ProjectContextResolver before external execution"
101 )));
102 }
103 SessionProjectIdentity::Assigned(_) | SessionProjectIdentity::Unassigned => {}
104 }
105
106 crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
110
111 if let Some(resolver) = project_context_resolver {
112 resolver
113 .refresh_session_prompt(session)
114 .await
115 .map_err(|error| AgentError::ProjectContext(error.to_string()))?;
116 return Ok(());
117 }
118
119 let workspace = ProjectContextResolver::resolve_workspace_candidate(session, None)
124 .map_err(|error| AgentError::ProjectContext(error.to_string()))?;
125 if let Some(workspace) = workspace.as_deref() {
126 let source = WorkspaceSource::from_metadata(
127 session
128 .metadata
129 .get(WORKSPACE_SOURCE_METADATA_KEY)
130 .map(String::as_str),
131 );
132 publish_resolved_workspace_for_execution(
133 session,
134 ResolvedExecutionWorkspace {
135 path: workspace,
136 source,
137 binding_status: WorkspaceBindingStatus::Unregistered,
138 },
139 &bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
140 "external_execution_prep",
141 );
142 } else {
143 session.metadata.remove(WORKSPACE_SOURCE_METADATA_KEY);
144 session
145 .metadata
146 .remove(WORKSPACE_BINDING_STATUS_METADATA_KEY);
147 }
148 crate::runner::refresh_prompt_snapshot(session);
149 Ok(())
150}
151
152pub fn prepare_session_for_execution(
168 session: &mut Session,
169 system_prompt: Option<&str>,
170 model: Option<&str>,
171) {
172 if let Some(prompt) = system_prompt {
173 match session.messages.first() {
174 Some(first) if matches!(first.role, Role::System) => {
175 session.messages[0] = Message::system(prompt.to_string());
176 }
177 _ => session
178 .messages
179 .insert(0, Message::system(prompt.to_string())),
180 }
181 }
182
183 if let Some(model) = model {
184 session.model = model.to_string();
185 }
186
187 crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
192 crate::runner::refresh_prompt_snapshot(session);
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use bamboo_agent_core::Message;
199 use std::sync::Arc;
200
201 struct EmptyProjectSource;
202
203 #[async_trait::async_trait]
204 impl crate::project_context::ProjectContextSource for EmptyProjectSource {
205 async fn find_project(
206 &self,
207 _project_id: &bamboo_domain::ProjectId,
208 ) -> Result<
209 Option<crate::project_context::ProjectDescriptor>,
210 crate::project_context::ProjectContextError,
211 > {
212 Ok(None)
213 }
214 }
215
216 fn session_with(messages: Vec<Message>) -> Session {
217 let mut s = Session::new("test-session", "old-model");
218 s.messages = messages;
219 s
220 }
221
222 #[test]
223 fn empty_session_with_prompt_inserts_at_index_zero() {
224 let mut session = session_with(vec![Message::user("hello")]);
225
226 prepare_session_for_execution(&mut session, Some("you are helpful"), None);
227
228 assert_eq!(session.messages.len(), 2);
229 assert!(matches!(session.messages[0].role, Role::System));
230 assert_eq!(session.messages[0].content, "you are helpful");
231 assert!(matches!(session.messages[1].role, Role::User));
232 }
233
234 #[test]
235 fn leading_system_is_replaced_not_duplicated() {
236 let mut session = session_with(vec![
237 Message::system("stale prompt"),
238 Message::user("hello"),
239 ]);
240
241 prepare_session_for_execution(&mut session, Some("authoritative prompt"), None);
242
243 assert_eq!(session.messages.len(), 2);
245 assert!(matches!(session.messages[0].role, Role::System));
246 assert_eq!(session.messages[0].content, "authoritative prompt");
247 assert!(matches!(session.messages[1].role, Role::User));
248 }
249
250 #[test]
251 fn leading_non_system_gets_prompt_inserted_at_zero() {
252 let mut session =
253 session_with(vec![Message::user("hello"), Message::assistant("hi", None)]);
254
255 prepare_session_for_execution(&mut session, Some("you are helpful"), None);
256
257 assert_eq!(session.messages.len(), 3);
258 assert!(matches!(session.messages[0].role, Role::System));
259 assert_eq!(session.messages[0].content, "you are helpful");
260 assert!(matches!(session.messages[1].role, Role::User));
261 assert!(matches!(session.messages[2].role, Role::Assistant));
262 }
263
264 #[test]
265 fn model_is_set_when_some() {
266 let mut session = session_with(vec![Message::user("hello")]);
267
268 prepare_session_for_execution(&mut session, None, Some("new-model"));
269
270 assert_eq!(session.model, "new-model");
271 assert_eq!(session.messages.len(), 1);
273 assert!(matches!(session.messages[0].role, Role::User));
274 }
275
276 #[test]
277 fn none_inputs_leave_session_untouched() {
278 let mut session = session_with(vec![Message::user("hello")]);
279
280 prepare_session_for_execution(&mut session, None, None);
281
282 assert_eq!(session.model, "old-model");
283 assert_eq!(session.messages.len(), 1);
284 assert!(session.prompt_snapshot.is_some());
285 }
286
287 #[tokio::test]
288 async fn external_prep_migrates_serialized_legacy_workspace_idempotently_before_configured_prompt(
289 ) {
290 let root = tempfile::tempdir().expect("workspace root");
291 let workspace = root.path().join("legacy-workspace");
292 std::fs::create_dir_all(&workspace).expect("legacy workspace");
293 let canonical = workspace.canonicalize().expect("canonical workspace");
294 let display = bamboo_config::paths::path_to_display_string(&canonical);
295 let legacy_block = crate::runtime::context::build_workspace_prompt_context(&display)
296 .expect("legacy Workspace block");
297
298 let mut legacy = Session::new("external-legacy", "old-model");
299 legacy.add_message(Message::system(legacy_block));
300 legacy.metadata.insert(
301 "runtime_prompt_snapshot".to_string(),
302 "stale-snapshot".to_string(),
303 );
304 let serialized = serde_json::to_vec(&legacy).expect("serialize legacy session");
305 let mut session: Session =
306 serde_json::from_slice(&serialized).expect("deserialize legacy session");
307
308 let resolver = ProjectContextResolver::new_with_workspace_resolver(
309 Arc::new(EmptyProjectSource),
310 bamboo_agent_core::workspace_state::WorkspaceResolver::new(|| None, {
311 let root = root.path().to_path_buf();
312 move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
313 root: root.clone(),
314 confine: false,
315 }
316 }),
317 );
318
319 prepare_external_session_for_execution(&mut session, Some(&resolver))
320 .await
321 .expect("first external prep");
322 assert!(
323 session
324 .messages
325 .iter()
326 .all(|message| !matches!(message.role, Role::System)),
327 "a marker-only legacy System message must be removed"
328 );
329 assert_eq!(
330 session.workspace_path_meta().as_deref(),
331 Some(display.as_str())
332 );
333 assert_eq!(
334 session
335 .metadata
336 .get(WORKSPACE_SOURCE_METADATA_KEY)
337 .map(String::as_str),
338 Some(WorkspaceSource::Session.as_str())
339 );
340 assert_eq!(
341 session
342 .metadata
343 .get(WORKSPACE_BINDING_STATUS_METADATA_KEY)
344 .map(String::as_str),
345 Some(WorkspaceBindingStatus::Unregistered.as_str())
346 );
347 assert!(!session.metadata.contains_key("runtime_prompt_snapshot"));
348 assert_eq!(
349 bamboo_agent_core::workspace_state::get_workspace(&session.id),
350 Some(canonical.clone())
351 );
352
353 let once = serde_json::to_value(&session).expect("first prepared session");
354 prepare_external_session_for_execution(&mut session, Some(&resolver))
355 .await
356 .expect("second external prep");
357 assert_eq!(
358 serde_json::to_value(&session).expect("second prepared session"),
359 once,
360 "external prep must be an exact session-state no-op after the first handoff"
361 );
362
363 prepare_session_for_execution(&mut session, Some("configured System"), Some("new-model"));
364 let systems = session
365 .messages
366 .iter()
367 .filter(|message| matches!(message.role, Role::System))
368 .collect::<Vec<_>>();
369 assert_eq!(systems.len(), 1);
370 assert_eq!(systems[0].content, "configured System");
371 assert!(!systems[0].content.contains(&display));
372 assert!(!systems[0].content.contains("BAMBOO_WORKSPACE_CONTEXT"));
373 assert_eq!(session.model, "new-model");
374 assert_eq!(
375 bamboo_agent_core::workspace_state::get_workspace(&session.id),
376 Some(canonical)
377 );
378 let snapshot = session
379 .prompt_snapshot
380 .as_ref()
381 .expect("configured snapshot");
382 assert_eq!(snapshot.effective_system_prompt, "configured System");
383 let workspace_context = snapshot
384 .workspace_context
385 .as_deref()
386 .expect("typed Workspace context");
387 assert!(workspace_context.contains(&display));
388 assert!(workspace_context.contains("Workspace source: session"));
389 assert!(workspace_context.contains("Binding status: unregistered"));
390 }
391
392 #[tokio::test]
393 async fn assigned_external_prep_without_resolver_fails_closed_before_mutating_retry_state() {
394 let workspace = tempfile::tempdir().expect("legacy Workspace");
395 let display = bamboo_config::paths::path_to_display_string(workspace.path());
396 let legacy_block = crate::runtime::context::build_workspace_prompt_context(&display)
397 .expect("legacy Workspace block");
398 let mut session = session_with(vec![Message::system(format!(
399 "caller System\n\n{legacy_block}"
400 ))]);
401 session.set_project_id_meta("project-external-prep");
402 session.metadata.insert(
403 crate::session_app::respond::PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
404 "retryable-tool-call".to_string(),
405 );
406 session.prompt_snapshot = Some(
407 serde_json::from_value(serde_json::json!({
408 "base_system_prompt": "stale caller snapshot",
409 "effective_system_prompt": "stale caller snapshot"
410 }))
411 .expect("synthetic stale prompt snapshot"),
412 );
413 let before = serde_json::to_vec(&session).expect("serialize retryable legacy state");
414
415 let error = prepare_external_session_for_execution(&mut session, None)
416 .await
417 .expect_err("assigned external execution requires resolver authority");
418
419 assert!(matches!(error, AgentError::ProjectContext(_)));
420 assert_eq!(
421 serde_json::to_vec(&session).expect("serialize failed legacy state"),
422 before,
423 "missing resolver must leave the complete serialized Session state unchanged"
424 );
425 assert!(session.messages[0].content.contains(&display));
426 assert!(session.messages[0]
427 .content
428 .contains("BAMBOO_WORKSPACE_CONTEXT"));
429 assert_eq!(
430 session
431 .metadata
432 .get(crate::session_app::respond::PERMISSION_REEXECUTE_METADATA_KEY)
433 .map(String::as_str),
434 Some("retryable-tool-call")
435 );
436 assert!(session.prompt_snapshot.is_some());
437 }
438}