1use bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct AgentRecord {
14 pub agent_id: String,
15 pub role: String,
16 #[serde(default)]
17 pub labels: Vec<String>,
18 pub endpoint: String,
20 pub pid: u32,
21 #[serde(default)]
22 pub version: String,
23 pub started_at: DateTime<Utc>,
24 pub lease_expires_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct RunSpec {
31 pub assignment: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub logical_session: Option<LogicalSessionIdentity>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub project_id: Option<ProjectId>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub reasoning_effort: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub permission_policy: Option<PermissionPolicyContext>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub messages: Vec<serde_json::Value>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub activation_run_id: Option<String>,
61 #[serde(default, skip_serializing_if = "is_zero")]
67 pub execution_epoch: u64,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub initial_session_messages: Vec<SessionMessageDelivery>,
73 #[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
76 pub secrets: RunSecrets,
77}
78
79pub const WORKFLOW_USAGE_REQUESTED_KEY: &str = "workflow.agent_usage_requested.v1";
83pub const WORKFLOW_USAGE_OBSERVATION_KEY: &str = "workflow.agent_usage_observation.v1";
84pub const WORKFLOW_TERMINAL_OBSERVATION_KEY: &str = "workflow.agent_terminal_observation.v1";
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(deny_unknown_fields)]
89pub struct WorkflowAgentUsage {
90 #[serde(rename = "type")]
91 pub kind: String,
92 pub activation_run_id: String,
93 pub child_session_id: String,
94 pub child_created_at: chrono::DateTime<chrono::Utc>,
95 pub prompt_tokens: u64,
96 pub completion_tokens: u64,
97}
98
99impl WorkflowAgentUsage {
100 pub const TYPE: &'static str = "workflow_agent_usage";
101
102 pub fn matches(&self, child: &bamboo_domain::Session, activation: &str) -> bool {
103 self.kind == Self::TYPE
104 && !activation.is_empty()
105 && self.activation_run_id == activation
106 && self.child_session_id == child.id
107 && self.child_created_at == child.created_at
108 }
109
110 pub fn from_session(child: &bamboo_domain::Session, activation: &str) -> Option<Self> {
111 let usage: Self =
112 serde_json::from_str(child.metadata.get(WORKFLOW_USAGE_OBSERVATION_KEY)?).ok()?;
113 usage.matches(child, activation).then_some(usage)
114 }
115
116 pub fn total_tokens(&self) -> u64 {
117 self.prompt_tokens.saturating_add(self.completion_tokens)
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(tag = "type", deny_unknown_fields)]
126pub enum ReadOnlyActorTranscript {
127 #[serde(rename = "owned_readonly_transcript")]
128 Complete { messages: Vec<serde_json::Value> },
129}
130impl ReadOnlyActorTranscript {
131 pub const MAX_BYTES: usize = 64 * 1024;
132 pub fn validate(&self) -> Result<(), &'static str> {
133 let Self::Complete { messages } = self;
134 if messages.len() != 3
135 || serde_json::to_vec(self).map_or(true, |bytes| bytes.len() > Self::MAX_BYTES)
136 {
137 return Err("owned_readonly_transcript_unsupported");
138 }
139 Ok(())
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(tag = "type", deny_unknown_fields)]
146pub enum LocalToolMessages {
147 #[serde(rename = "local_client_tool_messages_v1")]
148 Complete {
149 version: u32,
150 messages: Vec<serde_json::Value>,
151 },
152}
153
154impl LocalToolMessages {
155 pub const TYPE: &'static str = "local_client_tool_messages_v1";
156 pub const MAX_BYTES: usize = 64 * 1024;
157 pub const MAX_MESSAGES: usize = 128;
158 pub const MAX_PAIRS: usize = 32;
159
160 pub fn supports_tools(tools: &[String], read_only: bool) -> bool {
161 if tools.iter().any(|name| name == "Task") {
162 return tools
163 .iter()
164 .all(|name| matches!(name.as_str(), "Task" | "Read" | "Write"));
165 }
166 !tools.is_empty()
167 && !(read_only && tools.len() == 1 && tools[0] == "Glob")
168 && tools
169 .iter()
170 .all(|name| matches!(name.as_str(), "Read" | "Glob" | "Write"))
171 }
172
173 pub fn validate(&self) -> Result<Vec<bamboo_domain::Message>, &'static str> {
174 use std::io::Write;
175 struct Limit(usize);
176 impl Write for Limit {
177 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
178 self.0 = self
179 .0
180 .checked_add(bytes.len())
181 .filter(|n| *n <= LocalToolMessages::MAX_BYTES)
182 .ok_or_else(|| std::io::Error::other("local tool history limit"))?;
183 Ok(bytes.len())
184 }
185 fn flush(&mut self) -> std::io::Result<()> {
186 Ok(())
187 }
188 }
189 let Self::Complete { version, messages } = self;
190 if *version != 1
191 || messages.is_empty()
192 || messages.len() > Self::MAX_MESSAGES
193 || serde_json::to_writer(Limit(0), self).is_err()
194 {
195 return Err("local_tool_history_unsupported");
196 }
197 let typed: Vec<bamboo_domain::Message> = messages
198 .iter()
199 .map(|value| {
200 let message: bamboo_domain::Message = serde_json::from_value(value.clone())
201 .map_err(|_| "local_tool_history_unsupported")?;
202 if serde_json::to_value(&message).map_err(|_| "local_tool_history_unsupported")?
203 != *value
204 || message.id.is_empty()
205 || message.id.len() > 128
206 || (message.reasoning.is_some()
207 && message.role != bamboo_domain::Role::Assistant)
208 || message.reasoning_signature.is_some()
209 || message.content_parts.is_some()
210 || message.image_ocr.is_some()
211 || message.compressed
212 || message.compressed_by_event_id.is_some()
213 || message.compression_level != 0
214 {
215 return Err("local_tool_history_unsupported");
216 }
217 Ok(message)
218 })
219 .collect::<Result<_, _>>()?;
220 let mut ids = std::collections::HashSet::new();
221 let mut calls = std::collections::HashSet::new();
222 for message in &typed {
223 if !ids.insert(&message.id) {
224 return Err("local_tool_history_unsupported");
225 }
226 for call in message.tool_calls.iter().flatten() {
227 if call.id.is_empty()
228 || call.id.len() > 128
229 || !calls.insert(&call.id)
230 || calls.len() > Self::MAX_PAIRS
231 {
232 return Err("local_tool_history_unsupported");
233 }
234 }
235 }
236 Ok(typed)
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct LogicalSessionIdentity {
243 pub session_id: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub parent_session_id: Option<String>,
246 pub root_session_id: String,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub creation: Option<ChildCreationIdentity>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct ChildCreationIdentity {
256 pub created_at: DateTime<Utc>,
257 pub spawn_depth: u32,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct NativeToolCeiling {
265 pub version: u32,
266 pub child_session_id: String,
267 pub parent_session_id: String,
268 pub root_session_id: String,
269 pub created_at: DateTime<Utc>,
270 pub spawn_depth: u32,
271 #[serde(deserialize_with = "deserialize_project_observation")]
273 pub project_id: Option<ProjectId>,
274 pub tools: Vec<String>,
275}
276
277fn deserialize_project_observation<'de, D: serde::Deserializer<'de>>(
278 deserializer: D,
279) -> Result<Option<ProjectId>, D::Error> {
280 Option::<ProjectId>::deserialize(deserializer)
281}
282
283impl NativeToolCeiling {
284 pub const NAMES: [&'static str; 8] = [
285 "Bash",
286 "Edit",
287 "Glob",
288 "Read",
289 "Task",
290 "Write",
291 "exec_command",
292 "write_stdin",
293 ];
294 pub const MAX_BYTES: usize = 16 * 1024;
295
296 pub fn validate(&self) -> Result<(), &'static str> {
297 let mut candidate = bamboo_domain::Session::new(self.child_session_id.clone(), "");
298 candidate.kind = bamboo_domain::SessionKind::Child;
299 candidate.parent_session_id = Some(self.parent_session_id.clone());
300 candidate.root_session_id = self.root_session_id.clone();
301 candidate.created_at = self.created_at;
302 candidate.spawn_depth = self.spawn_depth;
303 if self.version != 1
304 || bamboo_domain::ActorSession::from_session(&candidate).is_err()
305 || self.tools.len() > Self::NAMES.len()
306 || self
307 .tools
308 .iter()
309 .any(|name| !Self::NAMES.contains(&name.as_str()))
310 || self.tools.windows(2).any(|pair| pair[0] >= pair[1])
311 || serde_json::to_vec(self).map_or(true, |bytes| bytes.len() > Self::MAX_BYTES)
312 {
313 return Err("native_tool_ceiling_invalid");
314 }
315 Ok(())
316 }
317
318 pub fn matches_run(&self, run: &RunSpec) -> bool {
319 self.validate().is_ok()
320 && run.project_id == self.project_id
321 && run.logical_session.as_ref().is_some_and(|identity| {
322 identity.session_id == self.child_session_id
323 && identity.parent_session_id.as_deref()
324 == Some(self.parent_session_id.as_str())
325 && identity.root_session_id == self.root_session_id
326 && identity.creation.as_ref().is_some_and(|creation| {
327 creation.created_at == self.created_at
328 && creation.spawn_depth == self.spawn_depth
329 })
330 })
331 }
332}
333
334fn is_zero(value: &u64) -> bool {
335 *value == 0
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ActorEventQos {
347 Durable,
348 Snapshot,
349 Ephemeral,
350}
351
352impl ActorEventQos {
353 pub fn classify(event: &serde_json::Value) -> Self {
356 match event.get("type").and_then(serde_json::Value::as_str) {
357 Some("token" | "reasoning_token" | "tool_token" | "sub_agent_heartbeat") => {
358 Self::Ephemeral
359 }
360 Some("sub_agent_event") => event
364 .get("event")
365 .map(Self::classify)
366 .unwrap_or(Self::Durable),
367 Some("runner_progress" | "token_budget_updated" | "context_pressure_notification") => {
368 Self::Snapshot
369 }
370 Some("task_list_item_progress") => Self::Durable,
374 _ => Self::Durable,
376 }
377 }
378}
379
380pub const MAX_ACTOR_EVENT_BATCH_EVENTS: usize = 64;
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct ActorEventBatch {
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub logical_session: Option<LogicalSessionIdentity>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub activation_id: Option<String>,
394 #[serde(default, skip_serializing_if = "is_zero")]
395 pub execution_epoch: u64,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub source_node_id: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub source_actor_id: Option<String>,
400 pub first_seq: u64,
401 pub last_seq: u64,
402 pub qos: ActorEventQos,
403 pub events: Vec<serde_json::Value>,
404}
405
406impl ActorEventBatch {
407 pub fn validate(&self) -> Result<(), String> {
410 if self.events.is_empty() {
411 return Err("actor event batch is empty".to_string());
412 }
413 if self.events.len() > MAX_ACTOR_EVENT_BATCH_EVENTS {
414 return Err(format!(
415 "actor event batch has {} events; maximum is {MAX_ACTOR_EVENT_BATCH_EVENTS}",
416 self.events.len()
417 ));
418 }
419 let expected_last = self
420 .first_seq
421 .checked_add(self.events.len() as u64 - 1)
422 .ok_or_else(|| "actor event batch sequence range overflows".to_string())?;
423 if self.first_seq == 0 || self.last_seq != expected_last {
424 return Err("actor event batch has an invalid sequence range".to_string());
425 }
426 if self.qos != ActorEventQos::Durable
427 && self
428 .events
429 .iter()
430 .any(|event| ActorEventQos::classify(event) != self.qos)
431 {
432 return Err("actor event batch QoS does not match its events".to_string());
433 }
434 Ok(())
435 }
436}
437
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
441pub struct ActorEventWatermark {
442 pub version: u32,
443 pub logical_session: Option<LogicalSessionIdentity>,
444 pub activation_id: Option<String>,
445 pub execution_epoch: u64,
446 pub final_seq: u64,
449}
450
451impl ActorEventWatermark {
452 pub const VERSION: u32 = 1;
453
454 pub fn matches_consumed_run(
455 &self,
456 logical_session: &LogicalSessionIdentity,
457 activation_id: &str,
458 execution_epoch: u64,
459 consumed_seq: u64,
460 ) -> bool {
461 self.version == Self::VERSION
462 && !activation_id.is_empty()
463 && execution_epoch != 0
464 && self.logical_session.as_ref() == Some(logical_session)
465 && self.activation_id.as_deref() == Some(activation_id)
466 && self.execution_epoch == execution_epoch
467 && self.final_seq == consumed_seq
468 }
469}
470
471#[derive(Debug)]
472struct PendingActorEventBatch {
473 first_seq: u64,
474 qos: ActorEventQos,
475 events: Vec<serde_json::Value>,
476}
477
478#[derive(Debug)]
482pub struct ActorEventBatcher {
483 logical_session: Option<LogicalSessionIdentity>,
484 activation_id: Option<String>,
485 execution_epoch: u64,
486 source_node_id: Option<String>,
487 source_actor_id: Option<String>,
488 next_seq: u64,
489 pending: Option<PendingActorEventBatch>,
490 durable_events: bool,
491}
492
493impl ActorEventBatcher {
494 pub fn for_run(
495 spec: &RunSpec,
496 source_node_id: Option<String>,
497 source_actor_id: Option<String>,
498 ) -> Self {
499 Self {
500 logical_session: spec.logical_session.clone(),
501 activation_id: spec.activation_run_id.clone(),
502 execution_epoch: spec.execution_epoch,
503 source_node_id,
504 source_actor_id,
505 next_seq: 1,
506 pending: None,
507 durable_events: false,
508 }
509 }
510
511 pub fn with_durable_events(mut self, required: bool) -> Self {
514 self.durable_events = required;
515 self
516 }
517
518 pub fn push(&mut self, event: serde_json::Value) -> Vec<ActorEventBatch> {
521 let qos = ActorEventQos::classify(&event);
522 let seq = self.next_seq;
523 self.next_seq = self.next_seq.saturating_add(1);
524 let mut ready = Vec::with_capacity(2);
525
526 if qos == ActorEventQos::Durable {
527 if let Some(batch) = self.flush() {
528 ready.push(batch);
529 }
530 ready.push(self.build(seq, qos, vec![event]));
531 return ready;
532 }
533
534 let boundary = self.pending.as_ref().is_some_and(|pending| {
535 pending.qos != qos || pending.events.len() >= MAX_ACTOR_EVENT_BATCH_EVENTS
536 });
537 if boundary {
538 if let Some(batch) = self.flush() {
539 ready.push(batch);
540 }
541 }
542 let pending = self.pending.get_or_insert_with(|| PendingActorEventBatch {
543 first_seq: seq,
544 qos,
545 events: Vec::with_capacity(MAX_ACTOR_EVENT_BATCH_EVENTS),
546 });
547 pending.events.push(event);
548 if pending.events.len() >= MAX_ACTOR_EVENT_BATCH_EVENTS {
549 if let Some(batch) = self.flush() {
550 ready.push(batch);
551 }
552 }
553 ready
554 }
555
556 pub fn flush(&mut self) -> Option<ActorEventBatch> {
557 let pending = self.pending.take()?;
558 Some(self.build(pending.first_seq, pending.qos, pending.events))
559 }
560
561 pub fn has_pending(&self) -> bool {
562 self.pending.is_some()
563 }
564
565 pub fn final_watermark(&self) -> Option<ActorEventWatermark> {
569 (self.durable_events
570 && self.execution_epoch != 0
571 && !self.has_pending()
572 && self.next_seq < u64::MAX)
574 .then(|| ActorEventWatermark {
575 version: ActorEventWatermark::VERSION,
576 logical_session: self.logical_session.clone(),
577 activation_id: self.activation_id.clone(),
578 execution_epoch: self.execution_epoch,
579 final_seq: self.next_seq - 1,
580 })
581 }
582
583 fn build(
584 &self,
585 first_seq: u64,
586 qos: ActorEventQos,
587 events: Vec<serde_json::Value>,
588 ) -> ActorEventBatch {
589 let last_seq = first_seq + events.len() as u64 - 1;
590 ActorEventBatch {
591 logical_session: self.logical_session.clone(),
592 activation_id: self.activation_id.clone(),
593 execution_epoch: self.execution_epoch,
594 source_node_id: self.source_node_id.clone(),
595 source_actor_id: self.source_actor_id.clone(),
596 first_seq,
597 last_seq,
598 qos: if self.durable_events {
599 ActorEventQos::Durable
600 } else {
601 qos
602 },
603 events,
604 }
605 }
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
612pub struct SessionMessageDelivery {
613 pub target_session_id: String,
614 pub envelope: SessionMessageEnvelope,
615 pub canonical_claim_generation: u64,
616 pub activation_run_id: String,
617 #[serde(default)]
620 pub activation_policy: SessionActivationPolicy,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627pub struct SessionMessageAdmissionConfirmation {
628 pub target_session_id: String,
629 pub envelope_id: String,
630 pub canonical_claim_generation: u64,
631 pub activation_run_id: String,
632}
633
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(deny_unknown_fields)]
637pub struct InitialInputReleaseRequest {
638 pub version: u32,
639 pub nonce: String,
640 pub child_id: String,
641 pub parent_id: String,
642 pub root_id: String,
643 pub created_at: DateTime<Utc>,
644 pub spawn_depth: u32,
645 #[serde(deserialize_with = "deserialize_project_observation")]
646 pub project_id: Option<ProjectId>,
647 pub envelope_id: String,
648 pub generation: u64,
649 pub activation_run_id: String,
650 pub execution_epoch: u64,
651}
652impl InitialInputReleaseRequest {
653 pub fn from_run(
654 run: &RunSpec,
655 delivery: &SessionMessageDelivery,
656 nonce: String,
657 ) -> Result<Self, String> {
658 let logical = run
659 .logical_session
660 .as_ref()
661 .ok_or("initial release logical identity missing")?;
662 let birth = logical
663 .creation
664 .as_ref()
665 .ok_or("initial release birth missing")?;
666 let parent = logical
667 .parent_session_id
668 .as_ref()
669 .ok_or("initial release parent missing")?;
670 if uuid::Uuid::parse_str(&nonce).is_err()
671 || nonce.len() != 36
672 || logical.session_id.is_empty()
673 || parent.is_empty()
674 || logical.root_session_id.is_empty()
675 || birth.spawn_depth == 0
676 || run.execution_epoch == 0
677 || delivery.canonical_claim_generation == 0
678 || run.activation_run_id.as_deref() != Some(delivery.activation_run_id.as_str())
679 || delivery.target_session_id != logical.session_id
680 || delivery.envelope.target_session_id != logical.session_id
681 {
682 return Err("initial release binding invalid".into());
683 }
684 let request = Self {
685 version: 1,
686 nonce,
687 child_id: logical.session_id.clone(),
688 parent_id: parent.clone(),
689 root_id: logical.root_session_id.clone(),
690 created_at: birth.created_at,
691 spawn_depth: birth.spawn_depth,
692 project_id: run.project_id.clone(),
693 envelope_id: delivery.envelope.id.as_str().into(),
694 generation: delivery.canonical_claim_generation,
695 activation_run_id: delivery.activation_run_id.clone(),
696 execution_epoch: run.execution_epoch,
697 };
698 request.validate()?;
699 Ok(request)
700 }
701 pub fn validate(&self) -> Result<(), String> {
702 let identity = NativeToolCeiling {
703 version: self.version,
704 child_session_id: self.child_id.clone(),
705 parent_session_id: self.parent_id.clone(),
706 root_session_id: self.root_id.clone(),
707 created_at: self.created_at,
708 spawn_depth: self.spawn_depth,
709 project_id: self.project_id.clone(),
710 tools: Vec::new(),
711 };
712 if identity.validate().is_err()
713 || uuid::Uuid::parse_str(&self.nonce).is_err()
714 || self.nonce.len() != 36
715 || self.generation == 0
716 || self.execution_epoch == 0
717 || self.activation_run_id.is_empty()
718 || self.activation_run_id.len() > 256
719 || bamboo_domain::SessionMessageId::parse(self.envelope_id.clone()).is_err()
720 {
721 return Err("initial release binding invalid".into());
722 }
723 Ok(())
724 }
725}
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(deny_unknown_fields)]
728pub struct InitialInputRelease {
729 pub request: InitialInputReleaseRequest,
730 pub expires_at: DateTime<Utc>,
731}
732impl InitialInputRelease {
733 pub fn permits(&self, request: &InitialInputReleaseRequest, now: DateTime<Utc>) -> bool {
734 &self.request == request && request.version == 1 && now < self.expires_at
735 }
736}
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(
740 tag = "initial_input_control",
741 rename_all = "snake_case",
742 deny_unknown_fields
743)]
744pub enum InitialInputControl {
745 Request { request: InitialInputReleaseRequest },
746 Release { release: InitialInputRelease },
747}
748impl InitialInputControl {
749 pub fn decode(body: serde_json::Value) -> Result<Self, String> {
750 if serde_json::to_vec(&body).map_or(true, |bytes| bytes.len() > 4096) {
751 return Err("initial release control exceeds bound".into());
752 }
753 let control: Self =
754 serde_json::from_value(body).map_err(|_| "initial release control malformed")?;
755 match &control {
756 Self::Request { request } => request.validate()?,
757 Self::Release { release } => release.request.validate()?,
758 }
759 Ok(control)
760 }
761}
762
763#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
766pub struct RunSecrets {
767 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub codex_provider_token: Option<SecretValue>,
769}
770
771impl RunSecrets {
772 pub fn is_empty(&self) -> bool {
773 self.codex_provider_token.is_none()
774 }
775}
776
777#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(transparent)]
780pub struct SecretValue(String);
781
782impl SecretValue {
783 pub fn new(value: impl Into<String>) -> Self {
784 Self(value.into())
785 }
786
787 pub fn expose(&self) -> &str {
788 &self.0
789 }
790}
791
792impl std::fmt::Debug for SecretValue {
793 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794 formatter.write_str("SecretValue([REDACTED])")
795 }
796}
797
798#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
801pub struct PermissionPolicyContext {
802 pub revision: u64,
803 #[serde(default)]
806 pub requested_mode: String,
807 #[serde(default)]
810 pub effective_mode: String,
811 pub bypass_permissions: bool,
812 #[serde(default)]
813 pub auto_approve_permissions: bool,
814 pub session_id: String,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub workspace_path: Option<String>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub environment_lease: Option<crate::environment::EnvironmentLease>,
821 #[serde(default)]
824 pub inherit_session_grants: bool,
825 pub policy: serde_json::Value,
826}
827
828impl PermissionPolicyContext {
829 pub fn resolved_modes(
833 &self,
834 ) -> Result<
835 (
836 bamboo_domain::SessionPermissionMode,
837 bamboo_domain::PermissionMode,
838 ),
839 String,
840 > {
841 if self.auto_approve_permissions && self.bypass_permissions {
842 return Err(
843 "permission_policy auto_approve_permissions and bypass_permissions are mutually exclusive"
844 .to_string(),
845 );
846 }
847 let has_requested_mode = !self.requested_mode.is_empty();
848 let has_effective_mode = !self.effective_mode.is_empty();
849 if has_requested_mode != has_effective_mode {
850 return Err(
851 "permission_policy requested_mode and effective_mode must be provided together"
852 .to_string(),
853 );
854 }
855 let requested = if self.requested_mode.is_empty() {
856 if self.auto_approve_permissions {
857 bamboo_domain::SessionPermissionMode::Auto
858 } else if self.bypass_permissions {
859 bamboo_domain::SessionPermissionMode::Bypass
860 } else {
861 bamboo_domain::SessionPermissionMode::Default
862 }
863 } else {
864 match self.requested_mode.as_str() {
865 "default" => bamboo_domain::SessionPermissionMode::Default,
866 "bypass" => bamboo_domain::SessionPermissionMode::Bypass,
867 "auto" => bamboo_domain::SessionPermissionMode::Auto,
868 other => return Err(format!("invalid requested permission mode '{other}'")),
869 }
870 };
871 let effective = if self.effective_mode.is_empty() {
872 bamboo_domain::resolve_permission_mode(
873 requested,
874 bamboo_domain::PermissionMode::Default,
875 )
876 .effective
877 } else {
878 bamboo_domain::PermissionMode::from_audit_str(&self.effective_mode).ok_or_else(
879 || {
880 format!(
881 "invalid effective permission mode '{}'",
882 self.effective_mode
883 )
884 },
885 )?
886 };
887 let resolution = bamboo_domain::PermissionModeResolution {
888 requested,
889 effective,
890 };
891 if !resolution.is_consistent() {
892 return Err("permission_policy requested/effective modes are inconsistent".into());
893 }
894 if has_requested_mode {
895 if self.bypass_permissions != resolution.bypass_permissions() {
896 return Err("permission_policy bypass flag disagrees with effective mode".into());
897 }
898 if self.auto_approve_permissions != resolution.suppress_approval_prompts() {
899 return Err(
900 "permission_policy auto flag disagrees with no-prompt resolution".into(),
901 );
902 }
903 }
904 Ok((requested, effective))
905 }
906}
907
908#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
910#[serde(tag = "kind", rename_all = "snake_case")]
911pub enum ParentFrame {
912 Run(RunSpec),
913 Cancel,
914 Message {
915 text: String,
916 },
917 SessionMessage {
918 delivery: SessionMessageDelivery,
919 },
920 InitialInputRelease {
921 release: InitialInputRelease,
922 },
923 ApprovalReply {
929 id: String,
930 approved: bool,
931 },
932 OwnedTreeReply {
935 id: String,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 page: Option<serde_json::Value>,
938 },
939 SubAgentReply {
941 id: String,
942 result: serde_json::Value,
943 },
944}
945
946#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
948#[serde(tag = "kind", rename_all = "snake_case")]
949pub enum ChildFrame {
950 Event { event: serde_json::Value },
952 EventBatch { batch: ActorEventBatch },
955 ApprovalRequest { id: String, body: serde_json::Value },
961 OwnedTreeRequest {
964 id: String,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
966 cursor: Option<String>,
967 },
968 SubAgentRequest {
971 id: String,
972 tool_call_id: String,
973 args: serde_json::Value,
974 },
975 SessionMessageAdmitted {
978 confirmation: SessionMessageAdmissionConfirmation,
979 },
980 Terminal {
981 status: TerminalStatus,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
985 final_event_watermark: Option<ActorEventWatermark>,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
987 result: Option<String>,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
989 error: Option<String>,
990 #[serde(default, skip_serializing_if = "Vec::is_empty")]
994 transcript: Vec<serde_json::Value>,
995 },
996}
997
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
999#[serde(rename_all = "snake_case")]
1000pub enum TerminalStatus {
1001 Completed,
1002 Error,
1003 Cancelled,
1004 Suspended,
1008}
1009
1010impl ParentFrame {
1011 pub fn to_text(&self) -> String {
1012 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
1013 }
1014 pub fn from_text(s: &str) -> serde_json::Result<Self> {
1015 serde_json::from_str(s)
1016 }
1017}
1018
1019impl ChildFrame {
1020 pub fn to_text(&self) -> String {
1021 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
1022 }
1023 pub fn from_text(s: &str) -> serde_json::Result<Self> {
1024 serde_json::from_str(s)
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn native_tool_ceiling_accepts_exact_shell_names_and_keeps_wire_bounds() {
1034 let parent = bamboo_domain::Session::new("native-parent", "model");
1035 let child = bamboo_domain::Session::new_child_of("native-child", &parent, "model", "role");
1036 let ceiling = NativeToolCeiling {
1037 version: 1,
1038 child_session_id: child.id.clone(),
1039 parent_session_id: parent.id,
1040 root_session_id: child.root_session_id,
1041 created_at: child.created_at,
1042 spawn_depth: child.spawn_depth,
1043 project_id: None,
1044 tools: NativeToolCeiling::NAMES
1045 .into_iter()
1046 .map(str::to_owned)
1047 .collect(),
1048 };
1049 ceiling.validate().unwrap();
1050 let raw = serde_json::to_value(&ceiling).unwrap();
1051 assert_eq!(raw["project_id"], serde_json::Value::Null);
1052 let restored: NativeToolCeiling = serde_json::from_value(raw.clone()).unwrap();
1053 assert_eq!(restored, ceiling);
1054 restored.validate().unwrap();
1055
1056 let mut legacy = ceiling.clone();
1058 legacy.tools = vec!["Bash".into()];
1059 let restored: NativeToolCeiling =
1060 serde_json::from_slice(&serde_json::to_vec(&legacy).unwrap()).unwrap();
1061 restored.validate().unwrap();
1062 assert_eq!(restored.tools, ["Bash"]);
1063
1064 for tools in [
1065 vec!["default::exec_command".into()],
1066 vec!["Exec_Command".into()],
1067 vec!["exec_command".into(), "exec_command".into()],
1068 vec!["write_stdin".into(), "exec_command".into()],
1069 vec!["Bash".into(); NativeToolCeiling::NAMES.len() + 1],
1070 ] {
1071 let mut invalid = ceiling.clone();
1072 invalid.tools = tools;
1073 assert!(invalid.validate().is_err());
1074 }
1075 let mut oversized = ceiling.clone();
1076 oversized.child_session_id = "x".repeat(NativeToolCeiling::MAX_BYTES);
1077 assert!(oversized.validate().is_err());
1078 let mut missing_project = raw.clone();
1079 missing_project
1080 .as_object_mut()
1081 .unwrap()
1082 .remove("project_id");
1083 assert!(serde_json::from_value::<NativeToolCeiling>(missing_project).is_err());
1084 let mut unknown = raw;
1085 unknown["extra"] = true.into();
1086 assert!(serde_json::from_value::<NativeToolCeiling>(unknown).is_err());
1087 }
1088
1089 #[test]
1090 fn workflow_agent_usage_is_bound_to_current_activation_and_birth() {
1091 let mut child = bamboo_domain::Session::new("child", "model");
1092 child.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new(""));
1093 let usage = WorkflowAgentUsage {
1094 kind: WorkflowAgentUsage::TYPE.into(),
1095 activation_run_id: "activation".into(),
1096 child_session_id: child.id.clone(),
1097 child_created_at: child.created_at,
1098 prompt_tokens: 24,
1099 completion_tokens: 12,
1100 };
1101 child.metadata.insert(
1102 WORKFLOW_USAGE_OBSERVATION_KEY.into(),
1103 serde_json::to_string(&usage).unwrap(),
1104 );
1105 assert_eq!(
1106 WorkflowAgentUsage::from_session(&child, "activation")
1107 .unwrap()
1108 .total_tokens(),
1109 36
1110 );
1111 assert!(WorkflowAgentUsage::from_session(&child, "next-activation").is_none());
1112 child.created_at += chrono::Duration::seconds(1);
1113 assert!(WorkflowAgentUsage::from_session(&child, "activation").is_none());
1114 }
1115
1116 #[test]
1117 fn local_tool_messages_are_closed_bounded_and_durable() {
1118 let raw = serde_json::to_value(bamboo_domain::Message::user("confirmed")).unwrap();
1119 let good = LocalToolMessages::Complete {
1120 version: 1,
1121 messages: vec![raw.clone()],
1122 };
1123 assert_eq!(
1124 serde_json::to_value(good.validate().unwrap()[0].clone()).unwrap(),
1125 raw
1126 );
1127 let event = serde_json::to_value(&good).unwrap();
1128 assert_eq!(ActorEventQos::classify(&event), ActorEventQos::Durable);
1129 for field in ["session", "permission", "version_extra"] {
1130 let mut changed = event.clone();
1131 changed[field] = serde_json::json!({});
1132 assert!(
1133 serde_json::from_value::<LocalToolMessages>(changed).is_err(),
1134 "{field}"
1135 );
1136 }
1137 for mutation in [
1138 "version",
1139 "missing",
1140 "unknown_message",
1141 "reasoning",
1142 "compressed",
1143 "duplicate",
1144 "bytes",
1145 "rows",
1146 ] {
1147 let mut changed = event.clone();
1148 match mutation {
1149 "version" => changed["version"] = 2.into(),
1150 "missing" => {
1151 changed.as_object_mut().unwrap().remove("messages");
1152 }
1153 "unknown_message" => changed["messages"][0]["cursor"] = 1.into(),
1154 "reasoning" => changed["messages"][0]["reasoning"] = "private".into(),
1155 "compressed" => changed["messages"][0]["compression_level"] = 1.into(),
1156 "duplicate" => changed["messages"] = serde_json::json!([raw, raw]),
1157 "bytes" => {
1158 changed["messages"][0]["content"] =
1159 "\\\"".repeat(LocalToolMessages::MAX_BYTES).into()
1160 }
1161 "rows" => {
1162 changed["messages"] =
1163 serde_json::to_value(vec![raw.clone(); LocalToolMessages::MAX_MESSAGES + 1])
1164 .unwrap()
1165 }
1166 _ => unreachable!(),
1167 }
1168 assert!(
1169 serde_json::from_value::<LocalToolMessages>(changed)
1170 .map_or(true, |data| data.validate().is_err()),
1171 "{mutation}"
1172 );
1173 }
1174 assert!(LocalToolMessages::supports_tools(
1175 &["Read".into(), "Write".into()],
1176 false
1177 ));
1178 assert!(!LocalToolMessages::supports_tools(&["Glob".into()], true));
1179 assert!(!LocalToolMessages::supports_tools(&["Bash".into()], false));
1180 }
1181
1182 #[test]
1183 fn local_tool_messages_preserve_bounded_assistant_reasoning_without_provider_authority() {
1184 let mut assistant = bamboo_domain::Message::assistant("complete report", None);
1185 assistant.reasoning = Some("Complete ordinary text reasoning 🪷.".into());
1186 let raw = serde_json::to_value(&assistant).unwrap();
1187 let data = LocalToolMessages::Complete {
1188 version: 1,
1189 messages: vec![raw.clone()],
1190 };
1191 assert_eq!(
1192 serde_json::to_value(data.validate().unwrap()).unwrap(),
1193 serde_json::json!([raw])
1194 );
1195
1196 for mutation in ["signature", "tool", "user", "system", "compressed", "bytes"] {
1197 let mut message = assistant.clone();
1198 match mutation {
1199 "signature" => {
1200 message.reasoning_signature = Some("opaque-provider-signature".into())
1201 }
1202 "tool" => message.role = bamboo_domain::Role::Tool,
1203 "user" => message.role = bamboo_domain::Role::User,
1204 "system" => message.role = bamboo_domain::Role::System,
1205 "compressed" => message.compressed = true,
1206 "bytes" => message.reasoning = Some("🪷".repeat(LocalToolMessages::MAX_BYTES)),
1207 _ => unreachable!(),
1208 }
1209 let changed = LocalToolMessages::Complete {
1210 version: 1,
1211 messages: vec![serde_json::to_value(&message).unwrap()],
1212 };
1213 assert!(changed.validate().is_err(), "{mutation}");
1214 }
1215 }
1216
1217 #[test]
1218 fn initial_release_schema_is_closed_and_exact() {
1219 let request = InitialInputReleaseRequest {
1220 version: 1,
1221 nonce: uuid::Uuid::new_v4().to_string(),
1222 child_id: "child".into(),
1223 parent_id: "parent".into(),
1224 root_id: "parent".into(),
1225 created_at: Utc::now(),
1226 spawn_depth: 1,
1227 project_id: None,
1228 envelope_id: "input".into(),
1229 generation: 1,
1230 activation_run_id: "run".into(),
1231 execution_epoch: 7,
1232 };
1233 let release = InitialInputRelease {
1234 request: request.clone(),
1235 expires_at: Utc::now() + chrono::Duration::seconds(1),
1236 };
1237 assert!(release.permits(&request, Utc::now()));
1238 for change in [
1239 "nonce",
1240 "generation",
1241 "execution_epoch",
1242 "child_id",
1243 "created_at",
1244 "project_id",
1245 ] {
1246 let mut value = serde_json::to_value(&request).unwrap();
1247 value[change] = match change {
1248 "generation" | "execution_epoch" => serde_json::json!(2),
1249 "created_at" => serde_json::json!("2020-01-01T00:00:00Z"),
1250 _ => serde_json::json!("foreign"),
1251 };
1252 if let Ok(changed) = serde_json::from_value(value) {
1253 assert!(!release.permits(&changed, Utc::now()), "{change}");
1254 }
1255 }
1256 assert!(!release.permits(&request, release.expires_at));
1257 let mut absent_project = serde_json::to_value(&request).unwrap();
1258 absent_project.as_object_mut().unwrap().remove("project_id");
1259 assert!(serde_json::from_value::<InitialInputReleaseRequest>(absent_project).is_err());
1260 let bytes = serde_json::to_string(&request).unwrap();
1261 let duplicate = bytes.replacen("{", "{\"nonce\":\"foreign\",", 1);
1262 assert!(serde_json::from_str::<InitialInputReleaseRequest>(&duplicate).is_err());
1263 let mut value = serde_json::to_value(InitialInputControl::Release { release }).unwrap();
1264 value["text"] = "cannot become steer".into();
1265 assert!(serde_json::from_value::<InitialInputControl>(value.clone()).is_err());
1266 value.as_object_mut().unwrap().remove("text");
1267 value["initial_input_control"] = "unknown".into();
1268 assert!(serde_json::from_value::<InitialInputControl>(value).is_err());
1269 }
1270
1271 #[test]
1272 fn parent_frames_round_trip() {
1273 for f in [
1274 ParentFrame::Run(RunSpec {
1275 assignment: "do x".into(),
1276 logical_session: None,
1277 project_id: None,
1278 reasoning_effort: None,
1279 permission_policy: None,
1280 messages: Vec::new(),
1281 activation_run_id: None,
1282 execution_epoch: 0,
1283 initial_session_messages: Vec::new(),
1284 secrets: Default::default(),
1285 }),
1286 ParentFrame::Cancel,
1287 ParentFrame::Message { text: "hi".into() },
1288 ] {
1289 assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
1290 }
1291 }
1292
1293 #[test]
1294 fn logical_creation_is_atomic_and_preserves_exact_birth() {
1295 let legacy = serde_json::json!({"session_id":"child", "parent_session_id":"parent", "root_session_id":"root"});
1296 let mut identity: LogicalSessionIdentity = serde_json::from_value(legacy.clone()).unwrap();
1297 assert!(identity.creation.is_none());
1298 assert_eq!(serde_json::to_value(&identity).unwrap(), legacy);
1299 identity.creation = Some(ChildCreationIdentity {
1300 created_at: "2026-09-26T01:02:03.123456789Z".parse().unwrap(),
1301 spawn_depth: 3,
1302 });
1303 let wire = serde_json::to_value(&identity).unwrap();
1304 assert_eq!(
1305 serde_json::from_value::<LogicalSessionIdentity>(wire.clone()).unwrap(),
1306 identity
1307 );
1308 for creation in [
1309 serde_json::json!({"created_at":"2026-09-26T01:02:03Z"}),
1310 serde_json::json!({"spawn_depth":3}),
1311 serde_json::json!({"created_at":"invalid", "spawn_depth":3}),
1312 serde_json::json!({"created_at":"2026-09-26T01:02:03Z", "spawn_depth":-1}),
1313 serde_json::json!({"created_at":"2026-09-26T01:02:03Z", "spawn_depth":3, "grant":true}),
1314 ] {
1315 let mut damaged = wire.clone();
1316 damaged["creation"] = creation;
1317 assert!(serde_json::from_value::<LogicalSessionIdentity>(damaged).is_err());
1318 }
1319 }
1320
1321 #[test]
1322 fn child_frames_round_trip() {
1323 let e = ChildFrame::Event {
1324 event: serde_json::json!({"type":"token","content":"hi"}),
1325 };
1326 assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
1327 let batch = ChildFrame::EventBatch {
1328 batch: ActorEventBatch {
1329 logical_session: Some(LogicalSessionIdentity {
1330 creation: None,
1331 session_id: "child".into(),
1332 parent_session_id: Some("parent".into()),
1333 root_session_id: "root".into(),
1334 }),
1335 activation_id: Some("run-7".into()),
1336 execution_epoch: 9,
1337 source_node_id: Some("node-a".into()),
1338 source_actor_id: Some("worker-2".into()),
1339 first_seq: 1,
1340 last_seq: 2,
1341 qos: ActorEventQos::Ephemeral,
1342 events: vec![
1343 serde_json::json!({"type":"token","content":"a"}),
1344 serde_json::json!({"type":"token","content":"b"}),
1345 ],
1346 },
1347 };
1348 assert_eq!(ChildFrame::from_text(&batch.to_text()).unwrap(), batch);
1349 let t = ChildFrame::Terminal {
1350 status: TerminalStatus::Completed,
1351 result: Some("done".into()),
1352 error: None,
1353 final_event_watermark: None,
1354 transcript: Vec::new(),
1355 };
1356 assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
1357
1358 let s = ChildFrame::Terminal {
1360 status: TerminalStatus::Suspended,
1361 result: None,
1362 error: None,
1363 final_event_watermark: None,
1364 transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
1365 };
1366 assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
1367
1368 let areq = ChildFrame::ApprovalRequest {
1370 id: "a1".into(),
1371 body: serde_json::json!({
1372 "tool_name": "Write",
1373 "permission_type": "WriteFile",
1374 "resource": "/tmp/x",
1375 "question": "approve?",
1376 }),
1377 };
1378 assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
1379 let areply = ParentFrame::ApprovalReply {
1380 id: "a1".into(),
1381 approved: true,
1382 };
1383 assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
1384 }
1385
1386 #[test]
1387 fn run_frame_tag_is_stable() {
1388 let f = ParentFrame::Run(RunSpec {
1389 assignment: "a".into(),
1390 logical_session: None,
1391 project_id: None,
1392 reasoning_effort: Some("high".into()),
1393 permission_policy: None,
1394 messages: Vec::new(),
1395 activation_run_id: None,
1396 execution_epoch: 0,
1397 initial_session_messages: Vec::new(),
1398 secrets: Default::default(),
1399 });
1400 let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
1401 assert_eq!(v["kind"], "run");
1402 assert_eq!(v["assignment"], "a");
1403 assert!(v.get("secrets").is_none());
1404 }
1405
1406 #[test]
1407 fn run_secret_round_trips_but_debug_output_is_redacted() {
1408 let secret = SecretValue::new("bcx1_secret-570");
1409 assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
1410 assert!(!format!(
1411 "{:?}",
1412 RunSecrets {
1413 codex_provider_token: Some(secret.clone()),
1414 }
1415 )
1416 .contains("secret-570"));
1417
1418 let frame = ParentFrame::Run(RunSpec {
1419 assignment: "a".into(),
1420 logical_session: None,
1421 project_id: None,
1422 reasoning_effort: None,
1423 permission_policy: None,
1424 messages: Vec::new(),
1425 activation_run_id: None,
1426 execution_epoch: 0,
1427 initial_session_messages: Vec::new(),
1428 secrets: RunSecrets {
1429 codex_provider_token: Some(secret),
1430 },
1431 });
1432 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
1433 assert_eq!(decoded, frame);
1434 }
1435
1436 #[test]
1437 fn permission_policy_context_round_trips_at_run_boundary() {
1438 let context = PermissionPolicyContext {
1439 revision: 9,
1440 requested_mode: "bypass".into(),
1441 effective_mode: "bypass".into(),
1442 bypass_permissions: true,
1443 auto_approve_permissions: false,
1444 session_id: "child-1".into(),
1445 workspace_path: Some("/workspace/project".into()),
1446 environment_lease: None,
1447 inherit_session_grants: false,
1448 policy: serde_json::json!({"enabled":true,"durable_rules":[]}),
1449 };
1450 let frame = ParentFrame::Run(RunSpec {
1451 assignment: "work".into(),
1452 logical_session: None,
1453 project_id: None,
1454 reasoning_effort: None,
1455 permission_policy: Some(context.clone()),
1456 messages: Vec::new(),
1457 activation_run_id: None,
1458 execution_epoch: 0,
1459 initial_session_messages: Vec::new(),
1460 secrets: Default::default(),
1461 });
1462 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
1463 assert_eq!(decoded, frame);
1464 let ParentFrame::Run(run) = decoded else {
1465 panic!("expected run frame");
1466 };
1467 assert_eq!(run.permission_policy, Some(context));
1468 }
1469
1470 #[test]
1471 fn legacy_permission_policy_context_defaults_auto_to_false() {
1472 let frame = ParentFrame::from_text(
1473 r#"{"kind":"run","assignment":"work","permission_policy":{"revision":8,"bypass_permissions":true,"session_id":"legacy-child","inherit_session_grants":false,"policy":{}}}"#,
1474 )
1475 .unwrap();
1476 let ParentFrame::Run(run) = frame else {
1477 panic!("expected run frame");
1478 };
1479 let context = run.permission_policy.expect("permission policy");
1480 assert!(context.bypass_permissions);
1481 assert!(!context.auto_approve_permissions);
1482 assert_eq!(
1483 context.resolved_modes().unwrap(),
1484 (
1485 bamboo_domain::SessionPermissionMode::Bypass,
1486 bamboo_domain::PermissionMode::BypassPermissions,
1487 )
1488 );
1489 }
1490
1491 #[test]
1492 fn permission_policy_rejects_partial_typed_mode_pairs() {
1493 let context = PermissionPolicyContext {
1494 revision: 1,
1495 requested_mode: "auto".to_string(),
1496 effective_mode: String::new(),
1497 bypass_permissions: false,
1498 auto_approve_permissions: true,
1499 session_id: "partial-policy".to_string(),
1500 workspace_path: None,
1501 environment_lease: None,
1502 inherit_session_grants: false,
1503 policy: serde_json::json!({}),
1504 };
1505 assert!(context
1506 .resolved_modes()
1507 .unwrap_err()
1508 .contains("provided together"));
1509
1510 let effective_only = PermissionPolicyContext {
1511 requested_mode: String::new(),
1512 effective_mode: "auto".to_string(),
1513 ..context
1514 };
1515 assert!(effective_only
1516 .resolved_modes()
1517 .unwrap_err()
1518 .contains("provided together"));
1519 }
1520
1521 #[test]
1522 fn run_frame_without_messages_parses_backward_compat() {
1523 let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
1525 match parsed {
1526 ParentFrame::Run(spec) => {
1527 assert_eq!(spec.assignment, "x");
1528 assert!(spec.messages.is_empty());
1529 }
1530 other => panic!("expected run frame, got {other:?}"),
1531 }
1532 }
1533
1534 #[test]
1535 fn run_frame_round_trips_typed_project_identity() {
1536 let frame = ParentFrame::Run(RunSpec {
1537 assignment: "work".into(),
1538 logical_session: None,
1539 project_id: Some(ProjectId::parse("project-1").unwrap()),
1540 reasoning_effort: None,
1541 permission_policy: None,
1542 messages: Vec::new(),
1543 activation_run_id: None,
1544 execution_epoch: 0,
1545 initial_session_messages: Vec::new(),
1546 secrets: Default::default(),
1547 });
1548
1549 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
1550 assert_eq!(decoded, frame);
1551 }
1552
1553 #[test]
1554 fn run_frame_rejects_unsafe_project_identity() {
1555 let error =
1556 ParentFrame::from_text(r#"{"kind":"run","assignment":"x","project_id":"../other"}"#)
1557 .unwrap_err();
1558
1559 assert!(error.to_string().contains("invalid project id"));
1560 }
1561
1562 #[test]
1563 fn actor_event_batcher_sequences_and_separates_qos() {
1564 let spec = RunSpec {
1565 assignment: "work".into(),
1566 logical_session: Some(LogicalSessionIdentity {
1567 creation: None,
1568 session_id: "child".into(),
1569 parent_session_id: Some("parent".into()),
1570 root_session_id: "root".into(),
1571 }),
1572 project_id: None,
1573 reasoning_effort: None,
1574 permission_policy: None,
1575 messages: Vec::new(),
1576 activation_run_id: Some("activation-1".into()),
1577 execution_epoch: 4,
1578 initial_session_messages: Vec::new(),
1579 secrets: Default::default(),
1580 };
1581 let mut batcher =
1582 ActorEventBatcher::for_run(&spec, Some("node-a".into()), Some("actor-a".into()));
1583 assert!(batcher
1584 .push(serde_json::json!({"type":"token","content":"a"}))
1585 .is_empty());
1586 assert!(batcher
1587 .push(serde_json::json!({"type":"token","content":"b"}))
1588 .is_empty());
1589
1590 let ready = batcher.push(serde_json::json!({
1591 "type":"tool_start",
1592 "tool_call_id":"t1",
1593 "tool_name":"Read",
1594 "arguments":{}
1595 }));
1596 assert_eq!(ready.len(), 2);
1597 assert_eq!(ready[0].qos, ActorEventQos::Ephemeral);
1598 assert_eq!((ready[0].first_seq, ready[0].last_seq), (1, 2));
1599 assert_eq!(ready[1].qos, ActorEventQos::Durable);
1600 assert_eq!((ready[1].first_seq, ready[1].last_seq), (3, 3));
1601 assert!(ready.iter().all(|batch| batch.validate().is_ok()));
1602 assert_eq!(ready[1].activation_id.as_deref(), Some("activation-1"));
1603 assert_eq!(ready[1].execution_epoch, 4);
1604 assert!(!batcher.has_pending());
1605 }
1606
1607 #[test]
1608 fn actor_event_batch_rejects_qos_downgrade_and_bad_range() {
1609 let mut batch = ActorEventBatch {
1610 logical_session: None,
1611 activation_id: None,
1612 execution_epoch: 0,
1613 source_node_id: None,
1614 source_actor_id: None,
1615 first_seq: 1,
1616 last_seq: 1,
1617 qos: ActorEventQos::Ephemeral,
1618 events: vec![serde_json::json!({"type":"tool_start"})],
1619 };
1620 assert!(batch.validate().unwrap_err().contains("QoS"));
1621 batch.qos = ActorEventQos::Durable;
1622 batch.last_seq = 2;
1623 assert!(batch.validate().unwrap_err().contains("sequence"));
1624 }
1625
1626 #[test]
1627 fn strict_history_upgrade_preserves_bounded_token_coalescing() {
1628 let spec: RunSpec = serde_json::from_value(serde_json::json!({
1629 "assignment":"read", "execution_epoch":1
1630 }))
1631 .unwrap();
1632 let mut batcher = ActorEventBatcher::for_run(&spec, None, None).with_durable_events(true);
1633 for _ in 1..MAX_ACTOR_EVENT_BATCH_EVENTS {
1634 assert!(batcher
1635 .push(serde_json::json!({"type":"token","content":"x"}))
1636 .is_empty());
1637 }
1638 let batch = batcher
1639 .push(serde_json::json!({"type":"token","content":"x"}))
1640 .pop()
1641 .unwrap();
1642 assert_eq!(batch.qos, ActorEventQos::Durable);
1643 assert_eq!(batch.events.len(), MAX_ACTOR_EVENT_BATCH_EVENTS);
1644 assert_eq!(
1645 (batch.first_seq, batch.last_seq),
1646 (1, MAX_ACTOR_EVENT_BATCH_EVENTS as u64)
1647 );
1648 assert!(batch.validate().is_ok());
1649 assert!(!batcher.has_pending());
1650 }
1651
1652 #[test]
1653 fn task_item_progress_delta_is_never_put_on_a_lossy_lane() {
1654 let event = serde_json::json!({
1655 "type": "task_list_item_progress",
1656 "session_id": "child",
1657 "item_id": "task-1",
1658 "status": "in_progress",
1659 "tool_calls_count": 2,
1660 "version": 3
1661 });
1662 assert_eq!(ActorEventQos::classify(&event), ActorEventQos::Durable);
1663 }
1664}
1665
1666#[cfg(test)]
1667mod watermark_tests;