Skip to main content

bamboo_engine/runtime/runner/
state_bridge.rs

1//! Bridge between structured [`AgentRuntimeState`] and session metadata.
2//!
3//! The read path prefers the structured `session.agent_runtime_state` field
4//! and falls back to the legacy `session.metadata["agent.runtime.state"]` key.
5//! The write path writes only to the structured field; the legacy metadata
6//! mirror is no longer maintained.
7
8use 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/// Original scalar data borrowed without Message, image or Source retention.
23#[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// Private deterministic measurement, never a wire/persistence format or decoder.
34#[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
59/// Validate all borrowed data and charge the whole batch before any owned copy.
60/// Success does not classify input as New, authorize it or publish anything.
61pub 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// Borrow the canonical request representation, including absent/default fields.
177// Serde counts the same keys/scalars as Q-B without copying any args tree.
178#[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
276// Private caller precondition: ONLY this completed boundary's New/ACKed prefix.
277// Never call on transcript history or use this projection as a provenance test.
278fn 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
404// Called only after accepted I-W depth64/node8192/byte validation and full fit.
405// Copy lengths, including nested strings/keys/arrays, never source capacities.
406fn 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
447/// Publish a genuinely new SDK Root through the Storage creation contract
448/// before the first provider round. Storage must reject an old snapshot from a
449/// deleted lifetime; a subsequent strict read binds the running snapshot to
450/// the durable authority. Server-created Roots are already present here.
451pub 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
490/// Recheck a Root before constructing its next provider catalog. Tool-boundary
491/// checks protect execution, while this bounded control-plane read also removes
492/// tools that were revoked after the prior round's last call. A Supervisor uses
493/// its existing strict management-proof read, not the ordinary Root path.
494pub 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/// Read `AgentRuntimeState` from session.
523///
524/// Tries the structured field first, falls back to the metadata key.
525#[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
535/// Write `AgentRuntimeState` to the structured session field.
536///
537/// Only writes to `session.agent_runtime_state`. The legacy metadata mirror
538/// was removed after the migration completed.
539pub fn write_runtime_state(session: &mut Session, state: &AgentRuntimeState) {
540    session.agent_runtime_state = Some(state.clone());
541}
542
543/// Sync runtime state fields from existing metadata keys.
544///
545/// This extracts values that are currently stored as individual metadata
546/// entries into the structured runtime state.
547pub fn sync_from_metadata(session: &Session, state: &mut AgentRuntimeState) {
548    // LLM info
549    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    // Prompt info
563    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/// Result of a turn-boundary disk refresh: how many injected messages were
590/// merged, which newly appended messages have a durable transcript checkpoint,
591/// and the live per-session permission mode as it stands on disk (the
592/// authoritative writer is `PATCH /sessions`).
593#[derive(Debug, Default, Clone)]
594pub struct TurnBoundaryRefresh {
595    pub merged: usize,
596    /// Newly appended messages whose transcript checkpoint completed at this
597    /// boundary. Callers may publish these only after this function returns;
598    /// recovery of an already-committed message is intentionally excluded.
599    pub committed_messages: Vec<Message>,
600    /// An unresolved ACK stops this activation even if its transcript and
601    /// permanent receipt are already durable. Neither is proof that ACK returned
602    /// successfully; the next safe boundary can retry existing recovery.
603    pub admission_error: Option<String>,
604    /// `None` when there was no storage / no on-disk session to read.
605    pub disk_permission_mode: Option<bamboo_domain::SessionPermissionMode>,
606}
607
608/// Refresh the authoritative permission posture and Root tool authority at a
609/// tool-dispatch safe boundary.
610///
611/// Unlike [`refresh_turn_boundary_with_inbox`], this deliberately never merges
612/// messages, SessionInbox claims, or any other control-plane field. Sequential
613/// tool calls may have externally visible side effects, so a configured durable
614/// store must be readable before the next call is admitted: keeping an old Auto
615/// posture after an Auto -> Default transition would execute without the newly
616/// required approval. SDK/in-memory runtimes with no store retain their current
617/// posture.
618pub 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    // Commit the coherent pair only after every fallible validation above. A
676    // failed refresh leaves the owned snapshot untouched and dispatch never
677    // enters the executor.
678    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/// Result of migrating only the rolling-upgrade legacy ingress queue.
691///
692/// Terminal execution paths use this without claiming the typed inbox: every
693/// returned generation still needs a successor reasoning turn.
694#[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/// Merge any queued follow-up messages that were injected via `send_message`
702/// while a session is running.
703///
704/// Thin wrapper over [`refresh_turn_boundary_from_disk`] kept for callers that
705/// only need the merge count. Returns the number of messages merged.
706#[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            // Preserve the complete legacy value for audit/replay even when it
738            // contained fields the old bridge ignored.
739            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                // The legacy source is the only restart signal until its
778                // coordinated CAS-clear. Authorize every durable prefix before
779                // that source can be removed, so a crash after clear can never
780                // leave a pending typed message behind an activation watermark
781                // of zero. Marking does not itself start another loop: the
782                // live caller claims below, while terminal callers hand the
783                // generation to the activation router.
784                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                // Some earlier entries may already be durable. Their
811                // deterministic ids make the next bounded retry safe; never
812                // clear the source queue unless every enqueue completed.
813                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
860/// Terminal-window compatibility migration.
861///
862/// Loads the latest durable legacy queue, enqueues it into the typed inbox and
863/// clears it with the same CAS protocol as a normal turn boundary, but never
864/// claims or acknowledges typed work. The caller must hand
865/// `highest_generation` to the activation router before finalization.
866pub 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                // Receipt-without-transcript is a damaged/incomplete commit.
1009                // Keep cur as the only recoverable copy. Cursor membership is
1010                // not required here because it is intentionally bounded.
1011                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            // Fail closed: an admitted cursor without its transcript message is
1064            // an invariant violation. Never establish a permanent tombstone
1065            // that could discard the only recoverable queue copy.
1066            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        // The provider-valid Message and bounded cursor live in the same
1095        // session.json atomic checkpoint. Only after that succeeds may ack
1096        // create the permanent admitted-id receipt and remove the claim.
1097        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        // Capture only the message appended by this checkpoint. A recovered
1121        // transcript entry is already represented by its original append and
1122        // must not mint a duplicate durable change-feed coordinate.
1123        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/// Turn-boundary refresh from the on-disk session: a SINGLE load that both
1154/// merges queued `send_message` injections AND reads the live typed permission
1155/// mode.
1156///
1157/// The mode read is what makes a mid-run
1158/// `PATCH /sessions {permission_mode|bypass_permissions}` take effect on the
1159/// CURRENT run: the run owns a `Session` value taken at spawn and never
1160/// otherwise re-reads storage, so the caller applies the returned
1161/// `disk_permission_mode` to the live runtime state at each round boundary. The
1162/// posture is folded into the existing per-round load so large parent sessions
1163/// aren't deserialized twice.
1164#[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
1173/// Turn-boundary refresh with the first-class durable SessionInbox enabled.
1174pub 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    // A disk copy with no runtime state carries no authoritative permission
1246    // mode — report `None` (unknown) so the caller leaves the live posture
1247    // untouched rather than force-disabling a legitimately permissive run.
1248    // #540/#770.
1249    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        // Keep the live snapshot's bounded audit record in the same revision as
1277        // the typed mode returned below. Runtime persistence also adopts these
1278        // keys under its session lock, so both the current round and any later
1279        // checkpoint observe one coherent permission transition.
1280        if let Some((_, audit)) = disk_permission_posture.as_ref() {
1281            audit.write_to(&mut session.metadata);
1282        }
1283        // A previous activation may have checkpointed and acked the claim
1284        // before this live snapshot was loaded/refreshed. Carry its durable
1285        // cursor forward so finalization never mistakes an admitted duplicate
1286        // for work requiring a successor activation.
1287        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            // Migration durably authorized this prefix before it was allowed
1300            // to CAS-clear the compatibility source. Carry the generation on
1301            // the live snapshot to the owning entry point's terminal
1302            // handshake if admission below cannot checkpoint it.
1303            session.session_inbox_admission_mut().observe(generation);
1304        }
1305        // The coordinated CAS cleared the latest durable source, but this
1306        // running snapshot may still carry the same compatibility queue. Clear
1307        // that exact stale copy before the inbox checkpoint, otherwise it would
1308        // resurrect the source queue in session.json/runtime sidecar.
1309        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    // Read via the typed accessor (prefers `runtime_metadata`, falls back to the
1350    // legacy `pending_injected_messages` JSON string; defensive on malformed).
1351    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        // Clear on both planes (typed field + legacy string mirror).
1406        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        // When the cleanup save ran, the adopting `save_runtime_session` stamped
1428        // the freshest on-disk bypass onto `session.agent_runtime_state` (so a
1429        // `PATCH` that landed DURING the merge is already reflected there) — read
1430        // it back from memory instead of a third full-session deserialization on
1431        // this steering hot path. Without a save, `session` holds no disk-fresh
1432        // value, so keep the pre-merge snapshot. #540.
1433        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) = &current {
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        // Legacy metadata mirror is no longer written
2138        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    // --- Pending injected message tests ---
2165
2166    #[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        // Second merge is a no-op
2919        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    // #540/#770: the turn-boundary refresh reports the live on-disk permission
2932    // mode so the pipeline can adopt a mid-run PATCH into the running loop.
2933    #[tokio::test]
2934    async fn refresh_turn_boundary_reports_disk_permission_mode() {
2935        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
2936
2937        // Persist a session with bypass ON.
2938        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        // A running loop holds a stale copy with bypass OFF.
2956        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    // #540 review: a disk copy with no agent_runtime_state reports None
2989    // (unknown), NOT Some(false) — so the pipeline won't force-disable a
2990    // legitimately bypassed run.
2991    #[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        // Restart: recover the cur claim and admit it exactly once.
3037        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        // A concurrent writer lands after the running snapshot was loaded but
3105        // before checkpoint merge. LockedSessionStore preserves that durable
3106        // message, so post-checkpoint proof must catch the collision.
3107        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        // A sender retry after restart observes the permanent receipt and does
3294        // not enqueue a second copy.
3295        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                    // Model receipt publication followed by an interrupted cur
3450                    // removal using the actual claim bytes and permanent receipt.
3451                    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        // Process restart: deterministic delivery reuses generation one, then
3666        // the already-published activation permits the source CAS-clear.
3667        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        // A retry sees the first envelope's permanent enqueue identity, adds
3785        // only the missing suffix, publishes generation two, then clears.
3786        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        // The provider has returned from its final round. A typed delivery now
3920        // lands, but terminal cleanup must not claim or ack it.
3921        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        // The successor's first safe boundary checkpoints and acknowledges the
3947        // envelope exactly once before reasoning.
3948        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(); // Individually valid; only whole-batch overhead overflows.
4158        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); // Data, never eligibility/currentness.
4463                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}