1use std::sync::Arc;
9
10use bamboo_agent_core::{AgentError, Message, Session};
11use bamboo_domain::{
12 AgentRuntimeState, SessionInboxPort, SessionMessageBody, SessionMessageContent,
13 SessionMessageEnvelope, SessionMessageId, SessionMessageKind, SessionMessageSource,
14 SessionRuntimeInstruction,
15};
16
17const METADATA_KEY: &str = "agent.runtime.state";
18const INBOX_ACK_UNRESOLVED: &str =
19 "SessionInbox ACK unresolved; durable input is preserved; retry this activation";
20const INBOX_CLAIM_UNRESOLVED: &str =
21 "SessionInbox claim unresolved; durable input is preserved; retry with its current owner";
22#[derive(Clone, Copy)]
24pub struct BorrowedInputRequestRecord<'a> {
25 pub input_id: &'a str,
26 pub source: &'a SessionMessageSource,
27 pub kind: SessionMessageKind,
28 pub wrapper: Option<&'a str>,
29 pub created_at: chrono::DateTime<chrono::Utc>,
30 pub request: Option<&'a bamboo_domain::SessionSkillRequest>,
31}
32
33#[derive(serde::Serialize)]
35struct InputRequestMeasurement<'a, T> {
36 session_id: &'a str,
37 execution_id: &'a str,
38 records: T,
39}
40struct InputRequestRecordsMeasurement<'a>(&'a [BorrowedInputRequestRecord<'a>]);
41impl serde::Serialize for InputRequestRecordsMeasurement<'_> {
42 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
43 use serde::ser::SerializeSeq;
44 let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
45 for record in self.0 {
46 sequence.serialize_element(&(
47 record.input_id,
48 record.source,
49 record.kind,
50 record.wrapper,
51 record.created_at,
52 record.request,
53 ))?;
54 }
55 sequence.end()
56 }
57}
58
59pub fn project_input_request_batch(
62 session_id: &str,
63 execution_id: &str,
64 records: &[BorrowedInputRequestRecord<'_>],
65) -> crate::runtime::managers::lifecycle::InputRequestProjection {
66 use crate::runtime::managers::lifecycle::{
67 BoundedInputRequestBatch,
68 InputRequestProjection::{Available, Unavailable},
69 InputRequestUnavailable, ProjectedInputRequest,
70 };
71 const LIMIT: usize = 262144;
72 if records.len() > 128 {
73 return Unavailable(InputRequestUnavailable::RecordLimit {
74 count: records.len(),
75 limit: 128,
76 });
77 }
78 for record in records {
79 let id = record.input_id;
80 if id.trim().is_empty()
81 || id.trim() != id
82 || id.len() > 256
83 || id.contains('/')
84 || id.contains('\\')
85 || id.contains("..")
86 || record
87 .request
88 .is_some_and(|request| request.validate().is_err())
89 {
90 return Unavailable(InputRequestUnavailable::InvalidData);
91 }
92 }
93 let Some(compact_bytes) = measure_input_request_view(
94 session_id,
95 execution_id,
96 InputRequestRecordsMeasurement(records),
97 ) else {
98 return Unavailable(InputRequestUnavailable::CompactLimit { limit: LIMIT });
99 };
100 let records = records
101 .iter()
102 .map(|record| ProjectedInputRequest {
103 input_id: record.input_id.into(),
104 source: match record.source {
105 SessionMessageSource::User => SessionMessageSource::User,
106 SessionMessageSource::Session { session_id } => SessionMessageSource::Session {
107 session_id: session_id.as_str().into(),
108 },
109 SessionMessageSource::Runtime { subsystem } => SessionMessageSource::Runtime {
110 subsystem: subsystem.as_str().into(),
111 },
112 },
113 kind: record.kind,
114 wrapper: record.wrapper.map(String::from),
115 created_at: record.created_at,
116 request: record
117 .request
118 .map(|request| bamboo_domain::SessionSkillRequest {
119 mode: request.mode.as_deref().map(String::from),
120 selections: request
121 .selections
122 .iter()
123 .map(|selection| bamboo_domain::SessionSkillSelection {
124 id: selection.id.as_str().into(),
125 source: selection.source.as_str().into(),
126 revision: selection.revision,
127 args: copy_input_request_value(&selection.args),
128 })
129 .collect::<Vec<_>>()
130 .into_boxed_slice()
131 .into_vec(),
132 }),
133 })
134 .collect::<Vec<_>>()
135 .into_boxed_slice();
136 Available(BoundedInputRequestBatch {
137 session_id: session_id.into(),
138 execution_id: execution_id.into(),
139 records,
140 compact_bytes,
141 })
142}
143
144fn measure_input_request_view(
145 session_id: &str,
146 execution_id: &str,
147 records: impl serde::Serialize,
148) -> Option<usize> {
149 struct CountingWrite(usize);
150 impl std::io::Write for CountingWrite {
151 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
152 self.0 = self
153 .0
154 .checked_add(bytes.len())
155 .filter(|n| *n <= 262144)
156 .ok_or_else(|| std::io::Error::other("compact input byte limit"))?;
157 Ok(bytes.len())
158 }
159 fn flush(&mut self) -> std::io::Result<()> {
160 Ok(())
161 }
162 }
163 let mut count = CountingWrite(0);
164 serde_json::to_writer(
165 &mut count,
166 &InputRequestMeasurement {
167 session_id,
168 execution_id,
169 records,
170 },
171 )
172 .ok()?;
173 Some(count.0)
174}
175
176#[derive(serde::Serialize)]
179struct RawRequest<'a> {
180 selections: RawSelections<'a>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 mode: Option<&'a str>,
183}
184struct RawSelections<'a>(&'a [serde_json::Value]);
185#[derive(serde::Serialize)]
186struct RawSelection<'a> {
187 id: &'a str,
188 source: &'a str,
189 revision: u64,
190 args: &'a serde_json::Value,
191}
192fn raw_selection(value: &serde_json::Value) -> Option<RawSelection<'_>> {
193 let fields = value.as_object()?;
194 if fields
195 .keys()
196 .any(|k| !matches!(k.as_str(), "id" | "source" | "revision" | "args"))
197 {
198 return None;
199 }
200 Some(RawSelection {
201 id: value.get("id")?.as_str()?,
202 source: value.get("source")?.as_str()?,
203 revision: value.get("revision")?.as_u64()?,
204 args: value.get("args").unwrap_or(&serde_json::Value::Null),
205 })
206}
207impl serde::Serialize for RawSelections<'_> {
208 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
209 use serde::ser::SerializeSeq;
210 let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
211 for value in self.0 {
212 sequence.serialize_element(
213 &raw_selection(value)
214 .ok_or_else(|| serde::ser::Error::custom("invalid checked selection"))?,
215 )?;
216 }
217 sequence.end()
218 }
219}
220fn raw_request(value: &serde_json::Value) -> Option<RawRequest<'_>> {
221 let fields = value.as_object()?;
222 if fields
223 .keys()
224 .any(|k| !matches!(k.as_str(), "selections" | "mode"))
225 {
226 return None;
227 }
228 let selections = value.get("selections")?.as_array()?;
229 if selections.is_empty() || selections.len() > 32 {
230 return None;
231 }
232 let mode = match value.get("mode") {
233 None | Some(serde_json::Value::Null) => None,
234 Some(value) => Some(value.as_str()?),
235 };
236 if mode.is_some_and(|mode| {
237 mode.is_empty()
238 || mode.len() > 64
239 || !mode
240 .bytes()
241 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
242 }) {
243 return None;
244 }
245 for (index, value) in selections.iter().enumerate() {
246 let selection = raw_selection(value)?;
247 bamboo_domain::SessionSkillSelection::validate_borrowed(
248 selection.id,
249 selection.source,
250 selection.revision,
251 selection.args,
252 )
253 .ok()?;
254 if selections[..index]
255 .iter()
256 .any(|prior| prior.get("id").and_then(|v| v.as_str()) == Some(selection.id))
257 {
258 return None;
259 }
260 }
261 Some(RawRequest {
262 selections: RawSelections(selections),
263 mode,
264 })
265}
266#[derive(serde::Serialize)]
267struct RawInputRecord<'a>(
268 &'a str,
269 &'a serde_json::Value,
270 SessionMessageKind,
271 Option<&'static str>,
272 chrono::DateTime<chrono::Utc>,
273 Option<RawRequest<'a>>,
274);
275
276fn project_new_committed_inputs(
279 session_id: &str,
280 execution_id: &str,
281 messages: &[Message],
282) -> crate::runtime::managers::lifecycle::InputRequestProjection {
283 use crate::runtime::managers::lifecycle::{
284 BoundedInputRequestBatch,
285 InputRequestProjection::{Available, Unavailable},
286 InputRequestUnavailable, ProjectedInputRequest,
287 };
288 if messages.len() > 128 {
289 return Unavailable(InputRequestUnavailable::RecordLimit {
290 count: messages.len(),
291 limit: 128,
292 });
293 }
294 let parse = || -> Option<Vec<RawInputRecord<'_>>> {
295 let mut records = Vec::with_capacity(messages.len());
296 for message in messages {
297 let proof = message.metadata.as_ref()?.get("session_message")?;
298 let source = proof.get("source")?;
299 let body = proof.get("body")?;
300 let (kind, wrapper, content) =
301 match (source.get("type")?.as_str()?, proof.get("kind")?.as_str()?) {
302 ("user", "user_input")
303 if source.as_object()?.len() == 1
304 && body.get("type")?.as_str()? == "content" =>
305 {
306 (SessionMessageKind::UserInput, None, body)
307 }
308 ("runtime", "runtime_instruction")
309 if source.as_object()?.len() == 2
310 && source.get("subsystem")?.as_str()? == "chat"
311 && body.get("type")?.as_str()? == "runtime_instruction"
312 && body.get("instruction")?.as_str()? == "root_chat_turn_v1" =>
313 {
314 (
315 SessionMessageKind::RuntimeInstruction,
316 Some("root_chat_turn_v1"),
317 body.get("content")?,
318 )
319 }
320 _ => return None,
321 };
322 let id = message.id.as_str();
323 if proof.get("id")?.as_str()? != id
324 || proof.get("target_session_id")?.as_str()? != session_id
325 || id.is_empty()
326 || id.len() > 256
327 || id.trim() != id
328 || id.contains('/')
329 || id.contains('\\')
330 || id.contains("..")
331 {
332 return None;
333 }
334 let time = proof
335 .get("created_at")?
336 .as_str()?
337 .parse::<chrono::DateTime<chrono::Utc>>()
338 .ok()?;
339 if time != message.created_at {
340 return None;
341 }
342 let request = match content.get("skill_request").filter(|v| !v.is_null()) {
343 Some(value) => Some(raw_request(value)?),
344 None => None,
345 };
346 records.push(RawInputRecord(id, source, kind, wrapper, time, request));
347 }
348 Some(records)
349 };
350 let Some(records) = parse() else {
351 return Unavailable(InputRequestUnavailable::InvalidData);
352 };
353 let Some(compact_bytes) = measure_input_request_view(session_id, execution_id, &records) else {
354 return Unavailable(InputRequestUnavailable::CompactLimit { limit: 262144 });
355 };
356 let records = records
357 .iter()
358 .map(|record| ProjectedInputRequest {
359 input_id: record.0.into(),
360 source: if record.2 == SessionMessageKind::UserInput {
361 SessionMessageSource::User
362 } else {
363 SessionMessageSource::Runtime {
364 subsystem: "chat".into(),
365 }
366 },
367 kind: record.2,
368 wrapper: record.3.map(String::from),
369 created_at: record.4,
370 request: record
371 .5
372 .as_ref()
373 .map(|request| bamboo_domain::SessionSkillRequest {
374 mode: request.mode.map(String::from),
375 selections: request
376 .selections
377 .0
378 .iter()
379 .map(|value| {
380 let selection =
381 raw_selection(value).expect("validated borrowed selection");
382 bamboo_domain::SessionSkillSelection {
383 id: selection.id.into(),
384 source: selection.source.into(),
385 revision: selection.revision,
386 args: copy_input_request_value(selection.args),
387 }
388 })
389 .collect::<Vec<_>>()
390 .into_boxed_slice()
391 .into_vec(),
392 }),
393 })
394 .collect::<Vec<_>>()
395 .into_boxed_slice();
396 Available(BoundedInputRequestBatch {
397 session_id: session_id.into(),
398 execution_id: execution_id.into(),
399 records,
400 compact_bytes,
401 })
402}
403
404fn copy_input_request_value(value: &serde_json::Value) -> serde_json::Value {
407 use serde_json::Value;
408 match value {
409 Value::String(value) => Value::String(value.as_str().into()),
410 Value::Array(values) => Value::Array(
411 values
412 .iter()
413 .map(copy_input_request_value)
414 .collect::<Vec<_>>()
415 .into_boxed_slice()
416 .into_vec(),
417 ),
418 Value::Object(values) => Value::Object(
419 values
420 .iter()
421 .map(|(key, value)| (key.as_str().into(), copy_input_request_value(value)))
422 .collect(),
423 ),
424 scalar => scalar.clone(),
425 }
426}
427
428#[cfg(test)]
429const PENDING_INJECTED_MESSAGES_KEY: &str = "pending_injected_messages";
430
431fn adopt_root_tool_authority(session: &mut Session, latest: &Session) -> Result<(), AgentError> {
432 check_conversation_readback(session, Some(latest))?;
433 session
434 .adopt_root_tool_authority_from(latest)
435 .map_err(|error| {
436 AgentError::Tool(format!(
437 "authoritative Root tool authority refresh failed closed: {error}"
438 ))
439 })
440}
441
442pub(crate) fn check_conversation_readback(
446 original: &Session,
447 canonical: Option<&Session>,
448) -> Result<(), AgentError> {
449 let conflict =
450 |reason: String| AgentError::Tool(format!("session_conversation_changed: {reason}"));
451 original
452 .validate_conversation_state()
453 .map_err(|error| conflict(error.to_string()))?;
454 match canonical {
455 Some(canonical) => original
456 .check_conversation_generation(canonical)
457 .map_err(|error| conflict(error.to_string())),
458 None if original.conversation_reset.is_some() => Err(conflict(
459 "canonical conversation unavailable; reload required".into(),
460 )),
461 None => Ok(()),
462 }
463}
464
465fn ordinary_root_uses_tool_authority_proof(session: &Session) -> bool {
466 session.kind == bamboo_domain::SessionKind::Root
467 && session.parent_session_id.is_none()
468 && session.authority_identity.is_ordinary()
469}
470
471pub async fn ensure_initial_root_tool_authority(
476 session: &mut Session,
477 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
478) -> Result<(), AgentError> {
479 if !ordinary_root_uses_tool_authority_proof(session) {
480 return Ok(());
481 }
482 let Some(storage) = storage else {
483 return Ok(());
484 };
485 let mut latest = storage
486 .load_runtime_control_plane(&session.id)
487 .await
488 .map_err(|error| {
489 AgentError::Tool(format!(
490 "initial Root tool authority read failed closed: {error}"
491 ))
492 })?;
493 check_conversation_readback(session, latest.as_ref())?;
494 if latest.is_none() {
495 storage.save_session(session).await.map_err(|error| {
496 AgentError::Tool(format!(
497 "initial Root authority publication failed closed: {error}"
498 ))
499 })?;
500 latest = storage
501 .load_runtime_control_plane(&session.id)
502 .await
503 .map_err(|error| {
504 AgentError::Tool(format!(
505 "initial Root tool authority read failed closed: {error}"
506 ))
507 })?;
508 }
509 let latest = latest.ok_or_else(|| {
510 AgentError::Tool("initial Root tool authority read failed closed: session missing".into())
511 })?;
512 adopt_root_tool_authority(session, &latest)
513}
514
515pub async fn refresh_round_root_tool_authority(
520 session: &mut Session,
521 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
522) -> Result<(), AgentError> {
523 if session.kind != bamboo_domain::SessionKind::Root || session.parent_session_id.is_some() {
524 return Ok(());
525 }
526 let Some(storage) = storage else {
527 return Ok(());
528 };
529 let latest_read = if session.authority_identity.is_ordinary() {
530 storage.load_runtime_control_plane(&session.id).await
531 } else {
532 storage.load_root_authority(&session.id).await
533 };
534 let latest = latest_read.map_err(|error| {
535 AgentError::Tool(format!(
536 "authoritative Root tool authority refresh failed closed: {error}"
537 ))
538 })?;
539 let latest = latest.ok_or_else(|| {
540 AgentError::Tool(
541 "authoritative Root tool authority refresh failed closed: session missing".to_string(),
542 )
543 })?;
544 adopt_root_tool_authority(session, &latest)
545}
546
547#[allow(dead_code)]
551pub fn read_runtime_state(session: &Session) -> Option<AgentRuntimeState> {
552 session.agent_runtime_state.clone().or_else(|| {
553 session
554 .metadata
555 .get(METADATA_KEY)
556 .and_then(|raw| serde_json::from_str::<AgentRuntimeState>(raw).ok())
557 })
558}
559
560pub fn write_runtime_state(session: &mut Session, state: &AgentRuntimeState) {
565 session.agent_runtime_state = Some(state.clone());
566}
567
568pub fn sync_from_metadata(session: &Session, state: &mut AgentRuntimeState) {
573 if state.llm.model_name.is_none() {
575 state.llm.model_name = Some(session.model.clone());
576 }
577 if state.llm.provider_name.is_none() {
578 state.llm.provider_name = session.provider_name();
579 }
580 if state.llm.responses_previous_id.is_none() {
581 state.llm.responses_previous_id = session
582 .metadata
583 .get("responses.previous_response_id")
584 .cloned();
585 }
586
587 if state.prompt.composer_version.is_none() {
589 state.prompt.composer_version = session
590 .metadata
591 .get("runtime_prompt_composer_version")
592 .cloned();
593 }
594 if state.prompt.section_flags.is_none() {
595 state.prompt.section_flags = session
596 .metadata
597 .get("runtime_prompt_component_flags")
598 .cloned();
599 }
600 if state.prompt.section_lengths.is_none() {
601 state.prompt.section_lengths = session
602 .metadata
603 .get("runtime_prompt_component_lengths")
604 .cloned();
605 }
606 if state.prompt.section_layout.is_none() {
607 state.prompt.section_layout = session
608 .metadata
609 .get("runtime_prompt_section_layout")
610 .cloned();
611 }
612}
613
614#[derive(Debug, Default, Clone)]
619pub struct TurnBoundaryRefresh {
620 pub merged: usize,
621 pub committed_messages: Vec<Message>,
625 pub admission_error: Option<String>,
629 pub disk_permission_mode: Option<bamboo_domain::SessionPermissionMode>,
631}
632
633pub async fn refresh_tool_boundary_authorities(
644 session: &mut Session,
645 runtime_state: &mut AgentRuntimeState,
646 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
647) -> Result<(), AgentError> {
648 let Some(storage) = storage else {
649 return check_conversation_readback(session, None);
650 };
651 let latest = storage
652 .load_runtime_control_plane(&session.id)
653 .await
654 .map_err(|error| {
655 AgentError::Tool(format!(
656 "authoritative permission posture refresh failed closed: {error}"
657 ))
658 })?
659 .ok_or_else(|| {
660 AgentError::Tool(
661 "authoritative permission posture refresh failed closed: session missing"
662 .to_string(),
663 )
664 })?;
665 check_conversation_readback(session, Some(&latest))?;
666 let disk_mode = latest
667 .agent_runtime_state
668 .as_ref()
669 .map(AgentRuntimeState::effective_permission_mode)
670 .ok_or_else(|| {
671 AgentError::Tool(
672 "authoritative permission posture refresh failed closed: typed mode missing"
673 .to_string(),
674 )
675 })?;
676 let current_mode = session
677 .agent_runtime_state
678 .as_ref()
679 .map(AgentRuntimeState::effective_permission_mode)
680 .unwrap_or_else(|| runtime_state.effective_permission_mode());
681
682 let should_adopt_audit = bamboo_domain::disk_permission_posture_is_fresher(
683 current_mode,
684 &session.metadata,
685 disk_mode,
686 &latest.metadata,
687 );
688 let fresher_audit = bamboo_domain::fresher_disk_permission_audit(
689 current_mode,
690 &session.metadata,
691 disk_mode,
692 &latest.metadata,
693 );
694 if should_adopt_audit && fresher_audit.is_none() {
695 return Err(AgentError::Tool(
696 "authoritative permission posture refresh failed closed: complete audit unavailable"
697 .to_string(),
698 ));
699 }
700
701 adopt_root_tool_authority(session, &latest)?;
705 if let Some(audit) = fresher_audit {
706 audit.write_to(&mut session.metadata);
707 }
708 runtime_state.set_permission_mode(disk_mode);
709 session
710 .agent_runtime_state
711 .get_or_insert_with(AgentRuntimeState::default)
712 .set_permission_mode(disk_mode);
713 Ok(())
714}
715
716#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
721pub struct LegacyPendingMigration {
722 pub delivered: usize,
723 pub source_cleared: bool,
724 pub highest_generation: Option<u64>,
725}
726
727#[cfg(test)]
733pub async fn merge_pending_injected_messages(
734 session: &mut Session,
735 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
736 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
737) -> usize {
738 refresh_turn_boundary_from_disk(session, storage, persistence)
739 .await
740 .merged
741}
742
743fn legacy_envelope(
744 session_id: &str,
745 index: usize,
746 value: &serde_json::Value,
747) -> SessionMessageEnvelope {
748 let content = value
749 .get("content")
750 .and_then(serde_json::Value::as_str)
751 .map(ToOwned::to_owned)
752 .unwrap_or_else(|| value.to_string());
753 SessionMessageEnvelope {
754 id: SessionMessageId::legacy(session_id, index, value),
755 source: SessionMessageSource::Runtime {
756 subsystem: "legacy_pending_injected_messages".to_string(),
757 },
758 target_session_id: session_id.to_string(),
759 kind: SessionMessageKind::RuntimeInstruction,
760 body: SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
761 instruction: "legacy_pending_injected_message".to_string(),
762 content: Some(SessionMessageContent::text(content)),
763 data: Some(value.clone()),
766 provider_message: None,
767 }),
768 created_at: chrono::Utc::now(),
769 thread_id: None,
770 in_reply_to: None,
771 attempt: None,
772 correlation_id: Some("legacy_pending_injected_messages".to_string()),
773 }
774}
775
776async fn migrate_legacy_pending_messages(
777 session_id: &str,
778 messages: &[serde_json::Value],
779 inbox: &Arc<dyn SessionInboxPort>,
780 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
781) -> LegacyPendingMigration {
782 if persistence.is_some_and(|p| p.root_inbox_receipt().is_some()) {
783 return LegacyPendingMigration::default();
784 }
785 if messages.is_empty() {
786 return LegacyPendingMigration {
787 source_cleared: true,
788 ..LegacyPendingMigration::default()
789 };
790 }
791 tracing::info!(
792 telemetry_event = "session_inbox.legacy_pending_ingress",
793 session_id,
794 count = messages.len(),
795 "observed rolling-upgrade legacy pending-message ingress"
796 );
797 let mut migration = LegacyPendingMigration::default();
798 for (index, value) in messages.iter().enumerate() {
799 let envelope = legacy_envelope(session_id, index, value);
800 match inbox.deliver(&envelope).await {
801 Ok(receipt) => {
802 migration.delivered += 1;
803 if let Err(error) = inbox
811 .mark_activation_eligible(
812 session_id,
813 receipt.generation,
814 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
815 )
816 .await
817 {
818 tracing::warn!(
819 session_id,
820 message_id = %envelope.id,
821 generation = receipt.generation,
822 %error,
823 "legacy pending message migration stopped before source cleanup because activation authorization failed"
824 );
825 return migration;
826 }
827 migration.highest_generation = Some(
828 migration
829 .highest_generation
830 .map_or(receipt.generation, |current| {
831 current.max(receipt.generation)
832 }),
833 );
834 }
835 Err(error) => {
836 tracing::warn!(
840 session_id,
841 message_id = %envelope.id,
842 %error,
843 "legacy pending message migration stopped before source cleanup"
844 );
845 return migration;
846 }
847 }
848 }
849
850 let Some(persistence) = persistence else {
851 tracing::warn!(
852 session_id,
853 "legacy pending messages were delivered but cannot be cleared without coordinated persistence"
854 );
855 return migration;
856 };
857 match persistence
858 .clear_legacy_pending_messages(session_id, messages)
859 .await
860 {
861 Ok(true) => {
862 tracing::info!(
863 session_id,
864 count = messages.len(),
865 "migrated legacy pending messages into SessionInbox"
866 );
867 migration.source_cleared = true;
868 }
869 Ok(false) => {
870 tracing::debug!(
871 session_id,
872 "legacy pending source changed during migration; leaving it for a deterministic retry"
873 );
874 }
875 Err(error) => {
876 tracing::warn!(
877 session_id,
878 %error,
879 "legacy pending messages are durable but source cleanup failed"
880 );
881 }
882 }
883 migration
884}
885
886pub async fn migrate_legacy_pending_only(
893 session: &mut Session,
894 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
895 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
896 inbox: Option<&Arc<dyn SessionInboxPort>>,
897) -> LegacyPendingMigration {
898 let (Some(storage), Some(inbox)) = (storage, inbox) else {
899 return LegacyPendingMigration::default();
900 };
901 let latest = match storage.load_session(&session.id).await {
902 Ok(Some(latest)) => latest,
903 Ok(None) => return LegacyPendingMigration::default(),
904 Err(error) => {
905 tracing::warn!(
906 session_id = %session.id,
907 %error,
908 "terminal legacy SessionInbox migration could not load session"
909 );
910 return LegacyPendingMigration::default();
911 }
912 };
913 if let Err(error) = check_conversation_readback(session, Some(&latest)) {
914 tracing::warn!(session_id = %session.id, %error, "legacy migration refused another conversation");
915 return LegacyPendingMigration::default();
916 }
917 let Some(messages) = latest.pending_injected_messages() else {
918 return LegacyPendingMigration::default();
919 };
920 let migration =
921 migrate_legacy_pending_messages(&session.id, &messages, inbox, persistence).await;
922 if migration.source_cleared
923 && session.pending_injected_messages().as_deref() == Some(messages.as_slice())
924 {
925 session.clear_pending_injected_messages();
926 }
927 migration
928}
929
930#[derive(Debug, Default)]
931struct InboxAdmission {
932 observation_complete: bool,
933 merged: usize,
934 committed_messages: Vec<Message>,
935 admission_error: Option<String>,
936}
937
938async fn admit_session_inbox(
939 session: &mut Session,
940 inbox: &Arc<dyn SessionInboxPort>,
941 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
942 active_run_id: Option<&str>,
943) -> InboxAdmission {
944 let Some(persistence) = persistence else {
945 tracing::warn!(
946 session_id = %session.id,
947 "SessionInbox cannot admit without durable runtime persistence"
948 );
949 return InboxAdmission {
950 admission_error: Some(INBOX_CLAIM_UNRESOLVED.to_string()),
951 ..Default::default()
952 };
953 };
954 match persistence
955 .admit_root_inbox(session, inbox.clone(), active_run_id)
956 .await
957 {
958 Ok(Some(admission)) => {
959 if let Some(error) = &admission.admission_error {
960 tracing::warn!(session_id = %session.id, %error, "owned Root Inbox admission stopped");
961 }
962 return InboxAdmission {
963 observation_complete: admission.admission_error.is_none(),
964 merged: admission.merged,
965 committed_messages: admission.committed_messages,
966 admission_error: admission
967 .admission_error
968 .map(|_| INBOX_CLAIM_UNRESOLVED.to_string()),
969 };
970 }
971 Err(error) => {
972 tracing::warn!(session_id = %session.id, %error, "owned Root Inbox admission failed");
973 return InboxAdmission {
974 admission_error: Some(INBOX_CLAIM_UNRESOLVED.to_string()),
975 ..Default::default()
976 };
977 }
978 Ok(None) => {}
979 }
980 let claims = match inbox.claim_for_turn(&session.id, 128, active_run_id).await {
981 Ok(claims) => claims,
982 Err(error) => {
983 tracing::warn!(session_id = %session.id, %error, "failed to claim SessionInbox");
984 return InboxAdmission {
985 admission_error: Some(INBOX_CLAIM_UNRESOLVED.to_string()),
986 ..Default::default()
987 };
988 }
989 };
990
991 let count = claims.len();
992 let mut admission = InboxAdmission {
993 observation_complete: count == 0,
994 ..Default::default()
995 };
996 for (index, claim) in claims.into_iter().enumerate() {
997 let permanently_admitted = match inbox.was_admitted(&session.id, &claim.envelope.id).await {
998 Ok(value) => value,
999 Err(error) => {
1000 tracing::warn!(
1001 session_id = %session.id,
1002 message_id = %claim.envelope.id,
1003 %error,
1004 "failed to inspect SessionInbox admitted receipt"
1005 );
1006 break;
1007 }
1008 };
1009 if permanently_admitted {
1010 let durable = match persistence.load_runtime_session(&session.id).await {
1011 Ok(Some(durable)) => durable,
1012 Ok(None) => {
1013 tracing::error!(
1014 session_id = %session.id,
1015 message_id = %claim.envelope.id,
1016 "permanent SessionInbox receipt has no durable transcript to verify; leaving claim recoverable"
1017 );
1018 break;
1019 }
1020 Err(error) => {
1021 tracing::warn!(
1022 session_id = %session.id,
1023 message_id = %claim.envelope.id,
1024 %error,
1025 "failed to load durable transcript for permanent SessionInbox receipt"
1026 );
1027 break;
1028 }
1029 };
1030 if let Err(error) = check_conversation_readback(session, Some(&durable)) {
1031 tracing::warn!(session_id = %session.id, %error, "permanent receipt belongs to another conversation; leaving claim recoverable");
1032 admission.admission_error = Some(error.to_string());
1033 break;
1034 }
1035 let Some(message) = durable
1036 .messages
1037 .iter()
1038 .find(|message| {
1039 bamboo_domain::is_matching_session_message(message, &claim.envelope)
1040 })
1041 .cloned()
1042 else {
1043 tracing::error!(
1047 session_id = %session.id,
1048 message_id = %claim.envelope.id,
1049 "permanent SessionInbox receipt lacks matching typed transcript proof; leaving claim recoverable"
1050 );
1051 break;
1052 };
1053 if let Some(existing) = session
1054 .messages
1055 .iter_mut()
1056 .find(|existing| existing.id == message.id)
1057 {
1058 *existing = message;
1059 } else {
1060 session.add_message(message);
1061 }
1062 bamboo_domain::merge_session_inbox_admission(session, &durable);
1063 if let Err(error) = inbox.ack(&session.id, &claim).await {
1064 tracing::warn!(
1065 session_id = %session.id,
1066 message_id = %claim.envelope.id,
1067 %error,
1068 "failed to remove permanently admitted duplicate"
1069 );
1070 admission.admission_error = Some(INBOX_ACK_UNRESOLVED.to_string());
1071 break;
1072 }
1073 admission.observation_complete = index + 1 == count;
1074 continue;
1075 }
1076
1077 let transcript_has_id = session
1078 .messages
1079 .iter()
1080 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1081 let transcript_has_conflicting_id = session
1082 .messages
1083 .iter()
1084 .any(|message| message.id == claim.envelope.id.as_str())
1085 && !transcript_has_id;
1086 if transcript_has_conflicting_id {
1087 tracing::error!(
1088 session_id = %session.id,
1089 message_id = %claim.envelope.id,
1090 "SessionInbox id collides with a non-matching transcript message; leaving claim recoverable"
1091 );
1092 break;
1093 }
1094 let cursor_has_id = session
1095 .session_inbox_admission()
1096 .is_some_and(|state| state.contains(&claim.envelope.id));
1097 if cursor_has_id && !transcript_has_id {
1098 tracing::error!(
1102 session_id = %session.id,
1103 message_id = %claim.envelope.id,
1104 "SessionInbox cursor exists without transcript message; leaving claim recoverable"
1105 );
1106 break;
1107 }
1108
1109 let before = session.clone();
1110 if !transcript_has_id {
1111 match claim.envelope.to_provider_message() {
1112 Ok(message) => session.add_message(message),
1113 Err(error) => {
1114 tracing::warn!(
1115 session_id = %session.id,
1116 message_id = %claim.envelope.id,
1117 %error,
1118 "typed SessionInbox envelope failed provider translation"
1119 );
1120 break;
1121 }
1122 }
1123 }
1124 session
1125 .session_inbox_admission_mut()
1126 .record(claim.envelope.id.clone(), claim.generation);
1127 session.updated_at = chrono::Utc::now();
1128
1129 if let Err(error) = persistence.checkpoint_runtime_session(session).await {
1133 *session = before;
1134 tracing::warn!(
1135 session_id = %session.id,
1136 message_id = %claim.envelope.id,
1137 %error,
1138 "SessionInbox checkpoint failed; claim remains recoverable"
1139 );
1140 break;
1141 }
1142 if !session
1143 .messages
1144 .iter()
1145 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1146 {
1147 *session = before;
1148 tracing::error!(
1149 session_id = %session.id,
1150 message_id = %claim.envelope.id,
1151 "SessionInbox checkpoint lost typed transcript proof to a concurrent id collision; leaving claim recoverable"
1152 );
1153 break;
1154 }
1155 if !transcript_has_id {
1159 if let Some(message) = session
1160 .messages
1161 .iter()
1162 .find(|message| {
1163 bamboo_domain::is_matching_session_message(message, &claim.envelope)
1164 })
1165 .cloned()
1166 {
1167 admission.committed_messages.push(message);
1168 }
1169 }
1170 if let Err(error) = inbox.ack(&session.id, &claim).await {
1171 tracing::warn!(
1172 session_id = %session.id,
1173 message_id = %claim.envelope.id,
1174 %error,
1175 "SessionInbox checkpoint succeeded but ack failed; dedupe will recover"
1176 );
1177 admission.admission_error = Some(INBOX_ACK_UNRESOLVED.to_string());
1178 break;
1179 }
1180 if !transcript_has_id {
1181 admission.merged += 1;
1182 }
1183 admission.observation_complete = index + 1 == count;
1184 }
1185 if !admission.observation_complete && admission.admission_error.is_none() {
1190 admission.admission_error = Some(INBOX_CLAIM_UNRESOLVED.to_string());
1191 }
1192 admission
1193}
1194
1195#[cfg(test)]
1207pub async fn refresh_turn_boundary_from_disk(
1208 session: &mut Session,
1209 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
1210 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
1211) -> TurnBoundaryRefresh {
1212 refresh_turn_boundary_with_inbox(session, storage, persistence, None).await
1213}
1214
1215pub async fn refresh_turn_boundary_with_inbox(
1217 session: &mut Session,
1218 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
1219 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
1220 inbox: Option<&Arc<dyn SessionInboxPort>>,
1221) -> TurnBoundaryRefresh {
1222 refresh_turn_boundary_with_inbox_for_run(session, storage, persistence, inbox, None).await
1223}
1224
1225pub(crate) async fn refresh_turn_boundary_with_inbox_for_run(
1226 session: &mut Session,
1227 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
1228 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
1229 inbox: Option<&Arc<dyn SessionInboxPort>>,
1230 active_run_id: Option<&str>,
1231) -> TurnBoundaryRefresh {
1232 refresh_turn_boundary_inner(session, storage, persistence, inbox, active_run_id, None).await
1233}
1234
1235pub(crate) async fn refresh_turn_boundary_with_observation(
1236 session: &mut Session,
1237 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
1238 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
1239 inbox: Option<&Arc<dyn SessionInboxPort>>,
1240 active_run_id: Option<&str>,
1241 execution_id: &str,
1242) -> (
1243 TurnBoundaryRefresh,
1244 crate::runtime::managers::lifecycle::InputObservation,
1245) {
1246 let mut observation = crate::runtime::managers::lifecycle::InputObservation::default();
1247 let refresh = refresh_turn_boundary_inner(
1248 session,
1249 storage,
1250 persistence,
1251 inbox,
1252 active_run_id,
1253 Some((execution_id, &mut observation)),
1254 )
1255 .await;
1256 (refresh, observation)
1257}
1258
1259async fn refresh_turn_boundary_inner(
1260 session: &mut Session,
1261 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
1262 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
1263 inbox: Option<&Arc<dyn SessionInboxPort>>,
1264 active_run_id: Option<&str>,
1265 capture: Option<(
1266 &str,
1267 &mut crate::runtime::managers::lifecycle::InputObservation,
1268 )>,
1269) -> TurnBoundaryRefresh {
1270 let mut read_complete = true;
1271 let latest = match storage {
1272 Some(storage) => match storage.load_session(&session.id).await {
1273 Ok(latest) => latest,
1274 Err(error) => {
1275 tracing::warn!(
1276 session_id = %session.id,
1277 %error,
1278 "turn-boundary session refresh failed"
1279 );
1280 read_complete = false;
1281 None
1282 }
1283 },
1284 None => None,
1285 };
1286 if let Err(error) = check_conversation_readback(session, latest.as_ref()) {
1287 return TurnBoundaryRefresh {
1288 admission_error: Some(error.to_string()),
1289 ..Default::default()
1290 };
1291 }
1292
1293 let disk_permission_posture = latest
1298 .as_ref()
1299 .and_then(|latest| {
1300 latest
1301 .agent_runtime_state
1302 .as_ref()
1303 .map(|state| (latest, state.effective_permission_mode()))
1304 })
1305 .and_then(|(latest, disk_mode)| {
1306 let current_mode = session
1307 .agent_runtime_state
1308 .as_ref()
1309 .map(|state| state.effective_permission_mode())
1310 .unwrap_or_default();
1311 bamboo_domain::fresher_disk_permission_audit(
1312 current_mode,
1313 &session.metadata,
1314 disk_mode,
1315 &latest.metadata,
1316 )
1317 .map(|audit| (disk_mode, audit))
1318 });
1319 let disk_permission_mode = disk_permission_posture
1320 .as_ref()
1321 .map(|(disk_mode, _)| *disk_mode);
1322
1323 if let Some(latest) = latest.as_ref() {
1324 if let Some((_, audit)) = disk_permission_posture.as_ref() {
1329 audit.write_to(&mut session.metadata);
1330 }
1331 bamboo_domain::merge_session_inbox_admission(session, latest);
1336 }
1337
1338 if let (Some(inbox), Some(messages)) = (
1339 inbox,
1340 latest
1341 .as_ref()
1342 .and_then(|latest| latest.pending_injected_messages()),
1343 ) {
1344 let migration =
1345 migrate_legacy_pending_messages(&session.id, &messages, inbox, persistence).await;
1346 if let Some(generation) = migration.highest_generation {
1347 session.session_inbox_admission_mut().observe(generation);
1352 }
1353 if migration.source_cleared
1358 && session.pending_injected_messages().as_deref() == Some(messages.as_slice())
1359 {
1360 session.clear_pending_injected_messages();
1361 }
1362 }
1363
1364 if let Some(inbox) = inbox {
1365 let admission = admit_session_inbox(session, inbox, persistence, active_run_id).await;
1366 if let Some((execution_id, observation)) = capture {
1367 if read_complete
1368 && admission.observation_complete
1369 && admission.admission_error.is_none()
1370 {
1371 *observation = crate::runtime::managers::lifecycle::InputObservation::projected(
1372 project_new_committed_inputs(
1373 &session.id,
1374 execution_id,
1375 &admission.committed_messages,
1376 ),
1377 );
1378 }
1379 }
1380 return TurnBoundaryRefresh {
1381 merged: admission.merged,
1382 committed_messages: admission.committed_messages,
1383 admission_error: admission.admission_error,
1384 disk_permission_mode,
1385 };
1386 }
1387
1388 let Some(latest) = latest else {
1389 return TurnBoundaryRefresh {
1390 merged: 0,
1391 committed_messages: Vec::new(),
1392 admission_error: None,
1393 disk_permission_mode,
1394 };
1395 };
1396
1397 let Some(messages) = latest.pending_injected_messages() else {
1400 if let Some((execution_id, observation)) =
1401 capture.filter(|_| super::session_setup::legacy_instruction::sdk_runtime_active())
1402 {
1403 let pending_absent = !latest.metadata.contains_key("pending_injected_messages")
1404 && latest
1405 .runtime_metadata
1406 .as_ref()
1407 .is_none_or(|metadata| metadata.pending_injected_messages.is_none());
1408 let current_users = session
1409 .messages
1410 .iter()
1411 .filter(|message| message.role == bamboo_agent_core::Role::User);
1412 let durable_users = latest
1413 .messages
1414 .iter()
1415 .filter(|message| message.role == bamboo_agent_core::Role::User);
1416 let mut current_users = current_users;
1417 let mut durable_users = durable_users;
1418 let same_inputs = loop {
1419 match (current_users.next(), durable_users.next()) {
1420 (None, None) => break true,
1421 (Some(current), Some(durable))
1422 if crate::runtime::config::sdk_message_digest(current)
1423 == crate::runtime::config::sdk_message_digest(durable) => {}
1424 _ => break false,
1425 }
1426 };
1427 if read_complete && pending_absent && same_inputs {
1428 *observation = crate::runtime::managers::lifecycle::InputObservation::projected(
1429 project_input_request_batch(&session.id, execution_id, &[]),
1430 );
1431 }
1432 }
1433 return TurnBoundaryRefresh {
1434 merged: 0,
1435 committed_messages: Vec::new(),
1436 admission_error: None,
1437 disk_permission_mode,
1438 };
1439 };
1440
1441 let mut merged = 0usize;
1442 let mut appended_messages = Vec::new();
1443 for msg in messages {
1444 if let Some(content) = msg.get("content").and_then(|v| v.as_str()) {
1445 let message = bamboo_agent_core::Message::user(content.to_string());
1446 appended_messages.push(message.clone());
1447 session.add_message(message);
1448 merged += 1;
1449 }
1450 }
1451
1452 if merged > 0 {
1453 session.clear_pending_injected_messages();
1455 session.updated_at = chrono::Utc::now();
1456
1457 let mut saved = false;
1458 if let Some(persistence) = persistence {
1459 match persistence.save_runtime_session(session).await {
1460 Ok(()) => saved = true,
1461 Err(error) => tracing::warn!(
1462 "[{}] Failed to persist pending injected message cleanup: {}",
1463 session.id,
1464 error
1465 ),
1466 }
1467 }
1468
1469 tracing::info!(
1470 "[{}] Merged {} injected message(s) from queued send_message at turn boundary",
1471 session.id,
1472 merged
1473 );
1474
1475 let disk_permission_mode = if saved {
1482 session
1483 .agent_runtime_state
1484 .as_ref()
1485 .map(|rs| rs.effective_permission_mode())
1486 .or(disk_permission_mode)
1487 } else {
1488 disk_permission_mode
1489 };
1490 return TurnBoundaryRefresh {
1491 merged,
1492 committed_messages: if saved { appended_messages } else { Vec::new() },
1493 admission_error: None,
1494 disk_permission_mode,
1495 };
1496 }
1497
1498 TurnBoundaryRefresh {
1499 merged,
1500 committed_messages: Vec::new(),
1501 admission_error: None,
1502 disk_permission_mode,
1503 }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 #[tokio::test]
1509 async fn ql_overflow_old_wrapper_and_rich_prelude_preserve_actual_outputs_and_ack() {
1510 let mut snapshots = Vec::new();
1511 for rich in [false, true] {
1512 let (_home, store, locked, inbox, mut session) =
1513 durable_inbox_fixture("ql-overflow-parity").await;
1514 let mut system = Message::system("fixed provider system");
1515 system.id = "fixed-system-id".into();
1516 system.created_at = chrono::DateTime::from_timestamp(1700000000, 0).unwrap();
1517 session.add_message(system);
1518 store.save_session(&session).await.unwrap();
1519 let storage: Arc<dyn Storage> = store;
1520 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
1521 for ordinal in 0..40 {
1522 deliver_interrupt_eligible(&inbox, &ql_envelope(&session.id, ordinal, Some(7000)))
1523 .await;
1524 }
1525 let config = crate::runtime::config::AgentLoopConfig {
1526 storage: Some(storage.clone()),
1527 persistence: Some(persistence),
1528 session_inbox: Some(inbox.clone()),
1529 prompt_memory_flags: crate::runtime::config::PromptMemoryFlags {
1530 project_prompt_injection: false,
1531 relevant_recall: false,
1532 relevant_recall_rerank: false,
1533 project_first_dream: false,
1534 ledger_agenda: false,
1535 },
1536 ..Default::default()
1537 };
1538 let mut runtime = AgentRuntimeState::new("actual-parity-run");
1539 let (tx, mut rx) = tokio::sync::mpsc::channel(128);
1540 let cancel = tokio_util::sync::CancellationToken::new();
1541 let memory = if rich {
1542 let (memory, observation) =
1543 crate::runtime::runner::round_prelude::refresh_round_boundary_with_observation(
1544 &mut session,
1545 &mut runtime,
1546 &config,
1547 Some(&tx),
1548 &cancel,
1549 None,
1550 None,
1551 "actual-parity-run",
1552 )
1553 .await
1554 .unwrap();
1555 assert!(
1556 observation.is_unavailable(),
1557 "whole overflow changes only optional data"
1558 );
1559 memory
1560 } else {
1561 crate::runtime::runner::round_prelude::refresh_round_boundary_and_prompt_context(
1562 &mut session,
1563 &mut runtime,
1564 &config,
1565 Some(&tx),
1566 &cancel,
1567 None,
1568 None,
1569 )
1570 .await
1571 .unwrap()
1572 };
1573 let provider = AckBoundaryProvider(AtomicUsize::new(0));
1574 let stream = bamboo_llm::LLMProvider::chat_stream(
1575 &provider,
1576 &session.messages,
1577 &[],
1578 None,
1579 "model",
1580 )
1581 .await
1582 .unwrap();
1583 drop(stream);
1584 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
1585 let mut events = Vec::new();
1586 while let Ok(event) = rx.try_recv() {
1587 events.push(serde_json::to_value(event).unwrap());
1588 }
1589 assert_eq!(
1590 events.len(),
1591 40,
1592 "all original committed prefix events remain visible"
1593 );
1594 for ordinal in 0..40 {
1595 assert!(inbox
1596 .was_admitted(
1597 &session.id,
1598 &SessionMessageId::parse(format!("ql-{ordinal}")).unwrap()
1599 )
1600 .await
1601 .unwrap());
1602 }
1603 let cold = storage.load_session(&session.id).await.unwrap().unwrap();
1604 assert_eq!(
1605 cold.messages
1606 .iter()
1607 .filter(|m| m.id.starts_with("ql-"))
1608 .count(),
1609 40
1610 );
1611 let backlog = inbox.inspect(&session.id).await.unwrap();
1612 assert_eq!(backlog.pending + backlog.claimed, 0);
1613 snapshots.push((
1614 memory,
1615 serde_json::to_value(&session.messages).unwrap(),
1616 events,
1617 runtime.effective_permission_mode(),
1618 session.metadata.clone(),
1619 ));
1620 }
1621 assert_eq!(
1622 snapshots[0], snapshots[1],
1623 "provider input, memory refresh, events and posture remain identical"
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn sdk_no_inbox_no_new_checks_user_inputs_only_and_unknown_storage_clears() {
1629 use crate::runtime::runner::session_setup::legacy_instruction::scope_sdk_runtime;
1630 use bamboo_domain::RuntimeSessionPersistence;
1631 let (_home, store, locked, _inbox, mut session) = durable_inbox_fixture("sdk-no-new").await;
1632 session.add_message(Message::user("accepted SDK input"));
1633 locked
1634 .checkpoint_runtime_session(&mut session)
1635 .await
1636 .unwrap();
1637 let storage: Arc<dyn Storage> = store;
1638 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
1639 session.add_message(Message::system("normal assembled system"));
1640 session.add_message(Message::assistant("normal output", None));
1641 let (_, generic) = refresh_turn_boundary_with_observation(
1642 &mut session,
1643 Some(&storage),
1644 Some(&persistence),
1645 None,
1646 None,
1647 "sdk-actual",
1648 )
1649 .await;
1650 assert!(
1651 generic.is_unavailable(),
1652 "generic history never establishes SDK currentness"
1653 );
1654 let (_, same) = scope_sdk_runtime(refresh_turn_boundary_with_observation(
1655 &mut session,
1656 Some(&storage),
1657 Some(&persistence),
1658 None,
1659 None,
1660 "sdk-actual",
1661 ))
1662 .await;
1663 assert!(same.is_successful_no_new_input());
1664 assert!(
1665 same.new_inputs().is_none(),
1666 "NoNew never manufactures a User or Source"
1667 );
1668 session.add_message(Message::user("unsaved input"));
1669 let (_, changed) = scope_sdk_runtime(refresh_turn_boundary_with_observation(
1670 &mut session,
1671 Some(&storage),
1672 Some(&persistence),
1673 None,
1674 None,
1675 "sdk-actual",
1676 ))
1677 .await;
1678 assert!(changed.is_unavailable());
1679 let (_, missing) = scope_sdk_runtime(refresh_turn_boundary_with_observation(
1680 &mut session,
1681 None,
1682 Some(&persistence),
1683 None,
1684 None,
1685 "sdk-actual",
1686 ))
1687 .await;
1688 assert!(missing.is_unavailable());
1689 }
1690
1691 fn ql_envelope(id: &str, ordinal: usize, bytes: Option<usize>) -> SessionMessageEnvelope {
1692 let mut envelope = SessionMessageEnvelope::user_input(id, "unchanged provider text");
1693 envelope.id = SessionMessageId::parse(format!("ql-{ordinal}")).unwrap();
1694 envelope.created_at =
1695 chrono::DateTime::from_timestamp(1700000000 + ordinal as i64, 0).unwrap();
1696 if let (Some(bytes), SessionMessageBody::Content(content)) = (bytes, &mut envelope.body) {
1697 content.skill_request = Some(bamboo_domain::SessionSkillRequest {
1698 mode: Some("exact-mode".into()),
1699 selections: vec![bamboo_domain::SessionSkillSelection {
1700 id: format!("selection-{ordinal}"),
1701 source: "user".into(),
1702 revision: u64::MAX,
1703 args: serde_json::json!({"payload":"x".repeat(bytes), "float": 1.125, "null":null}),
1704 }],
1705 });
1706 }
1707 envelope
1708 }
1709
1710 #[test]
1711 fn ql_raw_projection_matches_accepted_qb_cost_and_exact_numeric_data() {
1712 use crate::runtime::managers::lifecycle::InputRequestProjection;
1713 for root in [false, true] {
1714 for request in [None, Some(0), Some(7000)] {
1715 let envelope = ql_envelope("ql-raw", 1, request);
1716 let envelope = if root {
1717 envelope.with_root_chat_prompt("system".into()).unwrap()
1718 } else {
1719 envelope
1720 };
1721 let skill_request = match &envelope.body {
1722 SessionMessageBody::Content(content) => content.skill_request.as_ref(),
1723 SessionMessageBody::RuntimeInstruction(body) => {
1724 body.content.as_ref().unwrap().skill_request.as_ref()
1725 }
1726 SessionMessageBody::ChildOutcome(_) => None,
1727 };
1728 let records = [BorrowedInputRequestRecord {
1729 input_id: envelope.id.as_str(),
1730 source: &envelope.source,
1731 kind: envelope.kind,
1732 wrapper: root.then_some("root_chat_turn_v1"),
1733 created_at: envelope.created_at,
1734 request: skill_request,
1735 }];
1736 let expected = project_input_request_batch("ql-raw", "actual-execution", &records);
1737 let actual = project_new_committed_inputs(
1738 "ql-raw",
1739 "actual-execution",
1740 &[envelope.to_provider_message().unwrap()],
1741 );
1742 assert_eq!(actual, expected);
1743 let InputRequestProjection::Available(batch) = actual else {
1744 panic!("valid fit")
1745 };
1746 assert_eq!(batch.records()[0].request.as_ref(), skill_request);
1747 }
1748 }
1749 }
1750
1751 #[test]
1752 fn ql_raw_projection_normalizes_null_mode_and_missing_args_without_dropping_data() {
1753 let envelope = ql_envelope("ql-null", 0, Some(0));
1754 let mut message = envelope.to_provider_message().unwrap();
1755 let proof = &mut message.metadata.as_mut().unwrap()["session_message"];
1756 let request = &mut proof["body"]["skill_request"];
1757 request["mode"] = serde_json::Value::Null;
1758 request["selections"][0]
1759 .as_object_mut()
1760 .unwrap()
1761 .remove("args");
1762 let expected = bamboo_domain::SessionSkillRequest {
1763 mode: None,
1764 selections: vec![bamboo_domain::SessionSkillSelection {
1765 id: "selection-0".into(),
1766 source: "user".into(),
1767 revision: u64::MAX,
1768 args: serde_json::Value::Null,
1769 }],
1770 };
1771 let records = [BorrowedInputRequestRecord {
1772 input_id: envelope.id.as_str(),
1773 source: &envelope.source,
1774 kind: envelope.kind,
1775 wrapper: None,
1776 created_at: envelope.created_at,
1777 request: Some(&expected),
1778 }];
1779 assert_eq!(
1780 project_new_committed_inputs("ql-null", "e", &[message]),
1781 project_input_request_batch("ql-null", "e", &records)
1782 );
1783 }
1784
1785 #[test]
1786 fn ql_raw_projection_rejects_unknown_small_deep_and_wide_programmatic_data() {
1787 use crate::runtime::managers::lifecycle::InputRequestProjection::Unavailable;
1788 let message = ql_envelope("ql-shape", 0, Some(0))
1789 .to_provider_message()
1790 .unwrap();
1791 let mut deep = serde_json::Value::Null;
1792 for _ in 0..65 {
1793 deep = serde_json::json!([deep]);
1794 }
1795 for args in [
1796 deep,
1797 serde_json::json!((0..8192).map(|_| 0).collect::<Vec<_>>()),
1798 ] {
1799 let mut invalid = message.clone();
1800 invalid.metadata.as_mut().unwrap()["session_message"]["body"]["skill_request"]
1801 ["selections"][0]["args"] = args;
1802 assert!(matches!(
1803 project_new_committed_inputs("ql-shape", "e", &[invalid]),
1804 Unavailable(_)
1805 ));
1806 }
1807 let mut invalid = message.clone();
1808 invalid.metadata.as_mut().unwrap()["session_message"]["body"]["skill_request"]["extra"] =
1809 1.into();
1810 assert!(matches!(
1811 project_new_committed_inputs("ql-shape", "e", &[invalid]),
1812 Unavailable(_)
1813 ));
1814 let mut invalid = message;
1815 invalid.metadata.as_mut().unwrap()["session_message"]["id"] = "wrong-id".into();
1816 assert!(matches!(
1817 project_new_committed_inputs("ql-shape", "e", &[invalid]),
1818 Unavailable(_)
1819 ));
1820 assert!(matches!(
1821 project_new_committed_inputs("ql-shape", "e", &[Message::user("old native")]),
1822 Unavailable(_)
1823 ));
1824 }
1825
1826 #[tokio::test]
1827 async fn ql_real_new_order_none_overflow_nonew_and_fitting_restore() {
1828 let (_home, store, locked, inbox, mut session) = durable_inbox_fixture("ql-sequence").await;
1829 let storage: Arc<dyn Storage> = store;
1830 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
1831 let mut current = None;
1832 for (start, count, request, fitting) in [
1833 (0, 2, Some(7), true),
1834 (2, 1, None, true),
1835 (3, 40, Some(7000), false),
1836 (43, 0, None, false),
1837 (43, 1, Some(9), true),
1838 ] {
1839 for ordinal in start..start + count {
1840 deliver_interrupt_eligible(&inbox, &ql_envelope(&session.id, ordinal, request))
1841 .await;
1842 }
1843 let (refresh, observation) = refresh_turn_boundary_with_observation(
1844 &mut session,
1845 Some(&storage),
1846 Some(&persistence),
1847 Some(&inbox),
1848 None,
1849 "actual-sequence-run",
1850 )
1851 .await;
1852 assert_eq!(refresh.merged, count);
1853 assert_eq!(refresh.committed_messages.len(), count);
1854 assert!(refresh.admission_error.is_none());
1855 if count == 0 {
1856 assert!(observation.is_successful_no_new_input());
1857 }
1858 if count == 40 {
1859 assert!(observation.is_unavailable());
1860 }
1861 let session_id = session.id.clone();
1862 observation.update_current(&mut current, &session_id, "actual-sequence-run");
1863 assert_eq!(current.is_some(), fitting);
1864 if let Some(batch) = ¤t {
1865 assert_eq!(
1866 batch
1867 .records()
1868 .iter()
1869 .map(|r| r.input_id.as_str())
1870 .collect::<Vec<_>>(),
1871 (start..start + count)
1872 .map(|i| format!("ql-{i}"))
1873 .collect::<Vec<_>>()
1874 );
1875 assert_eq!(batch.records()[0].request.is_some(), request.is_some());
1876 assert_eq!(batch.execution_id(), "actual-sequence-run");
1877 }
1878 let backlog = inbox.inspect(&session_id).await.unwrap();
1879 assert_eq!(backlog.pending + backlog.claimed, 0);
1880 }
1881 }
1882
1883 #[tokio::test]
1884 async fn ql_original_checkpoint_failure_is_unavailable_and_cold_retry_is_new_once() {
1885 let (_home, store, locked, inbox, mut session) =
1886 durable_inbox_fixture("ql-checkpoint-failure").await;
1887 let storage: Arc<dyn Storage> = store;
1888 let faulted = Arc::new(FaultingPersistence {
1889 inner: locked,
1890 fail_checkpoint: std::sync::atomic::AtomicBool::new(true),
1891 });
1892 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = faulted.clone();
1893 let envelope = ql_envelope(&session.id, 0, Some(9));
1894 deliver_interrupt_eligible(&inbox, &envelope).await;
1895 let (refresh, observation) = refresh_turn_boundary_with_observation(
1896 &mut session,
1897 Some(&storage),
1898 Some(&persistence),
1899 Some(&inbox),
1900 None,
1901 "e",
1902 )
1903 .await;
1904 assert_eq!(
1905 refresh.admission_error.as_deref(),
1906 Some(INBOX_CLAIM_UNRESOLVED),
1907 "an uncheckpointed claim must stop before provider dispatch"
1908 );
1909 assert_eq!(refresh.merged, 0);
1910 assert!(refresh.committed_messages.is_empty());
1911 assert!(observation.is_unavailable());
1912 assert!(!session
1913 .messages
1914 .iter()
1915 .any(|m| m.id == envelope.id.as_str()));
1916 assert!(!inbox.was_admitted(&session.id, &envelope.id).await.unwrap());
1917 faulted.fail_checkpoint.store(false, Ordering::SeqCst);
1918 session = storage.load_session(&session.id).await.unwrap().unwrap();
1919 let (refresh, observation) = refresh_turn_boundary_with_observation(
1920 &mut session,
1921 Some(&storage),
1922 Some(&persistence),
1923 Some(&inbox),
1924 None,
1925 "actual-cold-retry",
1926 )
1927 .await;
1928 assert_eq!(refresh.merged, 1);
1929 assert_eq!(
1930 observation.new_inputs().unwrap().records()[0].input_id,
1931 envelope.id.as_str()
1932 );
1933 let (_, observation) = refresh_turn_boundary_with_observation(
1934 &mut session,
1935 Some(&storage),
1936 Some(&persistence),
1937 Some(&inbox),
1938 None,
1939 "actual-cold-retry",
1940 )
1941 .await;
1942 assert!(observation.is_successful_no_new_input());
1943 }
1944
1945 #[tokio::test]
1946 async fn ql_unresolved_ack_preserves_prefix_events_but_never_new_and_recovery_is_nonew() {
1947 for permanent in [false, true] {
1948 let (_home, store, locked, real, mut session) =
1949 durable_inbox_fixture("ql-ack-failure").await;
1950 let storage: Arc<dyn Storage> = store;
1951 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
1952 let inbox: Arc<dyn SessionInboxPort> = Arc::new(AckAfterPersistErrorInbox {
1953 inner: real.clone(),
1954 fail_before_once: std::sync::atomic::AtomicBool::new(!permanent),
1955 fail_after_once: std::sync::atomic::AtomicBool::new(permanent),
1956 });
1957 let envelope = ql_envelope(&session.id, 0, Some(9));
1958 deliver_interrupt_eligible(&inbox, &envelope).await;
1959 let (refresh, observation) = refresh_turn_boundary_with_observation(
1960 &mut session,
1961 Some(&storage),
1962 Some(&persistence),
1963 Some(&inbox),
1964 None,
1965 "e",
1966 )
1967 .await;
1968 assert_eq!(
1969 refresh.admission_error.as_deref(),
1970 Some(INBOX_ACK_UNRESOLVED)
1971 );
1972 assert_eq!(
1973 refresh.committed_messages.len(),
1974 1,
1975 "retain original pre-ACK durable prefix"
1976 );
1977 assert!(observation.is_unavailable());
1978 let mut cold = storage.load_session(&session.id).await.unwrap().unwrap();
1979 let (refresh, observation) = refresh_turn_boundary_with_observation(
1980 &mut cold,
1981 Some(&storage),
1982 Some(&persistence),
1983 Some(&inbox),
1984 None,
1985 "cold-new-run",
1986 )
1987 .await;
1988 assert_eq!(refresh.merged, 0);
1989 assert!(refresh.committed_messages.is_empty());
1990 assert!(observation.is_successful_no_new_input());
1991 assert_eq!(
1992 cold.messages
1993 .iter()
1994 .filter(|m| m.id == envelope.id.as_str())
1995 .count(),
1996 1
1997 );
1998 assert_eq!(real.inspect(&session.id).await.unwrap().claimed, 0);
1999 }
2000 }
2001
2002 #[tokio::test]
2003 async fn ql_owned_root_new_and_recovered_are_distinguished_after_exact_ack() {
2004 use bamboo_domain::RuntimeSessionPersistence;
2005 for recovered in [false, true] {
2006 let (_home, store, locked, inbox, mut root) =
2007 durable_inbox_fixture("ql-owned-root").await;
2008 let envelope = ql_envelope(&root.id, 0, Some(17))
2009 .with_root_chat_prompt("Root system".into())
2010 .unwrap();
2011 if recovered {
2012 root.add_message(envelope.to_provider_message().unwrap());
2013 store.save_session(&root).await.unwrap();
2014 }
2015 let repo = crate::SessionRepository::new(Arc::default(), store.clone(), locked)
2016 .with_root_actor_directory(store.clone());
2017 let binding = repo
2018 .bind_root_actor_execution(&root, "actual-owned-root-run")
2019 .await
2020 .unwrap()
2021 .unwrap();
2022 let storage: Arc<dyn Storage> = store;
2023 deliver_interrupt_eligible(&inbox, &envelope).await;
2024 let (refresh, observation) = refresh_turn_boundary_with_observation(
2025 &mut root,
2026 Some(&storage),
2027 Some(&binding.persistence),
2028 Some(&inbox),
2029 None,
2030 "actual-owned-root-run",
2031 )
2032 .await;
2033 assert!(refresh.admission_error.is_none());
2034 assert_eq!(refresh.merged, usize::from(!recovered));
2035 assert_eq!(observation.is_successful_no_new_input(), recovered);
2036 if !recovered {
2037 let batch = observation
2038 .new_inputs()
2039 .expect("actual owned Root New must project");
2040 assert_eq!(batch.records().len(), 1);
2041 let record = &batch.records()[0];
2042 assert_eq!(record.input_id, envelope.id.as_str());
2043 assert_eq!(
2044 record.source,
2045 SessionMessageSource::Runtime {
2046 subsystem: "chat".into()
2047 }
2048 );
2049 assert_eq!(record.kind, SessionMessageKind::RuntimeInstruction);
2050 assert_eq!(record.wrapper.as_deref(), Some("root_chat_turn_v1"));
2051 assert_eq!(record.created_at, envelope.created_at);
2052 assert_eq!(
2053 record.request.as_ref().unwrap().selections[0].revision,
2054 u64::MAX
2055 );
2056 }
2057 assert!(inbox.was_admitted(&root.id, &envelope.id).await.unwrap());
2058 assert_eq!(inbox.inspect(&root.id).await.unwrap().pending, 0);
2059 assert_eq!(
2060 root.messages
2061 .iter()
2062 .filter(|m| m.id == envelope.id.as_str())
2063 .count(),
2064 1
2065 );
2066 }
2067 }
2068
2069 #[tokio::test]
2070 async fn ql_new_peer_user_presentation_is_unavailable_without_stale_fallback() {
2071 let (_home, store, locked, inbox, mut session) = durable_inbox_fixture("ql-peer").await;
2072 let storage: Arc<dyn Storage> = store;
2073 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
2074 let user = ql_envelope(&session.id, 0, Some(8));
2075 deliver_interrupt_eligible(&inbox, &user).await;
2076 let (_, observation) = refresh_turn_boundary_with_observation(
2077 &mut session,
2078 Some(&storage),
2079 Some(&persistence),
2080 Some(&inbox),
2081 None,
2082 "e",
2083 )
2084 .await;
2085 let mut current = None;
2086 observation.update_current(&mut current, &session.id, "e");
2087 assert!(current.is_some());
2088 let mut peer = ql_envelope(&session.id, 1, None);
2089 peer.source = SessionMessageSource::Session {
2090 session_id: "real-peer".into(),
2091 };
2092 peer.kind = SessionMessageKind::PeerMessage;
2093 assert_eq!(
2094 peer.to_provider_message().unwrap().role,
2095 bamboo_domain::Role::User
2096 );
2097 deliver_interrupt_eligible(&inbox, &peer).await;
2098 let (refresh, observation) = refresh_turn_boundary_with_observation(
2099 &mut session,
2100 Some(&storage),
2101 Some(&persistence),
2102 Some(&inbox),
2103 None,
2104 "e",
2105 )
2106 .await;
2107 assert_eq!(refresh.merged, 1);
2108 assert!(observation.is_unavailable());
2109 observation.update_current(&mut current, &session.id, "e");
2110 assert!(current.is_none());
2111 assert!(inbox.was_admitted(&session.id, &peer.id).await.unwrap());
2112 }
2113
2114 use super::*;
2115 use bamboo_agent_core::storage::Storage;
2116 use bamboo_domain::{
2117 AgentStatusState, SessionActivationDisposition, SessionActivationError,
2118 SessionActivationPort,
2119 };
2120 use std::collections::HashMap;
2121 use std::sync::atomic::{AtomicUsize, Ordering};
2122 use tokio::sync::RwLock;
2123
2124 fn test_session() -> Session {
2125 Session::new("test-session", "test-model")
2126 }
2127
2128 #[test]
2129 fn read_from_structured_field() {
2130 let mut session = test_session();
2131 let mut state = AgentRuntimeState::new("run-1");
2132 state.status = AgentStatusState::Running;
2133 session.agent_runtime_state = Some(state.clone());
2134
2135 let read = read_runtime_state(&session).unwrap();
2136 assert_eq!(read.status, AgentStatusState::Running);
2137 assert_eq!(read.run_id, "run-1");
2138 }
2139
2140 #[test]
2141 fn read_from_metadata_fallback() {
2142 let mut session = test_session();
2143 let state = AgentRuntimeState::new("run-2");
2144 session.metadata.insert(
2145 METADATA_KEY.to_string(),
2146 serde_json::to_string(&state).unwrap(),
2147 );
2148
2149 let read = read_runtime_state(&session).unwrap();
2150 assert_eq!(read.run_id, "run-2");
2151 }
2152
2153 #[test]
2154 fn structured_field_takes_priority() {
2155 let mut session = test_session();
2156 let mut state1 = AgentRuntimeState::new("from-field");
2157 state1.status = AgentStatusState::Running;
2158 session.agent_runtime_state = Some(state1);
2159
2160 let mut state2 = AgentRuntimeState::new("from-metadata");
2161 state2.status = AgentStatusState::Completed;
2162 session.metadata.insert(
2163 METADATA_KEY.to_string(),
2164 serde_json::to_string(&state2).unwrap(),
2165 );
2166
2167 let read = read_runtime_state(&session).unwrap();
2168 assert_eq!(read.run_id, "from-field");
2169 assert_eq!(read.status, AgentStatusState::Running);
2170 }
2171
2172 #[test]
2173 fn read_returns_none_when_empty() {
2174 let session = test_session();
2175 assert!(read_runtime_state(&session).is_none());
2176 }
2177
2178 #[test]
2179 fn write_only_structured_field() {
2180 let mut session = test_session();
2181 let state = AgentRuntimeState::new("run-3");
2182
2183 write_runtime_state(&mut session, &state);
2184
2185 assert!(session.agent_runtime_state.is_some());
2186 assert!(!session.metadata.contains_key(METADATA_KEY));
2188 assert_eq!(
2189 session.agent_runtime_state.as_ref().unwrap().run_id,
2190 "run-3"
2191 );
2192 }
2193
2194 #[test]
2195 fn sync_extracts_model_name() {
2196 let mut session = test_session();
2197 session.model = "gpt-4o".to_string();
2198 session.metadata.insert(
2199 "responses.previous_response_id".to_string(),
2200 "resp-123".to_string(),
2201 );
2202
2203 let mut state = AgentRuntimeState::new("run-4");
2204 sync_from_metadata(&session, &mut state);
2205
2206 assert_eq!(state.llm.model_name, Some("gpt-4o".to_string()));
2207 assert_eq!(
2208 state.llm.responses_previous_id,
2209 Some("resp-123".to_string())
2210 );
2211 }
2212
2213 #[derive(Default)]
2216 struct TestStorage {
2217 sessions: RwLock<HashMap<String, Session>>,
2218 }
2219
2220 #[async_trait::async_trait]
2221 impl Storage for TestStorage {
2222 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
2223 self.sessions
2224 .write()
2225 .await
2226 .insert(session.id.clone(), session.clone());
2227 Ok(())
2228 }
2229
2230 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
2231 Ok(self.sessions.read().await.get(session_id).cloned())
2232 }
2233
2234 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
2235 Ok(self.sessions.write().await.remove(session_id).is_some())
2236 }
2237 }
2238
2239 #[tokio::test]
2240 async fn tool_boundary_permission_refresh_preserves_legacy_same_mode_without_audit() {
2241 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
2242 let mut persisted = Session::new("legacy-same-mode", "model");
2243 persisted.agent_runtime_state = Some(AgentRuntimeState::new("run"));
2244 persisted.add_message(bamboo_agent_core::Message::user(
2245 "must stay on disk until the next turn boundary",
2246 ));
2247 storage.save_session(&persisted).await.unwrap();
2248
2249 let mut running = persisted.clone();
2250 running.messages.clear();
2251 let mut runtime_state = AgentRuntimeState::new("run");
2252 refresh_tool_boundary_authorities(&mut running, &mut runtime_state, Some(&storage))
2253 .await
2254 .expect("same-mode legacy posture is not a transition to adopt");
2255
2256 assert_eq!(
2257 runtime_state.effective_permission_mode(),
2258 bamboo_domain::SessionPermissionMode::Default
2259 );
2260 assert!(
2261 running.messages.is_empty(),
2262 "tool-boundary refresh must not merge durable messages mid-round"
2263 );
2264 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&running.metadata).is_none());
2265 }
2266
2267 #[tokio::test]
2268 async fn first_unpersisted_root_is_published_before_provider_round() {
2269 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
2270 let mut fresh = Session::new("fresh-sdk-root", "model");
2271 fresh.agent_runtime_state = Some(AgentRuntimeState::new("run"));
2272 ensure_initial_root_tool_authority(&mut fresh, Some(&storage))
2273 .await
2274 .expect("new SDK Root is durably published first");
2275 refresh_round_root_tool_authority(&mut fresh, Some(&storage))
2276 .await
2277 .expect("first provider round reads the published Root");
2278 assert!(fresh.allows_model_tool_execution("Bash"));
2279 assert!(storage
2280 .load_runtime_control_plane(&fresh.id)
2281 .await
2282 .unwrap()
2283 .is_some());
2284
2285 let mut runtime_state = AgentRuntimeState::new("run");
2286 refresh_tool_boundary_authorities(&mut fresh, &mut runtime_state, Some(&storage))
2287 .await
2288 .expect("the saved control plane admits ordinary execution");
2289
2290 let mut selected = Session::new("selected-sdk-root", "model");
2291 selected.set_root_orchestration_only(true).unwrap();
2292 assert!(
2293 refresh_round_root_tool_authority(&mut selected, Some(&storage))
2294 .await
2295 .is_err()
2296 );
2297
2298 let mut resumed = Session::new("resumed-sdk-root", "model");
2299 resumed.add_message(Message::assistant("prior round", None));
2300 assert!(
2301 refresh_round_root_tool_authority(&mut resumed, Some(&storage))
2302 .await
2303 .is_err()
2304 );
2305 }
2306
2307 #[tokio::test]
2308 async fn revoked_early_root_cannot_be_recreated_by_initial_runtime_publication() {
2309 let home = tempfile::tempdir().unwrap();
2310 let store = Arc::new(
2311 bamboo_storage::SessionStoreV2::new(home.path().join("sessions"))
2312 .await
2313 .unwrap(),
2314 );
2315 let stale = Session::new("revoked-early-root", "model");
2316 store.save_session(&stale).await.unwrap();
2317 assert!(store.delete_session(&stale.id).await.unwrap());
2318 let storage: Arc<dyn Storage> = store;
2319 let mut running = stale;
2320
2321 let error = ensure_initial_root_tool_authority(&mut running, Some(&storage))
2322 .await
2323 .expect_err("ordinary save must not revive a deleted Root lifetime");
2324 assert!(error.to_string().contains("failed closed"));
2325 assert!(storage
2326 .load_runtime_control_plane(&running.id)
2327 .await
2328 .unwrap()
2329 .is_none());
2330 assert!(
2331 refresh_round_root_tool_authority(&mut running, Some(&storage))
2332 .await
2333 .is_err()
2334 );
2335 }
2336
2337 #[tokio::test]
2338 async fn old_sdk_root_cannot_adopt_a_recreated_root_lifetime() {
2339 let home = tempfile::tempdir().unwrap();
2340 let store = Arc::new(
2341 bamboo_storage::SessionStoreV2::new(home.path().join("sessions"))
2342 .await
2343 .unwrap(),
2344 );
2345 let mut old = Session::new("recreated-sdk-root", "model");
2346 old.agent_runtime_state = Some(AgentRuntimeState::new("old-run"));
2347 store.save_session(&old).await.unwrap();
2348 assert!(store.delete_session(&old.id).await.unwrap());
2349
2350 let mut replacement = store
2351 .recreate_root_session(&old.id, "new-model")
2352 .await
2353 .unwrap();
2354 assert_ne!(replacement.created_at, old.created_at);
2355 replacement.agent_runtime_state = Some(AgentRuntimeState::new("new-run"));
2356 store.save_session(&replacement).await.unwrap();
2357 let storage: Arc<dyn Storage> = store;
2358
2359 let old_birth = old.created_at;
2360 let mut running = old;
2361 let error = ensure_initial_root_tool_authority(&mut running, Some(&storage))
2362 .await
2363 .expect_err("pre-provider proof must reject the old SDK snapshot");
2364 assert!(error.to_string().contains("Root tool authority"));
2365 assert!(
2366 refresh_round_root_tool_authority(&mut running, Some(&storage))
2367 .await
2368 .is_err(),
2369 "subsequent catalog refresh must reject the same stale lifetime"
2370 );
2371 let mut runtime_state = AgentRuntimeState::new("old-run");
2372 let error =
2373 refresh_tool_boundary_authorities(&mut running, &mut runtime_state, Some(&storage))
2374 .await
2375 .expect_err("pre-dispatch proof must reject the old SDK snapshot");
2376 assert!(error.to_string().contains("Root tool authority"));
2377 assert_eq!(running.created_at, old_birth);
2378 assert_eq!(runtime_state.run_id, "old-run");
2379 }
2380
2381 #[tokio::test]
2382 async fn active_supervisor_adopts_mode_before_next_provider_catalog() {
2383 let home = tempfile::tempdir().unwrap();
2384 let store = std::sync::Arc::new(
2385 bamboo_storage::SessionStoreV2::new(home.path().join("sessions"))
2386 .await
2387 .unwrap(),
2388 );
2389 store
2390 .get_or_create_default_supervisor("model")
2391 .await
2392 .unwrap();
2393 let id = bamboo_domain::DEFAULT_SUPERVISOR_SESSION_ID;
2394 let mut running = store.load_root_authority(id).await.unwrap().unwrap();
2395 assert!(!running.root_orchestration_only_enabled());
2396 assert!(running.model_orchestration_only_enabled());
2397 assert!(!running.allows_model_tool_execution("Bash"));
2398 assert!(running.allows_model_tool_execution("SubAgent"));
2399 let operation = bamboo_domain::RootModeOperationRequest {
2400 session_id: id.to_string(),
2401 operation_id: format!("0:{}", uuid::Uuid::new_v4()),
2402 birth_token: running.root_mode_birth_token(),
2403 expected_epoch: 0,
2404 requested_enabled: true,
2405 action: bamboo_domain::RootModeOperationAction::Select,
2406 };
2407 assert!(matches!(
2408 store.root_mode_operation(&operation).await.unwrap(),
2409 bamboo_domain::RootModeOperationDecision::Terminal(_)
2410 ));
2411
2412 let storage: Arc<dyn Storage> = store;
2413 refresh_round_root_tool_authority(&mut running, Some(&storage))
2414 .await
2415 .expect("strict Supervisor management proof remains valid");
2416 assert!(running.root_orchestration_only_enabled());
2417 assert!(!running.allows_model_tool_execution("Bash"));
2418 assert!(running.allows_model_tool_execution("SubAgent"));
2419 assert_eq!(running.root_mode_transition_epoch, 1);
2420 assert_eq!(running.root_mode_operations.len(), 1);
2421 running.add_message(Message::assistant("Supervisor result", None));
2422 let persistence =
2423 std::sync::Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
2424 let repository =
2425 crate::SessionRepository::new(std::sync::Arc::default(), storage.clone(), persistence);
2426 repository.save(&mut running).await.unwrap();
2427 let durable = storage.load_session(id).await.unwrap().unwrap();
2428 assert_eq!(durable.root_mode_transition_epoch, 1);
2429 assert_eq!(
2430 durable.messages.last().unwrap().content,
2431 "Supervisor result"
2432 );
2433 }
2434
2435 #[tokio::test]
2436 async fn ordinary_root_at_reserved_id_still_refreshes_before_provider() {
2437 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
2438 let mut running = Session::new(bamboo_domain::DEFAULT_SUPERVISOR_SESSION_ID, "model");
2439 let mut latest = running.clone();
2440 latest.set_root_orchestration_only(true).unwrap();
2441 storage.save_session(&latest).await.unwrap();
2442
2443 refresh_round_root_tool_authority(&mut running, Some(&storage))
2444 .await
2445 .expect("ordinary identity at the reserved ID still needs durable proof");
2446 assert!(running.root_orchestration_only_enabled());
2447 assert!(!running.allows_model_tool_execution("Bash"));
2448 }
2449
2450 struct TestPersistence(Arc<dyn Storage>);
2451
2452 #[async_trait::async_trait]
2453 impl bamboo_domain::RuntimeSessionPersistence for TestPersistence {
2454 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2455 self.0.save_session(session).await
2456 }
2457 }
2458
2459 struct FaultingPersistence {
2460 inner: Arc<bamboo_storage::LockedSessionStore>,
2461 fail_checkpoint: std::sync::atomic::AtomicBool,
2462 }
2463
2464 #[async_trait::async_trait]
2465 impl bamboo_domain::RuntimeSessionPersistence for FaultingPersistence {
2466 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2467 self.inner.merge_save_runtime(session).await
2468 }
2469
2470 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2471 if self
2472 .fail_checkpoint
2473 .load(std::sync::atomic::Ordering::SeqCst)
2474 {
2475 return Err(std::io::Error::other("injected checkpoint failure"));
2476 }
2477 self.inner.checkpoint_runtime_session(session).await
2478 }
2479
2480 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
2481 self.inner.storage().load_session(session_id).await
2482 }
2483 }
2484
2485 struct AckAfterPersistErrorInbox {
2486 inner: Arc<dyn SessionInboxPort>,
2487 fail_before_once: std::sync::atomic::AtomicBool,
2488 fail_after_once: std::sync::atomic::AtomicBool,
2489 }
2490
2491 struct ForcedPermanentReceiptInbox {
2492 inner: Arc<dyn SessionInboxPort>,
2493 ack_count: Arc<AtomicUsize>,
2494 }
2495
2496 struct FailSecondDeliveryInbox {
2497 inner: Arc<dyn SessionInboxPort>,
2498 deliveries: AtomicUsize,
2499 }
2500
2501 struct FailActivationInbox {
2502 inner: Arc<dyn SessionInboxPort>,
2503 marks: AtomicUsize,
2504 }
2505
2506 #[async_trait::async_trait]
2507 impl SessionInboxPort for FailActivationInbox {
2508 async fn deliver(
2509 &self,
2510 envelope: &SessionMessageEnvelope,
2511 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
2512 self.inner.deliver(envelope).await
2513 }
2514
2515 async fn mark_activation_eligible(
2516 &self,
2517 _target_session_id: &str,
2518 _generation: u64,
2519 _policy: bamboo_domain::SessionActivationPolicy,
2520 ) -> Result<(), bamboo_domain::SessionInboxError> {
2521 self.marks.fetch_add(1, Ordering::SeqCst);
2522 Err(bamboo_domain::SessionInboxError::Storage(
2523 "injected activation watermark failure".to_string(),
2524 ))
2525 }
2526
2527 async fn claim(
2528 &self,
2529 target_session_id: &str,
2530 limit: usize,
2531 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, bamboo_domain::SessionInboxError>
2532 {
2533 self.inner.claim(target_session_id, limit).await
2534 }
2535
2536 async fn was_admitted(
2537 &self,
2538 target_session_id: &str,
2539 id: &SessionMessageId,
2540 ) -> Result<bool, bamboo_domain::SessionInboxError> {
2541 self.inner.was_admitted(target_session_id, id).await
2542 }
2543
2544 async fn ack(
2545 &self,
2546 target_session_id: &str,
2547 claim: &bamboo_domain::SessionInboxClaim,
2548 ) -> Result<(), bamboo_domain::SessionInboxError> {
2549 self.inner.ack(target_session_id, claim).await
2550 }
2551
2552 async fn inspect(
2553 &self,
2554 target_session_id: &str,
2555 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
2556 self.inner.inspect(target_session_id).await
2557 }
2558 }
2559
2560 #[async_trait::async_trait]
2561 impl SessionInboxPort for FailSecondDeliveryInbox {
2562 async fn deliver(
2563 &self,
2564 envelope: &SessionMessageEnvelope,
2565 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
2566 if self.deliveries.fetch_add(1, Ordering::SeqCst) == 1 {
2567 return Err(bamboo_domain::SessionInboxError::Storage(
2568 "injected second legacy delivery failure".to_string(),
2569 ));
2570 }
2571 self.inner.deliver(envelope).await
2572 }
2573
2574 async fn mark_activation_eligible(
2575 &self,
2576 target_session_id: &str,
2577 generation: u64,
2578 policy: bamboo_domain::SessionActivationPolicy,
2579 ) -> Result<(), bamboo_domain::SessionInboxError> {
2580 self.inner
2581 .mark_activation_eligible(target_session_id, generation, policy)
2582 .await
2583 }
2584
2585 async fn claim(
2586 &self,
2587 target_session_id: &str,
2588 limit: usize,
2589 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, bamboo_domain::SessionInboxError>
2590 {
2591 self.inner.claim(target_session_id, limit).await
2592 }
2593
2594 async fn was_admitted(
2595 &self,
2596 target_session_id: &str,
2597 id: &SessionMessageId,
2598 ) -> Result<bool, bamboo_domain::SessionInboxError> {
2599 self.inner.was_admitted(target_session_id, id).await
2600 }
2601
2602 async fn ack(
2603 &self,
2604 target_session_id: &str,
2605 claim: &bamboo_domain::SessionInboxClaim,
2606 ) -> Result<(), bamboo_domain::SessionInboxError> {
2607 self.inner.ack(target_session_id, claim).await
2608 }
2609
2610 async fn inspect(
2611 &self,
2612 target_session_id: &str,
2613 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
2614 self.inner.inspect(target_session_id).await
2615 }
2616 }
2617
2618 struct ActivationBeforeClearPersistence {
2619 inner: Arc<bamboo_storage::LockedSessionStore>,
2620 inbox: Arc<dyn SessionInboxPort>,
2621 fail_clear_once: std::sync::atomic::AtomicBool,
2622 clear_calls: AtomicUsize,
2623 }
2624
2625 #[async_trait::async_trait]
2626 impl bamboo_domain::RuntimeSessionPersistence for ActivationBeforeClearPersistence {
2627 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2628 self.inner.merge_save_runtime(session).await
2629 }
2630
2631 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2632 self.inner.checkpoint_runtime_session(session).await
2633 }
2634
2635 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
2636 self.inner.storage().load_session(session_id).await
2637 }
2638
2639 async fn clear_legacy_pending_messages(
2640 &self,
2641 session_id: &str,
2642 expected: &[serde_json::Value],
2643 ) -> std::io::Result<bool> {
2644 let backlog = self
2645 .inbox
2646 .inspect(session_id)
2647 .await
2648 .map_err(|error| std::io::Error::other(error.to_string()))?;
2649 if backlog.activation_generation < backlog.generation
2650 || backlog.interrupt_generation < backlog.generation
2651 {
2652 return Err(std::io::Error::other(format!(
2653 "legacy source clear attempted before interrupt activation publish: {backlog:?}"
2654 )));
2655 }
2656 self.clear_calls.fetch_add(1, Ordering::SeqCst);
2657 if self
2658 .fail_clear_once
2659 .swap(false, std::sync::atomic::Ordering::SeqCst)
2660 {
2661 return Err(std::io::Error::other(
2662 "injected crash after enqueue+mark and before source clear",
2663 ));
2664 }
2665 self.inner
2666 .clear_legacy_pending_messages(session_id, expected)
2667 .await
2668 }
2669 }
2670
2671 #[async_trait::async_trait]
2672 impl SessionInboxPort for ForcedPermanentReceiptInbox {
2673 async fn deliver(
2674 &self,
2675 envelope: &SessionMessageEnvelope,
2676 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
2677 self.inner.deliver(envelope).await
2678 }
2679
2680 async fn mark_activation_eligible(
2681 &self,
2682 target_session_id: &str,
2683 generation: u64,
2684 policy: bamboo_domain::SessionActivationPolicy,
2685 ) -> Result<(), bamboo_domain::SessionInboxError> {
2686 self.inner
2687 .mark_activation_eligible(target_session_id, generation, policy)
2688 .await
2689 }
2690
2691 async fn claim(
2692 &self,
2693 target_session_id: &str,
2694 limit: usize,
2695 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, bamboo_domain::SessionInboxError>
2696 {
2697 self.inner.claim(target_session_id, limit).await
2698 }
2699
2700 async fn was_admitted(
2701 &self,
2702 _target_session_id: &str,
2703 _id: &SessionMessageId,
2704 ) -> Result<bool, bamboo_domain::SessionInboxError> {
2705 Ok(true)
2706 }
2707
2708 async fn ack(
2709 &self,
2710 target_session_id: &str,
2711 claim: &bamboo_domain::SessionInboxClaim,
2712 ) -> Result<(), bamboo_domain::SessionInboxError> {
2713 self.inner.ack(target_session_id, claim).await?;
2714 self.ack_count.fetch_add(1, Ordering::SeqCst);
2715 Ok(())
2716 }
2717
2718 async fn inspect(
2719 &self,
2720 target_session_id: &str,
2721 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
2722 self.inner.inspect(target_session_id).await
2723 }
2724 }
2725
2726 struct BacklogActivationSpawner {
2727 inbox: Arc<dyn SessionInboxPort>,
2728 reservations: Arc<AtomicUsize>,
2729 launches: Arc<AtomicUsize>,
2730 }
2731
2732 #[async_trait::async_trait]
2733 impl crate::SessionActivationSpawner for BacklogActivationSpawner {
2734 async fn reserve_activation(
2735 &self,
2736 target_session_id: &str,
2737 _inbox_generation: u64,
2738 ) -> Result<crate::SessionActivationReserveOutcome, SessionActivationError> {
2739 let backlog = self
2740 .inbox
2741 .inspect(target_session_id)
2742 .await
2743 .map_err(|error| SessionActivationError::Internal(error.to_string()))?;
2744 if !backlog.activation_pending() {
2745 return Ok(crate::SessionActivationReserveOutcome::NoWork);
2746 }
2747 let ordinal = self.reservations.fetch_add(1, Ordering::SeqCst) + 1;
2748 let launches = self.launches.clone();
2749 Ok(crate::SessionActivationReserveOutcome::Reserved(
2750 crate::SessionActivationLaunch::new(format!("successor-{ordinal}"), move || {
2751 launches.fetch_add(1, Ordering::SeqCst);
2752 }),
2753 ))
2754 }
2755 }
2756
2757 #[async_trait::async_trait]
2758 impl SessionInboxPort for AckAfterPersistErrorInbox {
2759 async fn deliver(
2760 &self,
2761 envelope: &SessionMessageEnvelope,
2762 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
2763 self.inner.deliver(envelope).await
2764 }
2765
2766 async fn mark_activation_eligible(
2767 &self,
2768 target_session_id: &str,
2769 generation: u64,
2770 policy: bamboo_domain::SessionActivationPolicy,
2771 ) -> Result<(), bamboo_domain::SessionInboxError> {
2772 self.inner
2773 .mark_activation_eligible(target_session_id, generation, policy)
2774 .await
2775 }
2776
2777 async fn claim(
2778 &self,
2779 target_session_id: &str,
2780 limit: usize,
2781 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, bamboo_domain::SessionInboxError>
2782 {
2783 self.inner.claim(target_session_id, limit).await
2784 }
2785
2786 async fn was_admitted(
2787 &self,
2788 target_session_id: &str,
2789 id: &SessionMessageId,
2790 ) -> Result<bool, bamboo_domain::SessionInboxError> {
2791 self.inner.was_admitted(target_session_id, id).await
2792 }
2793
2794 async fn ack(
2795 &self,
2796 target_session_id: &str,
2797 claim: &bamboo_domain::SessionInboxClaim,
2798 ) -> Result<(), bamboo_domain::SessionInboxError> {
2799 if self
2800 .fail_before_once
2801 .swap(false, std::sync::atomic::Ordering::SeqCst)
2802 {
2803 return Err(bamboo_domain::SessionInboxError::Storage(
2804 "injected pre-receipt ack failure".to_string(),
2805 ));
2806 }
2807 self.inner.ack(target_session_id, claim).await?;
2808 if self
2809 .fail_after_once
2810 .swap(false, std::sync::atomic::Ordering::SeqCst)
2811 {
2812 return Err(bamboo_domain::SessionInboxError::Storage(
2813 "injected post-receipt ack failure".to_string(),
2814 ));
2815 }
2816 Ok(())
2817 }
2818
2819 async fn inspect(
2820 &self,
2821 target_session_id: &str,
2822 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
2823 self.inner.inspect(target_session_id).await
2824 }
2825 }
2826
2827 async fn durable_inbox_fixture(
2828 id: &str,
2829 ) -> (
2830 tempfile::TempDir,
2831 Arc<bamboo_storage::SessionStoreV2>,
2832 Arc<bamboo_storage::LockedSessionStore>,
2833 Arc<dyn SessionInboxPort>,
2834 Session,
2835 ) {
2836 let temp = tempfile::tempdir().unwrap();
2837 let store = Arc::new(
2838 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
2839 .await
2840 .unwrap(),
2841 );
2842 let storage: Arc<dyn Storage> = store.clone();
2843 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
2844 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
2845 store.clone(),
2846 bamboo_domain::SessionInboxLimits::default(),
2847 ));
2848 let session = Session::new(id, "model");
2849 store.save_session(&session).await.unwrap();
2850 (temp, store, persistence, inbox, session)
2851 }
2852
2853 async fn deliver_interrupt_eligible(
2854 inbox: &Arc<dyn SessionInboxPort>,
2855 envelope: &SessionMessageEnvelope,
2856 ) -> bamboo_domain::SessionInboxReceipt {
2857 let receipt = inbox.deliver(envelope).await.unwrap();
2858 inbox
2859 .mark_activation_eligible(
2860 &envelope.target_session_id,
2861 receipt.generation,
2862 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
2863 )
2864 .await
2865 .unwrap();
2866 receipt
2867 }
2868
2869 #[tokio::test]
2870 async fn owned_inbox_claim_requires_current_owner_before_provider_admission() {
2871 use bamboo_domain::{SessionInboxConsumerId, SessionInboxLeaseRequest};
2872
2873 let (_home, store, locked, inbox, mut running) =
2874 durable_inbox_fixture("owned-claim-boundary").await;
2875 let envelope = SessionMessageEnvelope::user_input(&running.id, "leased input");
2876 deliver_interrupt_eligible(&inbox, &envelope).await;
2877 let lease = SessionInboxLeaseRequest {
2878 consumer: SessionInboxConsumerId::new(),
2879 now: chrono::Utc::now(),
2880 duration: chrono::Duration::seconds(30),
2881 };
2882 let owned = inbox
2883 .claim_owned(&running.id, 1, None, &lease)
2884 .await
2885 .unwrap();
2886 assert_eq!(owned.len(), 1);
2887
2888 let storage: Arc<dyn Storage> = store.clone();
2889 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
2890 let refresh = refresh_turn_boundary_with_inbox(
2891 &mut running,
2892 Some(&storage),
2893 Some(&persistence),
2894 Some(&inbox),
2895 )
2896 .await;
2897 assert_eq!(
2898 refresh.admission_error.as_deref(),
2899 Some(INBOX_CLAIM_UNRESOLVED)
2900 );
2901 assert_eq!(refresh.merged, 0);
2902 assert!(!running
2903 .messages
2904 .iter()
2905 .any(|message| message.id == envelope.id.as_str()));
2906 assert!(!inbox.was_admitted(&running.id, &envelope.id).await.unwrap());
2907 let cold = store.load_session(&running.id).await.unwrap().unwrap();
2908 assert!(!cold
2909 .messages
2910 .iter()
2911 .any(|message| message.id == envelope.id.as_str()));
2912 assert_eq!(
2913 inbox
2914 .inspect_owned_leases(&running.id, 1, lease.now)
2915 .await
2916 .unwrap()
2917 .len(),
2918 1
2919 );
2920 }
2921
2922 #[tokio::test]
2923 async fn merge_pending_injected_messages_merges_and_clears() {
2924 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
2925 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> =
2926 Arc::new(TestPersistence(storage.clone()));
2927 let mut persisted = Session::new_child("child-merge", "parent", "model", "Child");
2928 persisted.add_message(bamboo_agent_core::Message::system("system"));
2929 persisted.add_message(bamboo_agent_core::Message::user("original task"));
2930 persisted.metadata.insert(
2931 PENDING_INJECTED_MESSAGES_KEY.to_string(),
2932 serde_json::json!([
2933 {
2934 "content": "queued correction",
2935 "created_at": chrono::Utc::now(),
2936 }
2937 ])
2938 .to_string(),
2939 );
2940 storage
2941 .save_session(&persisted)
2942 .await
2943 .expect("persisted child should be saved");
2944
2945 let mut running = persisted.clone();
2946 running.metadata.remove(PENDING_INJECTED_MESSAGES_KEY);
2947
2948 let count =
2949 merge_pending_injected_messages(&mut running, Some(&storage), Some(&persistence)).await;
2950
2951 assert_eq!(count, 1);
2952 assert_eq!(
2953 running
2954 .messages
2955 .last()
2956 .map(|message| message.content.as_str()),
2957 Some("queued correction")
2958 );
2959 assert!(!running.metadata.contains_key(PENDING_INJECTED_MESSAGES_KEY));
2960 let saved = storage
2961 .load_session("child-merge")
2962 .await
2963 .expect("load should succeed")
2964 .expect("session should exist");
2965 assert!(!saved.metadata.contains_key(PENDING_INJECTED_MESSAGES_KEY));
2966
2967 let count2 =
2969 merge_pending_injected_messages(&mut running, Some(&storage), Some(&persistence)).await;
2970 assert_eq!(count2, 0);
2971 }
2972
2973 #[tokio::test]
2974 async fn merge_pending_injected_messages_returns_zero_without_storage() {
2975 let mut session = test_session();
2976 let count = merge_pending_injected_messages(&mut session, None, None).await;
2977 assert_eq!(count, 0);
2978 }
2979
2980 #[tokio::test]
2983 async fn refresh_turn_boundary_reports_disk_permission_mode() {
2984 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
2985
2986 let mut persisted = Session::new("bypass-live", "model");
2988 let mut state = AgentRuntimeState::new("run-x");
2989 state.set_permission_mode(bamboo_domain::SessionPermissionMode::Auto);
2990 persisted.agent_runtime_state = Some(state);
2991 for (key, value) in [
2992 ("permission.policy_revision", "17"),
2993 ("permission.requested_mode", "auto"),
2994 ("permission.effective_mode", "auto"),
2995 ("permission.executor_mapping", "bamboo_runtime:auto"),
2996 ("permission.transitioned_at", "2026-07-31T12:00:00Z"),
2997 ] {
2998 persisted
2999 .metadata
3000 .insert(key.to_string(), value.to_string());
3001 }
3002 storage.save_session(&persisted).await.unwrap();
3003
3004 let mut running = Session::new("bypass-live", "model");
3006 running.agent_runtime_state = Some(AgentRuntimeState::new("run-x"));
3007 running.metadata.insert(
3008 "permission.executor_mapping".to_string(),
3009 "bamboo_runtime:default".to_string(),
3010 );
3011
3012 let refresh = refresh_turn_boundary_from_disk(&mut running, Some(&storage), None).await;
3013 assert_eq!(
3014 refresh.disk_permission_mode,
3015 Some(bamboo_domain::SessionPermissionMode::Auto)
3016 );
3017 assert_eq!(refresh.merged, 0);
3018 for (key, value) in [
3019 ("permission.policy_revision", "17"),
3020 ("permission.requested_mode", "auto"),
3021 ("permission.effective_mode", "auto"),
3022 ("permission.executor_mapping", "bamboo_runtime:auto"),
3023 ("permission.transitioned_at", "2026-07-31T12:00:00Z"),
3024 ] {
3025 assert_eq!(running.metadata.get(key).map(String::as_str), Some(value));
3026 }
3027 }
3028
3029 #[tokio::test]
3030 async fn refresh_turn_boundary_reports_none_without_storage() {
3031 let mut session = test_session();
3032 let refresh = refresh_turn_boundary_from_disk(&mut session, None, None).await;
3033 assert_eq!(refresh.disk_permission_mode, None);
3034 assert_eq!(refresh.merged, 0);
3035 }
3036
3037 #[tokio::test]
3041 async fn refresh_turn_boundary_reports_none_when_disk_has_no_runtime_state() {
3042 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
3043 let persisted = Session::new("no-rs", "model");
3044 assert!(persisted.agent_runtime_state.is_none());
3045 storage.save_session(&persisted).await.unwrap();
3046
3047 let mut running = Session::new("no-rs", "model");
3048 let refresh = refresh_turn_boundary_from_disk(&mut running, Some(&storage), None).await;
3049 assert_eq!(refresh.disk_permission_mode, None);
3050 }
3051
3052 #[tokio::test]
3053 async fn inbox_checkpoint_failure_rolls_back_memory_and_leaves_claim_recoverable() {
3054 let (_temp, store, locked, inbox, mut running) =
3055 durable_inbox_fixture("checkpoint-failure").await;
3056 let mut envelope = SessionMessageEnvelope::user_input("checkpoint-failure", "recover me");
3057 envelope.id = SessionMessageId::parse("checkpoint-failure-message").unwrap();
3058 deliver_interrupt_eligible(&inbox, &envelope).await;
3059
3060 let fault = Arc::new(FaultingPersistence {
3061 inner: locked.clone(),
3062 fail_checkpoint: std::sync::atomic::AtomicBool::new(true),
3063 });
3064 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = fault;
3065 let storage: Arc<dyn Storage> = store.clone();
3066 let refresh = refresh_turn_boundary_with_inbox(
3067 &mut running,
3068 Some(&storage),
3069 Some(&persistence),
3070 Some(&inbox),
3071 )
3072 .await;
3073 assert_eq!(refresh.merged, 0);
3074 assert_eq!(
3075 refresh.admission_error.as_deref(),
3076 Some(INBOX_CLAIM_UNRESOLVED)
3077 );
3078 assert!(!running
3079 .messages
3080 .iter()
3081 .any(|message| message.id == envelope.id.as_str()));
3082 assert!(!running
3083 .session_inbox_admission()
3084 .is_some_and(|state| state.contains(&envelope.id)));
3085 let backlog = inbox.inspect(&running.id).await.unwrap();
3086 assert_eq!(backlog.claimed, 1);
3087 assert!(!inbox.was_admitted(&running.id, &envelope.id).await.unwrap());
3088
3089 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3091 store.clone(),
3092 bamboo_domain::SessionInboxLimits::default(),
3093 ));
3094 let normal: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3095 let mut restarted = store.load_session(&running.id).await.unwrap().unwrap();
3096 let refresh = refresh_turn_boundary_with_inbox(
3097 &mut restarted,
3098 Some(&storage),
3099 Some(&normal),
3100 Some(&reopened),
3101 )
3102 .await;
3103 assert_eq!(refresh.merged, 1);
3104 assert_eq!(
3105 restarted
3106 .messages
3107 .iter()
3108 .filter(|message| message.id == envelope.id.as_str())
3109 .count(),
3110 1
3111 );
3112 assert!(reopened
3113 .was_admitted(&running.id, &envelope.id)
3114 .await
3115 .unwrap());
3116 }
3117
3118 #[tokio::test]
3119 async fn non_typed_transcript_id_collision_never_acks_the_typed_claim() {
3120 let (_temp, store, locked, inbox, mut running) =
3121 durable_inbox_fixture("transcript-id-collision").await;
3122 let mut envelope =
3123 SessionMessageEnvelope::user_input("transcript-id-collision", "typed truth");
3124 envelope.id = SessionMessageId::parse("colliding-id").unwrap();
3125 deliver_interrupt_eligible(&inbox, &envelope).await;
3126
3127 let mut forged = bamboo_agent_core::Message::user("unrelated transcript entry");
3128 forged.id = envelope.id.to_string();
3129 running.add_message(forged);
3130 let storage: Arc<dyn Storage> = store;
3131 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3132 let refresh = refresh_turn_boundary_with_inbox(
3133 &mut running,
3134 Some(&storage),
3135 Some(&persistence),
3136 Some(&inbox),
3137 )
3138 .await;
3139 assert_eq!(refresh.merged, 0);
3140 assert_eq!(
3141 refresh.admission_error.as_deref(),
3142 Some(INBOX_CLAIM_UNRESOLVED)
3143 );
3144 assert!(!running
3145 .session_inbox_admission()
3146 .is_some_and(|state| state.contains(&envelope.id)));
3147 let backlog = inbox.inspect(&running.id).await.unwrap();
3148 assert_eq!(backlog.claimed, 1);
3149 assert!(!inbox.was_admitted(&running.id, &envelope.id).await.unwrap());
3150 }
3151
3152 #[tokio::test]
3153 async fn concurrent_durable_id_collision_after_claim_never_acks() {
3154 let (_temp, store, locked, inbox, mut running) =
3155 durable_inbox_fixture("durable-id-collision").await;
3156 let mut envelope =
3157 SessionMessageEnvelope::user_input("durable-id-collision", "typed truth");
3158 envelope.id = SessionMessageId::parse("durable-colliding-id").unwrap();
3159 deliver_interrupt_eligible(&inbox, &envelope).await;
3160
3161 let mut concurrent = store.load_session(&running.id).await.unwrap().unwrap();
3165 let mut forged = bamboo_agent_core::Message::user("concurrent unrelated message");
3166 forged.id = envelope.id.to_string();
3167 concurrent.add_message(forged);
3168 store.save_session(&concurrent).await.unwrap();
3169
3170 let storage: Arc<dyn Storage> = store.clone();
3171 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3172 let refresh = refresh_turn_boundary_with_inbox(
3173 &mut running,
3174 Some(&storage),
3175 Some(&persistence),
3176 Some(&inbox),
3177 )
3178 .await;
3179 assert_eq!(refresh.merged, 0);
3180 assert_eq!(
3181 refresh.admission_error.as_deref(),
3182 Some(INBOX_CLAIM_UNRESOLVED)
3183 );
3184 let backlog = inbox.inspect(&running.id).await.unwrap();
3185 assert_eq!(backlog.claimed, 1);
3186 assert!(!inbox.was_admitted(&running.id, &envelope.id).await.unwrap());
3187 let durable = store.load_session(&running.id).await.unwrap().unwrap();
3188 assert!(!durable
3189 .messages
3190 .iter()
3191 .any(|message| { bamboo_domain::is_matching_session_message(message, &envelope) }));
3192 }
3193
3194 #[tokio::test]
3195 async fn concurrent_durable_typed_body_mismatch_after_claim_never_acks() {
3196 let (_temp, store, locked, inbox, mut running) =
3197 durable_inbox_fixture("durable-typed-body-collision").await;
3198 let mut envelope =
3199 SessionMessageEnvelope::user_input("durable-typed-body-collision", "typed truth");
3200 envelope.id = SessionMessageId::parse("durable-typed-colliding-id").unwrap();
3201 deliver_interrupt_eligible(&inbox, &envelope).await;
3202
3203 let mut different = envelope.clone();
3204 different.body = bamboo_domain::SessionMessageBody::Content(
3205 bamboo_domain::SessionMessageContent::text("forged different body"),
3206 );
3207 let mut concurrent = store.load_session(&running.id).await.unwrap().unwrap();
3208 concurrent.add_message(different.to_provider_message().unwrap());
3209 store.save_session(&concurrent).await.unwrap();
3210
3211 let storage: Arc<dyn Storage> = store.clone();
3212 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3213 let refresh = refresh_turn_boundary_with_inbox(
3214 &mut running,
3215 Some(&storage),
3216 Some(&persistence),
3217 Some(&inbox),
3218 )
3219 .await;
3220 assert_eq!(refresh.merged, 0);
3221 assert_eq!(
3222 refresh.admission_error.as_deref(),
3223 Some(INBOX_CLAIM_UNRESOLVED)
3224 );
3225 assert_eq!(inbox.inspect(&running.id).await.unwrap().claimed, 1);
3226 assert!(!inbox.was_admitted(&running.id, &envelope.id).await.unwrap());
3227 let durable = store.load_session(&running.id).await.unwrap().unwrap();
3228 assert!(!durable
3229 .messages
3230 .iter()
3231 .any(|message| bamboo_domain::is_matching_session_message(message, &envelope)));
3232 }
3233
3234 #[tokio::test]
3235 async fn permanent_receipt_without_typed_transcript_proof_leaves_cur_recoverable() {
3236 let (_temp, store, locked, real_inbox, mut running) =
3237 durable_inbox_fixture("permanent-without-proof").await;
3238 let mut envelope =
3239 SessionMessageEnvelope::user_input("permanent-without-proof", "recoverable");
3240 envelope.id = SessionMessageId::parse("receipt-without-proof").unwrap();
3241 deliver_interrupt_eligible(&real_inbox, &envelope).await;
3242 let ack_count = Arc::new(AtomicUsize::new(0));
3243 let inbox: Arc<dyn SessionInboxPort> = Arc::new(ForcedPermanentReceiptInbox {
3244 inner: real_inbox.clone(),
3245 ack_count: ack_count.clone(),
3246 });
3247 let storage: Arc<dyn Storage> = store;
3248 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3249
3250 let refresh = refresh_turn_boundary_with_inbox(
3251 &mut running,
3252 Some(&storage),
3253 Some(&persistence),
3254 Some(&inbox),
3255 )
3256 .await;
3257 assert_eq!(refresh.merged, 0);
3258 assert_eq!(
3259 refresh.admission_error.as_deref(),
3260 Some(INBOX_CLAIM_UNRESOLVED)
3261 );
3262 assert_eq!(ack_count.load(Ordering::SeqCst), 0);
3263 assert_eq!(real_inbox.inspect(&running.id).await.unwrap().claimed, 1);
3264 }
3265
3266 fn next_conversation(source: &Session) -> Session {
3267 let mut current = source.clone();
3268 current.conversation_reset = Some(bamboo_domain::ConversationResetState {
3269 schema_version: 1,
3270 conversation_revision: 1,
3271 receipts: vec![bamboo_domain::ConversationResetReceipt {
3272 operation_id: format!("0:{}", uuid::Uuid::new_v4()),
3273 birth_token: source.root_mode_birth_token(),
3274 expected_snapshot_sha256: "a".repeat(64),
3275 previous_revision: 0,
3276 conversation_revision: 1,
3277 retained_system_messages: 0,
3278 reset_at: source.updated_at,
3279 }],
3280 });
3281 current
3282 }
3283
3284 struct ConversationReadPersistence {
3285 current: Session,
3286 writes: AtomicUsize,
3287 }
3288 #[async_trait::async_trait]
3289 impl bamboo_domain::RuntimeSessionPersistence for ConversationReadPersistence {
3290 async fn save_runtime_session(&self, _: &mut Session) -> std::io::Result<()> {
3291 self.writes.fetch_add(1, Ordering::SeqCst);
3292 Err(std::io::Error::other("unexpected write"))
3293 }
3294 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
3295 Ok(Some(self.current.clone()))
3296 }
3297 }
3298
3299 #[tokio::test]
3300 async fn permanent_receipt_from_another_conversation_cannot_adopt_history_or_ack() {
3301 let (_temp, store, _locked, real_inbox, mut running) =
3302 durable_inbox_fixture("permanent-conversation").await;
3303 let envelope =
3304 SessionMessageEnvelope::user_input(&running.id, "different conversation input");
3305 deliver_interrupt_eligible(&real_inbox, &envelope).await;
3306 let mut current = next_conversation(&running);
3307 current.add_message(envelope.to_provider_message().unwrap());
3308 let read = Arc::new(ConversationReadPersistence {
3309 current,
3310 writes: AtomicUsize::new(0),
3311 });
3312 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = read.clone();
3313 let ack_count = Arc::new(AtomicUsize::new(0));
3314 let inbox: Arc<dyn SessionInboxPort> = Arc::new(ForcedPermanentReceiptInbox {
3315 inner: real_inbox.clone(),
3316 ack_count: ack_count.clone(),
3317 });
3318 let before = serde_json::to_value(&running).unwrap();
3319 let storage: Arc<dyn Storage> = store.clone();
3320 let refresh = refresh_turn_boundary_with_inbox(
3321 &mut running,
3322 Some(&storage),
3323 Some(&persistence),
3324 Some(&inbox),
3325 )
3326 .await;
3327 assert_eq!(refresh.merged, 0);
3328 assert!(refresh.committed_messages.is_empty());
3329 assert!(refresh
3330 .admission_error
3331 .unwrap()
3332 .contains("session_conversation_changed:"));
3333 assert_eq!(ack_count.load(Ordering::SeqCst), 0);
3334 assert_eq!(real_inbox.inspect(&running.id).await.unwrap().claimed, 1);
3335 assert_eq!(read.writes.load(Ordering::SeqCst), 0);
3336 assert_eq!(serde_json::to_value(&running).unwrap(), before);
3337 assert_eq!(
3338 serde_json::to_value(store.load_session(&running.id).await.unwrap().unwrap()).unwrap(),
3339 before
3340 );
3341 }
3342
3343 #[tokio::test]
3344 async fn canonical_refresh_cannot_adopt_authority_queue_or_inputs_from_another_conversation() {
3345 let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
3346 let mut original = Session::new("refresh-conversation", "model");
3347 original.add_message(Message::user("original history"));
3348 let mut current = next_conversation(&original);
3349 current.metadata.insert(
3350 "pending_injected_messages".into(),
3351 r#"[{"content":"new conversation queue"}]"#.into(),
3352 );
3353 storage.save_session(¤t).await.unwrap();
3354 let before = serde_json::to_value(&original).unwrap();
3355 let mut runtime = AgentRuntimeState::new("original-operation");
3356 let before_runtime = serde_json::to_value(&runtime).unwrap();
3357 assert!(
3358 refresh_tool_boundary_authorities(&mut original, &mut runtime, Some(&storage))
3359 .await
3360 .is_err()
3361 );
3362 assert!(
3363 refresh_round_root_tool_authority(&mut original, Some(&storage))
3364 .await
3365 .is_err()
3366 );
3367 assert!(
3368 ensure_initial_root_tool_authority(&mut original, Some(&storage))
3369 .await
3370 .is_err()
3371 );
3372 let refresh =
3373 refresh_turn_boundary_with_inbox(&mut original, Some(&storage), None, None).await;
3374 assert!(refresh
3375 .admission_error
3376 .unwrap()
3377 .contains("session_conversation_changed:"));
3378 assert_eq!(refresh.merged, 0);
3379 assert!(refresh.disk_permission_mode.is_none());
3380 assert_eq!(serde_json::to_value(&original).unwrap(), before);
3381 assert_eq!(serde_json::to_value(&runtime).unwrap(), before_runtime);
3382 assert_eq!(
3383 serde_json::to_value(storage.load_session(&original.id).await.unwrap().unwrap())
3384 .unwrap(),
3385 serde_json::to_value(¤t).unwrap()
3386 );
3387 }
3388
3389 #[tokio::test]
3390 async fn permanent_receipt_uses_unbounded_typed_marker_after_cursor_eviction() {
3391 let (_temp, store, locked, real_inbox, mut running) =
3392 durable_inbox_fixture("permanent-evicted-cursor").await;
3393 let mut envelope =
3394 SessionMessageEnvelope::user_input("permanent-evicted-cursor", "old admitted message");
3395 envelope.id = SessionMessageId::parse("evicted-receipt-id").unwrap();
3396 running.add_message(envelope.to_provider_message().unwrap());
3397 running
3398 .session_inbox_admission_mut()
3399 .record(envelope.id.clone(), 1);
3400 for sequence in 2..=(bamboo_domain::SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1) {
3401 running.session_inbox_admission_mut().record(
3402 SessionMessageId::parse(format!("later-{sequence}")).unwrap(),
3403 sequence,
3404 );
3405 }
3406 assert!(!running
3407 .session_inbox_admission()
3408 .unwrap()
3409 .contains(&envelope.id));
3410 store.save_session(&running).await.unwrap();
3411 deliver_interrupt_eligible(&real_inbox, &envelope).await;
3412
3413 let ack_count = Arc::new(AtomicUsize::new(0));
3414 let inbox: Arc<dyn SessionInboxPort> = Arc::new(ForcedPermanentReceiptInbox {
3415 inner: real_inbox.clone(),
3416 ack_count: ack_count.clone(),
3417 });
3418 let storage: Arc<dyn Storage> = store;
3419 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3420 let refresh = refresh_turn_boundary_with_inbox(
3421 &mut running,
3422 Some(&storage),
3423 Some(&persistence),
3424 Some(&inbox),
3425 )
3426 .await;
3427 assert_eq!(refresh.merged, 0);
3428 assert_eq!(ack_count.load(Ordering::SeqCst), 1);
3429 let backlog = real_inbox.inspect(&running.id).await.unwrap();
3430 assert_eq!(backlog.pending + backlog.claimed, 0);
3431 assert_eq!(
3432 running
3433 .messages
3434 .iter()
3435 .filter(|message| message.id == envelope.id.as_str())
3436 .count(),
3437 1
3438 );
3439 }
3440
3441 #[tokio::test]
3442 async fn post_receipt_ack_error_restarts_without_duplicate_transcript() {
3443 let (_temp, store, locked, real_inbox, mut running) =
3444 durable_inbox_fixture("ack-after-receipt").await;
3445 let mut envelope = SessionMessageEnvelope::user_input("ack-after-receipt", "exactly once");
3446 envelope.id = SessionMessageId::parse("ack-after-receipt-message").unwrap();
3447 deliver_interrupt_eligible(&real_inbox, &envelope).await;
3448
3449 let faulted: Arc<dyn SessionInboxPort> = Arc::new(AckAfterPersistErrorInbox {
3450 inner: real_inbox.clone(),
3451 fail_before_once: std::sync::atomic::AtomicBool::new(false),
3452 fail_after_once: std::sync::atomic::AtomicBool::new(true),
3453 });
3454 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked.clone();
3455 let storage: Arc<dyn Storage> = store.clone();
3456 refresh_turn_boundary_with_inbox(
3457 &mut running,
3458 Some(&storage),
3459 Some(&persistence),
3460 Some(&faulted),
3461 )
3462 .await;
3463
3464 let checkpointed = store.load_session(&running.id).await.unwrap().unwrap();
3465 assert_eq!(
3466 checkpointed
3467 .messages
3468 .iter()
3469 .filter(|message| message.id == envelope.id.as_str())
3470 .count(),
3471 1
3472 );
3473 assert!(checkpointed
3474 .session_inbox_admission()
3475 .is_some_and(|state| state.contains(&envelope.id)));
3476 assert!(real_inbox
3477 .was_admitted(&running.id, &envelope.id)
3478 .await
3479 .unwrap());
3480
3481 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3482 store.clone(),
3483 bamboo_domain::SessionInboxLimits::default(),
3484 ));
3485 reopened.deliver(&envelope).await.unwrap();
3488 assert_eq!(
3489 reopened.inspect(&running.id).await.unwrap().pending
3490 + reopened.inspect(&running.id).await.unwrap().claimed,
3491 0
3492 );
3493 let mut restarted = store.load_session(&running.id).await.unwrap().unwrap();
3494 refresh_turn_boundary_with_inbox(
3495 &mut restarted,
3496 Some(&storage),
3497 Some(&persistence),
3498 Some(&reopened),
3499 )
3500 .await;
3501 assert_eq!(
3502 restarted
3503 .messages
3504 .iter()
3505 .filter(|message| message.id == envelope.id.as_str())
3506 .count(),
3507 1
3508 );
3509 }
3510
3511 #[tokio::test]
3512 async fn checkpoint_success_pre_ack_failure_recovers_cur_without_duplicate() {
3513 let (_temp, store, locked, real_inbox, mut running) =
3514 durable_inbox_fixture("pre-ack-failure").await;
3515 let mut envelope = SessionMessageEnvelope::user_input("pre-ack-failure", "recover claimed");
3516 envelope.id = SessionMessageId::parse("pre-ack-message").unwrap();
3517 deliver_interrupt_eligible(&real_inbox, &envelope).await;
3518 let faulted: Arc<dyn SessionInboxPort> = Arc::new(AckAfterPersistErrorInbox {
3519 inner: real_inbox.clone(),
3520 fail_before_once: std::sync::atomic::AtomicBool::new(true),
3521 fail_after_once: std::sync::atomic::AtomicBool::new(false),
3522 });
3523 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked.clone();
3524 let storage: Arc<dyn Storage> = store.clone();
3525 refresh_turn_boundary_with_inbox(
3526 &mut running,
3527 Some(&storage),
3528 Some(&persistence),
3529 Some(&faulted),
3530 )
3531 .await;
3532 let checkpointed = store.load_session(&running.id).await.unwrap().unwrap();
3533 assert_eq!(
3534 checkpointed
3535 .messages
3536 .iter()
3537 .filter(|message| message.id == envelope.id.as_str())
3538 .count(),
3539 1
3540 );
3541 assert_eq!(real_inbox.inspect(&running.id).await.unwrap().claimed, 1);
3542 assert!(!real_inbox
3543 .was_admitted(&running.id, &envelope.id)
3544 .await
3545 .unwrap());
3546
3547 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3548 store.clone(),
3549 bamboo_domain::SessionInboxLimits::default(),
3550 ));
3551 let mut restarted = checkpointed;
3552 refresh_turn_boundary_with_inbox(
3553 &mut restarted,
3554 Some(&storage),
3555 Some(&persistence),
3556 Some(&reopened),
3557 )
3558 .await;
3559 assert_eq!(
3560 restarted
3561 .messages
3562 .iter()
3563 .filter(|message| message.id == envelope.id.as_str())
3564 .count(),
3565 1
3566 );
3567 assert!(reopened
3568 .was_admitted(&running.id, &envelope.id)
3569 .await
3570 .unwrap());
3571 let backlog = reopened.inspect(&running.id).await.unwrap();
3572 assert_eq!(backlog.pending + backlog.claimed, 0);
3573 }
3574
3575 #[derive(Default)]
3576 struct AckBoundaryProvider(AtomicUsize);
3577
3578 #[async_trait::async_trait]
3579 impl bamboo_llm::LLMProvider for AckBoundaryProvider {
3580 async fn chat_stream(
3581 &self,
3582 _messages: &[Message],
3583 _tools: &[bamboo_agent_core::tools::ToolSchema],
3584 _max_output_tokens: Option<u32>,
3585 _model: &str,
3586 ) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
3587 self.0.fetch_add(1, Ordering::SeqCst);
3588 Ok(Box::pin(futures::stream::iter(vec![
3589 Ok(bamboo_llm::LLMChunk::Token("done".into())),
3590 Ok(bamboo_llm::LLMChunk::Done),
3591 ])))
3592 }
3593 }
3594
3595 async fn run_ack_boundary(
3596 session: &mut Session,
3597 storage: Arc<dyn Storage>,
3598 persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence>,
3599 inbox: Arc<dyn SessionInboxPort>,
3600 provider: Arc<AckBoundaryProvider>,
3601 ) -> (Result<(), AgentError>, Vec<bamboo_agent_core::AgentEvent>) {
3602 let (events, mut received) = tokio::sync::mpsc::channel(128);
3603 let result = crate::runtime::runner::run_agent_loop_with_config(
3604 session,
3605 "continue".into(),
3606 events,
3607 provider,
3608 Arc::new(bamboo_tools::BuiltinToolExecutorBuilder::new().build()),
3609 tokio_util::sync::CancellationToken::new(),
3610 crate::runtime::config::AgentLoopConfig {
3611 storage: Some(storage),
3612 persistence: Some(persistence),
3613 session_inbox: Some(inbox),
3614 system_prompt: Some("system".into()),
3615 model_name: Some("model".into()),
3616 run_budget: bamboo_config::RunBudgetConfig {
3617 max_rounds: Some(1),
3618 ..Default::default()
3619 },
3620 ..Default::default()
3621 },
3622 )
3623 .await;
3624 let mut observed = Vec::new();
3625 while let Ok(event) = received.try_recv() {
3626 observed.push(event);
3627 }
3628 (result, observed)
3629 }
3630
3631 #[tokio::test]
3632 async fn unresolved_ack_stops_shared_provider_round_and_cold_retry_deduplicates() {
3633 for permanent in [false, true] {
3634 for after_receipt in [false, true] {
3635 let id = format!("ack-stop-{permanent}-{after_receipt}");
3636 let (home, store, locked, real_inbox, mut running) =
3637 durable_inbox_fixture(&id).await;
3638 let envelope = SessionMessageEnvelope::user_input(&id, "durable input");
3639 deliver_interrupt_eligible(&real_inbox, &envelope).await;
3640 if permanent {
3641 let claim = real_inbox.claim(&id, 1).await.unwrap().remove(0);
3644 let path = store
3645 .bamboo_home_dir()
3646 .join(store.resolve_rel_path(&id).await.unwrap())
3647 .join("inbox/cur")
3648 .join(&claim.claim_id);
3649 let raw_claim = tokio::fs::read(&path).await.unwrap();
3650 running.add_message(envelope.to_provider_message().unwrap());
3651 running
3652 .session_inbox_admission_mut()
3653 .record(envelope.id.clone(), claim.generation);
3654 locked
3655 .checkpoint_runtime_session(&mut running)
3656 .await
3657 .unwrap();
3658 real_inbox.ack(&id, &claim).await.unwrap();
3659 tokio::fs::write(&path, raw_claim).await.unwrap();
3660 assert!(real_inbox.was_admitted(&id, &envelope.id).await.unwrap());
3661 }
3662 let faulted: Arc<dyn SessionInboxPort> = Arc::new(AckAfterPersistErrorInbox {
3663 inner: real_inbox.clone(),
3664 fail_before_once: std::sync::atomic::AtomicBool::new(!after_receipt),
3665 fail_after_once: std::sync::atomic::AtomicBool::new(after_receipt),
3666 });
3667 let provider = Arc::new(AckBoundaryProvider::default());
3668 let (result, events) = run_ack_boundary(
3669 &mut running,
3670 store.clone(),
3671 locked,
3672 faulted,
3673 provider.clone(),
3674 )
3675 .await;
3676 assert!(result
3677 .unwrap_err()
3678 .to_string()
3679 .contains("SessionInbox ACK unresolved"));
3680 assert_eq!(provider.0.load(Ordering::SeqCst), 0);
3681 assert_eq!(
3682 events
3683 .iter()
3684 .filter(|event| matches!(event,
3685 bamboo_agent_core::AgentEvent::MessageAppended { message_id, .. }
3686 if message_id == envelope.id.as_str()))
3687 .count(),
3688 usize::from(!permanent)
3689 );
3690 assert_eq!(
3691 real_inbox.was_admitted(&id, &envelope.id).await.unwrap(),
3692 permanent || after_receipt
3693 );
3694 assert_eq!(
3695 real_inbox.inspect(&id).await.unwrap().claimed,
3696 usize::from(!after_receipt)
3697 );
3698
3699 let reopened = Arc::new(
3700 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
3701 .await
3702 .unwrap(),
3703 );
3704 let mut retry = reopened.load_session(&id).await.unwrap().unwrap();
3705 assert_eq!(
3706 retry
3707 .messages
3708 .iter()
3709 .filter(|message| bamboo_domain::is_matching_session_message(
3710 message, &envelope
3711 ))
3712 .count(),
3713 1
3714 );
3715 assert!(retry
3716 .session_inbox_admission()
3717 .unwrap()
3718 .contains(&envelope.id));
3719 let storage: Arc<dyn Storage> = reopened.clone();
3720 let persistence =
3721 Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
3722 let inbox: Arc<dyn SessionInboxPort> =
3723 Arc::new(bamboo_storage::FileSessionInbox::new(
3724 reopened.clone(),
3725 bamboo_domain::SessionInboxLimits::default(),
3726 ));
3727 let (result, events) = run_ack_boundary(
3728 &mut retry,
3729 storage,
3730 persistence,
3731 inbox.clone(),
3732 provider.clone(),
3733 )
3734 .await;
3735 result.unwrap();
3736 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
3737 assert!(!events.iter().any(|event| matches!(event,
3738 bamboo_agent_core::AgentEvent::MessageAppended { message_id, .. }
3739 if message_id == envelope.id.as_str())));
3740 assert!(inbox.was_admitted(&id, &envelope.id).await.unwrap());
3741 let backlog = inbox.inspect(&id).await.unwrap();
3742 assert_eq!(backlog.pending + backlog.claimed, 0);
3743 let durable = reopened.load_session(&id).await.unwrap().unwrap();
3744 assert_eq!(
3745 durable
3746 .messages
3747 .iter()
3748 .filter(|message| bamboo_domain::is_matching_session_message(
3749 message, &envelope
3750 ))
3751 .count(),
3752 1
3753 );
3754 }
3755 }
3756 }
3757
3758 #[tokio::test]
3759 async fn legacy_migration_clears_matching_running_snapshot_without_resurrection() {
3760 let (_temp, store, locked, inbox, mut running) =
3761 durable_inbox_fixture("legacy-running-snapshot").await;
3762 let legacy = vec![serde_json::json!({
3763 "content": "migrate me once",
3764 "created_at": "2026-07-25T00:00:00Z"
3765 })];
3766 running.set_pending_injected_messages(legacy.clone());
3767 store.save_session(&running).await.unwrap();
3768
3769 let storage: Arc<dyn Storage> = store.clone();
3770 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3771 let refresh = refresh_turn_boundary_with_inbox(
3772 &mut running,
3773 Some(&storage),
3774 Some(&persistence),
3775 Some(&inbox),
3776 )
3777 .await;
3778 assert_eq!(refresh.merged, 1);
3779 assert!(!running.has_pending_injected_messages());
3780
3781 let message_id = SessionMessageId::legacy(&running.id, 0, &legacy[0]);
3782 let reloaded = store.load_session(&running.id).await.unwrap().unwrap();
3783 assert!(!reloaded.has_pending_injected_messages());
3784 assert_eq!(
3785 reloaded
3786 .messages
3787 .iter()
3788 .filter(|message| message.id == message_id.as_str())
3789 .count(),
3790 1
3791 );
3792
3793 let mut restarted = reloaded;
3794 let refresh = refresh_turn_boundary_with_inbox(
3795 &mut restarted,
3796 Some(&storage),
3797 Some(&persistence),
3798 Some(&inbox),
3799 )
3800 .await;
3801 assert_eq!(refresh.merged, 0);
3802 assert!(!restarted.has_pending_injected_messages());
3803 assert_eq!(
3804 restarted
3805 .messages
3806 .iter()
3807 .filter(|message| message.id == message_id.as_str())
3808 .count(),
3809 1
3810 );
3811 }
3812
3813 #[tokio::test]
3814 async fn legacy_migration_publishes_interrupt_activation_before_source_clear() {
3815 let (_temp, store, locked, inbox, mut running) =
3816 durable_inbox_fixture("legacy-mark-before-clear").await;
3817 let legacy = vec![serde_json::json!({
3818 "content": "survive clear crash",
3819 "nested": {"z": 1, "a": 2}
3820 })];
3821 let mut producer = store.load_session(&running.id).await.unwrap().unwrap();
3822 producer.set_pending_injected_messages(legacy.clone());
3823 store.save_session(&producer).await.unwrap();
3824
3825 let storage: Arc<dyn Storage> = store.clone();
3826 let fault = Arc::new(ActivationBeforeClearPersistence {
3827 inner: locked.clone(),
3828 inbox: inbox.clone(),
3829 fail_clear_once: std::sync::atomic::AtomicBool::new(true),
3830 clear_calls: AtomicUsize::new(0),
3831 });
3832 let fault_persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = fault.clone();
3833 let crashed = migrate_legacy_pending_only(
3834 &mut running,
3835 Some(&storage),
3836 Some(&fault_persistence),
3837 Some(&inbox),
3838 )
3839 .await;
3840 assert_eq!(crashed.delivered, 1);
3841 assert_eq!(crashed.highest_generation, Some(1));
3842 assert!(!crashed.source_cleared);
3843 assert_eq!(fault.clear_calls.load(Ordering::SeqCst), 1);
3844
3845 let after_crash = inbox.inspect(&running.id).await.unwrap();
3846 assert_eq!(after_crash.generation, 1);
3847 assert_eq!(after_crash.activation_generation, 1);
3848 assert_eq!(after_crash.interrupt_generation, 1);
3849 assert!(after_crash.activation_pending());
3850 assert!(store
3851 .load_session(&running.id)
3852 .await
3853 .unwrap()
3854 .unwrap()
3855 .has_pending_injected_messages());
3856
3857 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3860 store.clone(),
3861 bamboo_domain::SessionInboxLimits::default(),
3862 ));
3863 let normal: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
3864 let mut restarted = store.load_session(&running.id).await.unwrap().unwrap();
3865 let recovered = migrate_legacy_pending_only(
3866 &mut restarted,
3867 Some(&storage),
3868 Some(&normal),
3869 Some(&reopened),
3870 )
3871 .await;
3872 assert_eq!(recovered.delivered, 1);
3873 assert_eq!(recovered.highest_generation, Some(1));
3874 assert!(recovered.source_cleared);
3875 let backlog = reopened.inspect(&running.id).await.unwrap();
3876 assert_eq!(backlog.generation, 1);
3877 assert_eq!(backlog.activation_generation, 1);
3878 assert_eq!(backlog.interrupt_generation, 1);
3879 assert!(!store
3880 .load_session(&running.id)
3881 .await
3882 .unwrap()
3883 .unwrap()
3884 .has_pending_injected_messages());
3885 }
3886
3887 #[tokio::test]
3888 async fn legacy_activation_publish_failure_never_invokes_source_clear() {
3889 let (_temp, store, locked, inbox, mut running) =
3890 durable_inbox_fixture("legacy-mark-failure").await;
3891 let legacy = vec![serde_json::json!({"content": "do not clear"})];
3892 let mut producer = store.load_session(&running.id).await.unwrap().unwrap();
3893 producer.set_pending_injected_messages(legacy);
3894 store.save_session(&producer).await.unwrap();
3895 let storage: Arc<dyn Storage> = store.clone();
3896 let failed_inbox = Arc::new(FailActivationInbox {
3897 inner: inbox.clone(),
3898 marks: AtomicUsize::new(0),
3899 });
3900 let failed_port: Arc<dyn SessionInboxPort> = failed_inbox.clone();
3901 let clear_probe = Arc::new(ActivationBeforeClearPersistence {
3902 inner: locked,
3903 inbox: inbox.clone(),
3904 fail_clear_once: std::sync::atomic::AtomicBool::new(false),
3905 clear_calls: AtomicUsize::new(0),
3906 });
3907 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = clear_probe.clone();
3908
3909 let migration = migrate_legacy_pending_only(
3910 &mut running,
3911 Some(&storage),
3912 Some(&persistence),
3913 Some(&failed_port),
3914 )
3915 .await;
3916 assert_eq!(migration.delivered, 1);
3917 assert_eq!(migration.highest_generation, None);
3918 assert!(!migration.source_cleared);
3919 assert_eq!(failed_inbox.marks.load(Ordering::SeqCst), 1);
3920 assert_eq!(
3921 clear_probe.clear_calls.load(Ordering::SeqCst),
3922 0,
3923 "source CAS-clear must be unreachable until activation publish succeeds"
3924 );
3925 let backlog = inbox.inspect(&running.id).await.unwrap();
3926 assert_eq!(backlog.generation, 1);
3927 assert_eq!(backlog.activation_generation, 0);
3928 assert!(store
3929 .load_session(&running.id)
3930 .await
3931 .unwrap()
3932 .unwrap()
3933 .has_pending_injected_messages());
3934 }
3935
3936 #[tokio::test]
3937 async fn partial_legacy_migration_authorizes_prefix_and_never_clears_source() {
3938 let (_temp, store, locked, inbox, mut running) =
3939 durable_inbox_fixture("legacy-partial-prefix").await;
3940 let legacy = vec![
3941 serde_json::json!({"content": "first"}),
3942 serde_json::json!({"content": "second"}),
3943 ];
3944 let mut producer = store.load_session(&running.id).await.unwrap().unwrap();
3945 producer.set_pending_injected_messages(legacy.clone());
3946 store.save_session(&producer).await.unwrap();
3947 let storage: Arc<dyn Storage> = store.clone();
3948 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked.clone();
3949 let faulted: Arc<dyn SessionInboxPort> = Arc::new(FailSecondDeliveryInbox {
3950 inner: inbox.clone(),
3951 deliveries: AtomicUsize::new(0),
3952 });
3953
3954 let partial = migrate_legacy_pending_only(
3955 &mut running,
3956 Some(&storage),
3957 Some(&persistence),
3958 Some(&faulted),
3959 )
3960 .await;
3961 assert_eq!(partial.delivered, 1);
3962 assert_eq!(partial.highest_generation, Some(1));
3963 assert!(!partial.source_cleared);
3964 let prefix = inbox.inspect(&running.id).await.unwrap();
3965 assert_eq!(prefix.generation, 1);
3966 assert_eq!(prefix.activation_generation, 1);
3967 assert_eq!(prefix.interrupt_generation, 1);
3968 assert!(prefix.activation_pending());
3969 assert!(store
3970 .load_session(&running.id)
3971 .await
3972 .unwrap()
3973 .unwrap()
3974 .has_pending_injected_messages());
3975
3976 let mut restarted = store.load_session(&running.id).await.unwrap().unwrap();
3979 let complete = migrate_legacy_pending_only(
3980 &mut restarted,
3981 Some(&storage),
3982 Some(&persistence),
3983 Some(&inbox),
3984 )
3985 .await;
3986 assert_eq!(complete.delivered, 2);
3987 assert_eq!(complete.highest_generation, Some(2));
3988 assert!(complete.source_cleared);
3989 let backlog = inbox.inspect(&running.id).await.unwrap();
3990 assert_eq!(backlog.generation, 2);
3991 assert_eq!(backlog.activation_generation, 2);
3992 assert_eq!(backlog.interrupt_generation, 2);
3993 assert_eq!(backlog.pending, 2);
3994 assert!(!store
3995 .load_session(&running.id)
3996 .await
3997 .unwrap()
3998 .unwrap()
3999 .has_pending_injected_messages());
4000 }
4001
4002 #[tokio::test]
4003 async fn task_end_guidance_survives_later_round_admission_and_launches_one_successor() {
4004 let (_temp, store, locked, inbox, mut running) =
4005 durable_inbox_fixture("guidance-modes").await;
4006 let storage: Arc<dyn Storage> = store;
4007 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
4008 let router = crate::SessionActivationRouter::new();
4009 router.set_inbox(inbox.clone());
4010 let reservations = Arc::new(AtomicUsize::new(0));
4011 let launches = Arc::new(AtomicUsize::new(0));
4012 router
4013 .set_spawner(Arc::new(BacklogActivationSpawner {
4014 inbox: inbox.clone(),
4015 reservations: reservations.clone(),
4016 launches: launches.clone(),
4017 }))
4018 .await;
4019 let mut registration = router.register_run(&running.id, "run-a").await.unwrap();
4020 let mut deferred = SessionMessageEnvelope::user_input(&running.id, "task end");
4021 deferred.correlation_id = Some("session-guidance-after-run:run-a".into());
4022 let mut immediate = SessionMessageEnvelope::user_input(&running.id, "round end");
4023 immediate.correlation_id = Some("session-guidance".into());
4024 for envelope in [&deferred, &immediate] {
4025 let receipt = deliver_interrupt_eligible(&inbox, envelope).await;
4026 router
4027 .request_activation(&running.id, receipt.generation)
4028 .await
4029 .unwrap();
4030 }
4031 let result = refresh_turn_boundary_with_inbox_for_run(
4032 &mut running,
4033 Some(&storage),
4034 Some(&persistence),
4035 Some(&inbox),
4036 Some("run-a"),
4037 )
4038 .await;
4039 assert_eq!(result.merged, 1);
4040 assert_eq!(result.committed_messages.len(), 1);
4041 assert_eq!(result.committed_messages[0].id, immediate.id.as_str());
4042 assert!(running
4043 .messages
4044 .iter()
4045 .any(|message| message.id == immediate.id.as_str()));
4046 assert!(!running
4047 .messages
4048 .iter()
4049 .any(|message| message.id == deferred.id.as_str()));
4050 let cursor = running
4051 .session_inbox_admission()
4052 .unwrap()
4053 .last_admitted_sequence;
4054 assert_eq!(cursor, 2);
4055 assert_eq!(inbox.pending_guidance(&running.id).await.unwrap().len(), 1);
4056 registration.begin_finalization().await;
4057 assert_eq!(
4058 registration.finish(cursor).await.unwrap(),
4059 Some(SessionActivationDisposition::ActivationReserved)
4060 );
4061 assert_eq!(launches.load(Ordering::SeqCst), 1);
4062 let mut next = router
4063 .register_run(&running.id, "successor-1")
4064 .await
4065 .unwrap();
4066 let result = refresh_turn_boundary_with_inbox_for_run(
4067 &mut running,
4068 Some(&storage),
4069 Some(&persistence),
4070 Some(&inbox),
4071 Some("successor-1"),
4072 )
4073 .await;
4074 assert_eq!(result.merged, 1);
4075 assert_eq!(result.committed_messages.len(), 1);
4076 assert_eq!(result.committed_messages[0].id, deferred.id.as_str());
4077 assert_eq!(
4078 running
4079 .messages
4080 .iter()
4081 .filter(|message| message.id == deferred.id.as_str())
4082 .count(),
4083 1
4084 );
4085 next.begin_finalization().await;
4086 assert_eq!(next.finish(cursor).await.unwrap(), None);
4087 assert_eq!(reservations.load(Ordering::SeqCst), 1);
4088 }
4089
4090 #[tokio::test]
4091 async fn terminal_typed_delivery_stays_pending_for_exactly_one_successor_turn() {
4092 let (_temp, store, locked, inbox, mut first_run) =
4093 durable_inbox_fixture("terminal-typed-race").await;
4094 let storage: Arc<dyn Storage> = store.clone();
4095 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
4096 let router = crate::SessionActivationRouter::new();
4097 let reservations = Arc::new(AtomicUsize::new(0));
4098 let launches = Arc::new(AtomicUsize::new(0));
4099 router
4100 .set_spawner(Arc::new(BacklogActivationSpawner {
4101 inbox: inbox.clone(),
4102 reservations: reservations.clone(),
4103 launches: launches.clone(),
4104 }))
4105 .await;
4106 let mut registration = router
4107 .register_run(&first_run.id, "first-run")
4108 .await
4109 .unwrap();
4110
4111 let mut envelope = SessionMessageEnvelope::user_input(&first_run.id, "after final round");
4114 envelope.id = SessionMessageId::parse("terminal-race-message").unwrap();
4115 let receipt = deliver_interrupt_eligible(&inbox, &envelope).await;
4116 assert_eq!(
4117 router
4118 .request_activation(&first_run.id, receipt.generation)
4119 .await
4120 .unwrap(),
4121 SessionActivationDisposition::ActiveNotified
4122 );
4123 assert_eq!(inbox.inspect(&first_run.id).await.unwrap().pending, 1);
4124 assert!(!first_run
4125 .messages
4126 .iter()
4127 .any(|message| message.id == envelope.id.as_str()));
4128
4129 registration.begin_finalization().await;
4130 assert_eq!(
4131 registration.finish(0).await.unwrap(),
4132 Some(SessionActivationDisposition::ActivationReserved)
4133 );
4134 assert_eq!(reservations.load(Ordering::SeqCst), 1);
4135 assert_eq!(launches.load(Ordering::SeqCst), 1);
4136 assert_eq!(inbox.inspect(&first_run.id).await.unwrap().pending, 1);
4137
4138 let refresh = refresh_turn_boundary_with_inbox(
4141 &mut first_run,
4142 Some(&storage),
4143 Some(&persistence),
4144 Some(&inbox),
4145 )
4146 .await;
4147 assert_eq!(refresh.merged, 1);
4148 assert_eq!(
4149 first_run
4150 .messages
4151 .iter()
4152 .filter(|message| message.id == envelope.id.as_str())
4153 .count(),
4154 1
4155 );
4156 let backlog = inbox.inspect(&first_run.id).await.unwrap();
4157 assert_eq!(backlog.pending + backlog.claimed, 0);
4158 }
4159
4160 #[tokio::test]
4161 async fn terminal_legacy_migration_enqueues_without_ack_and_hands_off_generation() {
4162 let (_temp, store, locked, inbox, mut first_run) =
4163 durable_inbox_fixture("terminal-legacy-race").await;
4164 let legacy = vec![serde_json::json!({
4165 "content": "legacy after final round",
4166 "created_at": "2026-07-25T00:00:00Z"
4167 })];
4168 let mut producer_snapshot = store.load_session(&first_run.id).await.unwrap().unwrap();
4169 producer_snapshot.set_pending_injected_messages(legacy.clone());
4170 store.save_session(&producer_snapshot).await.unwrap();
4171
4172 let storage: Arc<dyn Storage> = store.clone();
4173 let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> = locked;
4174 let migration = migrate_legacy_pending_only(
4175 &mut first_run,
4176 Some(&storage),
4177 Some(&persistence),
4178 Some(&inbox),
4179 )
4180 .await;
4181 assert_eq!(migration.delivered, 1);
4182 assert!(migration.source_cleared);
4183 let generation = migration.highest_generation.expect("durable generation");
4184 let message_id = SessionMessageId::legacy(&first_run.id, 0, &legacy[0]);
4185 assert_eq!(inbox.inspect(&first_run.id).await.unwrap().pending, 1);
4186 assert!(!inbox
4187 .was_admitted(&first_run.id, &message_id)
4188 .await
4189 .unwrap());
4190 assert!(!store
4191 .load_session(&first_run.id)
4192 .await
4193 .unwrap()
4194 .unwrap()
4195 .has_pending_injected_messages());
4196
4197 let router = crate::SessionActivationRouter::new();
4198 let reservations = Arc::new(AtomicUsize::new(0));
4199 let launches = Arc::new(AtomicUsize::new(0));
4200 router
4201 .set_spawner(Arc::new(BacklogActivationSpawner {
4202 inbox: inbox.clone(),
4203 reservations: reservations.clone(),
4204 launches: launches.clone(),
4205 }))
4206 .await;
4207 let mut registration = router
4208 .register_run(&first_run.id, "first-run")
4209 .await
4210 .unwrap();
4211 assert_eq!(
4212 router
4213 .request_activation(&first_run.id, generation)
4214 .await
4215 .unwrap(),
4216 SessionActivationDisposition::ActiveNotified
4217 );
4218 registration.begin_finalization().await;
4219 assert_eq!(
4220 registration.finish(0).await.unwrap(),
4221 Some(SessionActivationDisposition::ActivationReserved)
4222 );
4223 assert_eq!(reservations.load(Ordering::SeqCst), 1);
4224 assert_eq!(launches.load(Ordering::SeqCst), 1);
4225
4226 let refresh = refresh_turn_boundary_with_inbox(
4227 &mut first_run,
4228 Some(&storage),
4229 Some(&persistence),
4230 Some(&inbox),
4231 )
4232 .await;
4233 assert_eq!(refresh.merged, 1);
4234 assert_eq!(
4235 first_run
4236 .messages
4237 .iter()
4238 .filter(|message| message.id == message_id.as_str())
4239 .count(),
4240 1
4241 );
4242 let backlog = inbox.inspect(&first_run.id).await.unwrap();
4243 assert_eq!(backlog.pending + backlog.claimed, 0);
4244 }
4245}
4246
4247#[cfg(test)]
4248mod input_request_projection_tests {
4249 use super::*;
4250 use crate::runtime::managers::lifecycle::{
4251 BoundedInputRequestBatch,
4252 InputRequestProjection::{Available, Unavailable},
4253 InputRequestUnavailable,
4254 };
4255 use bamboo_domain::{SessionSkillRequest, SessionSkillSelection};
4256
4257 fn request() -> SessionSkillRequest {
4258 SessionSkillRequest {
4259 selections: vec![SessionSkillSelection {
4260 id: "skill".into(),
4261 source: "user".into(),
4262 revision: u64::MAX,
4263 args: serde_json::json!({"n": u64::MAX, "a": [1, 1.25, null]}),
4264 }],
4265 mode: None,
4266 }
4267 }
4268 fn record(request: Option<&SessionSkillRequest>) -> BorrowedInputRequestRecord<'_> {
4269 BorrowedInputRequestRecord {
4270 input_id: "u",
4271 source: &SessionMessageSource::User,
4272 kind: SessionMessageKind::UserInput,
4273 wrapper: None,
4274 created_at: chrono::DateTime::from_timestamp(1, 0).unwrap(),
4275 request,
4276 }
4277 }
4278 fn available(
4279 session: &str,
4280 execution: &str,
4281 records: &[BorrowedInputRequestRecord<'_>],
4282 ) -> BoundedInputRequestBatch {
4283 match project_input_request_batch(session, execution, records) {
4284 Available(batch) => batch,
4285 Unavailable(error) => panic!("unexpected bounded diagnostic {error:?}"),
4286 }
4287 }
4288
4289 #[test]
4290 fn input_request_projection_charges_exact_private_framing_escaping_and_null() {
4291 let mut row = record(None);
4292 row.input_id = "u\"\t界";
4293 let rows = [row];
4294 let expected = r#"{"session_id":"s\n界","execution_id":"e\"","records":[["u\"\t界",{"type":"user"},"user_input",null,"1970-01-01T00:00:01Z",null]]}"#;
4295 let encoded = serde_json::to_vec(&InputRequestMeasurement {
4296 session_id: "s\n界",
4297 execution_id: "e\"",
4298 records: InputRequestRecordsMeasurement(&rows),
4299 })
4300 .unwrap();
4301 assert_eq!(encoded, expected.as_bytes());
4302 let batch = available("s\n界", "e\"", &rows);
4303 assert_eq!(batch.compact_bytes(), expected.len());
4304 assert_eq!(batch.session_id(), "s\n界");
4305 assert_eq!(batch.execution_id(), "e\"");
4306 assert_eq!(batch.records()[0].input_id, row.input_id);
4307 assert_eq!(batch.records()[0].request, None);
4308 let mut data = request();
4309 let without_mode = available("s", "e", &[record(Some(&data))]).compact_bytes();
4310 data.mode = Some("review".into());
4311 assert_eq!(
4312 available("s", "e", &[record(Some(&data))]).compact_bytes(),
4313 without_mode + r#","mode":"review""#.len()
4314 );
4315 }
4316
4317 #[test]
4318 fn input_request_projection_accepts_exact_whole_limit_then_rejects_one_byte() {
4319 let mut data = SessionSkillRequest {
4320 selections: (0..32)
4321 .map(|i| SessionSkillSelection {
4322 id: format!("{i:x}"),
4323 source: "user".into(),
4324 revision: 1,
4325 args: serde_json::Value::String(String::new()),
4326 })
4327 .collect(),
4328 mode: None,
4329 };
4330 let baseline = serde_json::to_vec(&InputRequestMeasurement {
4331 session_id: "s",
4332 execution_id: "e",
4333 records: InputRequestRecordsMeasurement(&[record(Some(&data))]),
4334 })
4335 .unwrap()
4336 .len();
4337 let padding = 262144 - baseline;
4338 for (i, selection) in data.selections.iter_mut().enumerate() {
4339 selection.args =
4340 serde_json::Value::String("x".repeat(padding / 32 + usize::from(i < padding % 32)));
4341 }
4342 data.validate().unwrap();
4343 let batch = available("s", "e", &[record(Some(&data))]);
4344 assert_eq!(batch.compact_bytes(), 262144);
4345 assert_eq!(batch.records()[0].request.as_ref().unwrap(), &data);
4346 if let serde_json::Value::String(value) = &mut data.selections[0].args {
4347 value.push('x');
4348 }
4349 data.validate().unwrap(); assert_eq!(
4351 project_input_request_batch("s", "e", &[record(Some(&data))]),
4352 Unavailable(InputRequestUnavailable::CompactLimit { limit: 262144 })
4353 );
4354 let rows = [record(Some(&data)), record(Some(&data))];
4355 assert!(matches!(
4356 project_input_request_batch("s", "e", &rows),
4357 Unavailable(InputRequestUnavailable::CompactLimit { .. })
4358 ));
4359 }
4360
4361 #[test]
4362 fn input_request_projection_preserves_order_and_charges_every_none_record() {
4363 let ids: Vec<_> = (0..129).map(|i| format!("u-{i}")).collect();
4364 let rows: Vec<_> = ids
4365 .iter()
4366 .map(|id| BorrowedInputRequestRecord {
4367 input_id: id,
4368 ..record(None)
4369 })
4370 .collect();
4371 let batch = available("s", "e", &rows[..128]);
4372 assert_eq!(batch.records().len(), 128);
4373 assert_eq!(
4374 batch
4375 .records()
4376 .iter()
4377 .map(|r| &r.input_id)
4378 .collect::<Vec<_>>(),
4379 ids[..128].iter().collect::<Vec<_>>()
4380 );
4381 assert!(batch.records().iter().all(|row| row.request.is_none()));
4382 let one = available("s", "e", &rows[..1]);
4383 assert!(batch.compact_bytes() > one.compact_bytes() * 50);
4384 assert_eq!(
4385 project_input_request_batch("s", "e", &rows),
4386 Unavailable(InputRequestUnavailable::RecordLimit {
4387 count: 129,
4388 limit: 128
4389 })
4390 );
4391 let empty = available("s", "e", &[]);
4392 assert!(empty.records().is_empty());
4393 assert_eq!(
4394 empty.compact_bytes(),
4395 r#"{"session_id":"s","execution_id":"e","records":[]}"#.len()
4396 );
4397 }
4398
4399 #[test]
4400 fn input_request_projection_rejects_original_invalid_data_without_partial_output() {
4401 for id in ["", " ", " u", "u ", "a/b", "a\\b", "a..b", &"界".repeat(86)] {
4402 let rows = [
4403 record(None),
4404 BorrowedInputRequestRecord {
4405 input_id: id,
4406 ..record(None)
4407 },
4408 ];
4409 assert_eq!(
4410 project_input_request_batch("s", "e", &rows),
4411 Unavailable(InputRequestUnavailable::InvalidData)
4412 );
4413 }
4414 let canonical = format!("{}\t界", "x".repeat(252));
4415 assert_eq!(canonical.len(), 256);
4416 assert_eq!(
4417 available(
4418 "s",
4419 "e",
4420 &[BorrowedInputRequestRecord {
4421 input_id: &canonical,
4422 ..record(None)
4423 }]
4424 )
4425 .records()[0]
4426 .input_id,
4427 canonical
4428 );
4429 let mut data = request();
4430 for depth in [64, 65, 256] {
4431 data.selections[0].args = (0..depth).fold(serde_json::Value::Null, |value, _| {
4432 serde_json::Value::Array(vec![value])
4433 });
4434 let result =
4435 project_input_request_batch("s", "e", &[record(None), record(Some(&data))]);
4436 assert_eq!(matches!(result, Available(_)), depth == 64);
4437 if depth != 64 {
4438 assert_eq!(result, Unavailable(InputRequestUnavailable::InvalidData));
4439 }
4440 }
4441 data.selections[0].args = serde_json::Value::Array(vec![serde_json::Value::Null; 8192]);
4442 assert_eq!(
4443 project_input_request_batch("s", "e", &[record(Some(&data))]),
4444 Unavailable(InputRequestUnavailable::InvalidData)
4445 );
4446 data.selections[0].args = serde_json::Value::Null;
4447 for mode in ["", "UPPER", "a_b", &"x".repeat(65)] {
4448 data.mode = Some(mode.into());
4449 assert_eq!(
4450 project_input_request_batch("s", "e", &[record(Some(&data))]),
4451 Unavailable(InputRequestUnavailable::InvalidData)
4452 );
4453 }
4454 data.mode = Some("x".repeat(64));
4455 available("s", "e", &[record(Some(&data))]);
4456 data.selections.clear();
4457 assert_eq!(
4458 project_input_request_batch("s", "e", &[record(Some(&data))]),
4459 Unavailable(InputRequestUnavailable::InvalidData)
4460 );
4461 }
4462
4463 #[test]
4464 fn input_request_projection_normalizes_nested_capacities_and_outlives_original_data() {
4465 fn excess(value: &str) -> String {
4466 let mut s = String::with_capacity(100000);
4467 s.push_str(value);
4468 s
4469 }
4470 fn nodes(value: &serde_json::Value) -> usize {
4471 1 + match value {
4472 serde_json::Value::Array(v) => v.iter().map(nodes).sum(),
4473 serde_json::Value::Object(v) => v.values().map(nodes).sum(),
4474 _ => 0,
4475 }
4476 }
4477 let mut data = request();
4478 data.mode = Some(excess("review"));
4479 let mut array = Vec::with_capacity(100000);
4480 array.push(serde_json::Value::String(excess("界\"\n")));
4481 let mut object = serde_json::Map::new();
4482 object.insert(excess("key"), serde_json::Value::String(excess("value")));
4483 array.push(serde_json::Value::Object(object));
4484 data.selections[0].args = serde_json::Value::Array(array);
4485 data.selections[0].id = excess("skill");
4486 data.selections[0].source = excess("user");
4487 data.selections.reserve(100000);
4488 let source = SessionMessageSource::Runtime {
4489 subsystem: excess("chat"),
4490 };
4491 let input_id = excess("u");
4492 let wrapper = excess("root_chat_turn_v1");
4493 let session = excess("s");
4494 let execution = excess("e");
4495 let batch = available(
4496 &session,
4497 &execution,
4498 &[BorrowedInputRequestRecord {
4499 input_id: &input_id,
4500 source: &source,
4501 kind: SessionMessageKind::RuntimeInstruction,
4502 wrapper: Some(&wrapper),
4503 ..record(Some(&data))
4504 }],
4505 );
4506 let row = &batch.records()[0];
4507 assert_eq!(row.request.as_ref().unwrap(), &data);
4508 assert_eq!(
4509 row.created_at,
4510 chrono::DateTime::from_timestamp(1, 0).unwrap()
4511 );
4512 assert_eq!(row.kind, SessionMessageKind::RuntimeInstruction);
4513 assert_eq!(row.wrapper.as_deref(), Some("root_chat_turn_v1"));
4514 assert_eq!(batch.session_id.capacity(), batch.session_id.len());
4515 assert_eq!(batch.execution_id.capacity(), batch.execution_id.len());
4516 assert_eq!(row.input_id.capacity(), row.input_id.len());
4517 assert_eq!(row.wrapper.as_ref().unwrap().capacity(), wrapper.len());
4518 let SessionMessageSource::Runtime { subsystem } = &row.source else {
4519 panic!()
4520 };
4521 assert_eq!(subsystem.capacity(), subsystem.len());
4522 let copied = row.request.as_ref().unwrap();
4523 assert_eq!(copied.selections.capacity(), copied.selections.len());
4524 assert_eq!(copied.mode.as_ref().unwrap().capacity(), 6);
4525 let selection = &copied.selections[0];
4526 assert_eq!(selection.id.capacity(), selection.id.len());
4527 assert_eq!(selection.source.capacity(), selection.source.len());
4528 let values = selection.args.as_array().unwrap();
4529 assert_eq!(values.capacity(), values.len());
4530 let serde_json::Value::String(s) = &values[0] else {
4531 panic!()
4532 };
4533 assert_eq!(s.capacity(), s.len());
4534 let (key, value) = values[1].as_object().unwrap().iter().next().unwrap();
4535 assert_eq!(key.capacity(), key.len());
4536 let serde_json::Value::String(s) = value else {
4537 panic!()
4538 };
4539 assert_eq!(s.capacity(), s.len());
4540 assert!(nodes(&selection.args) <= batch.compact_bytes());
4541 drop((data, source, input_id, wrapper, session, execution));
4542 assert_eq!(
4543 batch.records()[0].request.as_ref().unwrap().selections[0].revision,
4544 u64::MAX
4545 );
4546 let scalar = request();
4547 assert_eq!(
4548 available("s", "e", &[record(Some(&scalar))]).records()[0]
4549 .request
4550 .as_ref(),
4551 Some(&scalar)
4552 );
4553 }
4554 #[test]
4555 fn input_request_projection_request_golden_charges_keys_numbers_mode_and_provenance() {
4556 let data = SessionSkillRequest {
4557 mode: Some("review".into()),
4558 selections: vec![SessionSkillSelection {
4559 id: "skill".into(),
4560 source: "plugin".into(),
4561 revision: u64::MAX,
4562 args: serde_json::json!({"n": u64::MAX, "q": "界\"\n"}),
4563 }],
4564 };
4565 let source = SessionMessageSource::Runtime {
4566 subsystem: "chat".into(),
4567 };
4568 let rows = [BorrowedInputRequestRecord {
4569 source: &source,
4570 kind: SessionMessageKind::RuntimeInstruction,
4571 wrapper: Some("root_chat_turn_v1"),
4572 created_at: chrono::DateTime::from_timestamp(1, 123456789).unwrap(),
4573 ..record(Some(&data))
4574 }];
4575 let expected = r#"{"session_id":"s","execution_id":"e","records":[["u",{"type":"runtime","subsystem":"chat"},"runtime_instruction","root_chat_turn_v1","1970-01-01T00:00:01.123456789Z",{"selections":[{"id":"skill","source":"plugin","revision":18446744073709551615,"args":{"n":18446744073709551615,"q":"界\"\n"}}],"mode":"review"}]]}"#;
4576 assert_eq!(
4577 serde_json::to_vec(&InputRequestMeasurement {
4578 session_id: "s",
4579 execution_id: "e",
4580 records: InputRequestRecordsMeasurement(&rows)
4581 })
4582 .unwrap(),
4583 expected.as_bytes()
4584 );
4585 let batch = available("s", "e", &rows);
4586 assert_eq!(batch.compact_bytes(), expected.len());
4587 assert_eq!(batch.records()[0].request.as_ref(), Some(&data));
4588 assert_eq!(batch.records()[0].created_at, rows[0].created_at);
4589 }
4590
4591 #[test]
4592 fn input_request_projection_reuses_all_original_selection_validation() {
4593 let mut invalid = Vec::new();
4594 for id in ["", " skill", "skill ", "bad\n", &"x".repeat(257)] {
4595 let mut value = request();
4596 value.selections[0].id = id.into();
4597 invalid.push(value);
4598 }
4599 for source in ["", "USER", "user ", "unknown"] {
4600 let mut value = request();
4601 value.selections[0].source = source.into();
4602 invalid.push(value);
4603 }
4604 let mut zero = request();
4605 zero.selections[0].revision = 0;
4606 invalid.push(zero);
4607 let mut duplicate = request();
4608 duplicate.selections.push(duplicate.selections[0].clone());
4609 duplicate.selections[1].source = "builtin".into();
4610 invalid.push(duplicate);
4611 let mut excessive = request();
4612 excessive.selections = (0..33)
4613 .map(|i| SessionSkillSelection {
4614 id: format!("s{i}"),
4615 ..request().selections.remove(0)
4616 })
4617 .collect();
4618 invalid.push(excessive);
4619 for value in invalid {
4620 assert_eq!(
4621 project_input_request_batch("s", "e", &[record(None), record(Some(&value))]),
4622 Unavailable(InputRequestUnavailable::InvalidData)
4623 );
4624 }
4625 }
4626
4627 #[test]
4628 fn input_request_projection_retains_every_untrusted_source_and_kind_exactly() {
4629 let mut identity = String::with_capacity(100000);
4630 identity.push_str(" peer/\n界");
4631 let sources = [
4632 SessionMessageSource::User,
4633 SessionMessageSource::Session {
4634 session_id: identity,
4635 },
4636 SessionMessageSource::Runtime {
4637 subsystem: "arbitrary".into(),
4638 },
4639 ];
4640 for source in &sources {
4641 for kind in [
4642 SessionMessageKind::UserInput,
4643 SessionMessageKind::PeerMessage,
4644 SessionMessageKind::ChildOutcome,
4645 SessionMessageKind::RuntimeInstruction,
4646 ] {
4647 let row = BorrowedInputRequestRecord {
4648 source,
4649 kind,
4650 ..record(None)
4651 };
4652 let batch = available("s", "e", &[row]);
4653 assert_eq!(&batch.records()[0].source, source);
4654 assert_eq!(batch.records()[0].kind, kind); if let SessionMessageSource::Session { session_id } = &batch.records()[0].source {
4656 assert_eq!(session_id.capacity(), session_id.len());
4657 assert_eq!(session_id, " peer/\n界");
4658 }
4659 }
4660 }
4661 }
4662}