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