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