use bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentRecord {
pub agent_id: String,
pub role: String,
#[serde(default)]
pub labels: Vec<String>,
pub endpoint: String,
pub pid: u32,
#[serde(default)]
pub version: String,
pub started_at: DateTime<Utc>,
pub lease_expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSpec {
pub assignment: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logical_session: Option<LogicalSessionIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<ProjectId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_policy: Option<PermissionPolicyContext>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation_run_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_session_messages: Vec<SessionMessageDelivery>,
#[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
pub secrets: RunSecrets,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogicalSessionIdentity {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<String>,
pub root_session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionMessageDelivery {
pub target_session_id: String,
pub envelope: SessionMessageEnvelope,
pub canonical_claim_generation: u64,
pub activation_run_id: String,
#[serde(default)]
pub activation_policy: SessionActivationPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionMessageAdmissionConfirmation {
pub target_session_id: String,
pub envelope_id: String,
pub canonical_claim_generation: u64,
pub activation_run_id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RunSecrets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider_token: Option<SecretValue>,
}
impl RunSecrets {
pub fn is_empty(&self) -> bool {
self.codex_provider_token.is_none()
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SecretValue(String);
impl SecretValue {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for SecretValue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("SecretValue([REDACTED])")
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PermissionPolicyContext {
pub revision: u64,
pub bypass_permissions: bool,
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_path: Option<String>,
#[serde(default)]
pub inherit_session_grants: bool,
pub policy: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ParentFrame {
Run(RunSpec),
Cancel,
Message {
text: String,
},
SessionMessage {
delivery: SessionMessageDelivery,
},
ApprovalReply {
id: String,
approved: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ChildFrame {
Event { event: serde_json::Value },
ApprovalRequest { id: String, body: serde_json::Value },
SessionMessageAdmitted {
confirmation: SessionMessageAdmissionConfirmation,
},
Terminal {
status: TerminalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
transcript: Vec<serde_json::Value>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TerminalStatus {
Completed,
Error,
Cancelled,
Suspended,
}
impl ParentFrame {
pub fn to_text(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn from_text(s: &str) -> serde_json::Result<Self> {
serde_json::from_str(s)
}
}
impl ChildFrame {
pub fn to_text(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn from_text(s: &str) -> serde_json::Result<Self> {
serde_json::from_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parent_frames_round_trip() {
for f in [
ParentFrame::Run(RunSpec {
assignment: "do x".into(),
logical_session: None,
project_id: None,
reasoning_effort: None,
permission_policy: None,
messages: Vec::new(),
activation_run_id: None,
initial_session_messages: Vec::new(),
secrets: Default::default(),
}),
ParentFrame::Cancel,
ParentFrame::Message { text: "hi".into() },
] {
assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
}
}
#[test]
fn child_frames_round_trip() {
let e = ChildFrame::Event {
event: serde_json::json!({"type":"token","content":"hi"}),
};
assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
let t = ChildFrame::Terminal {
status: TerminalStatus::Completed,
result: Some("done".into()),
error: None,
transcript: Vec::new(),
};
assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
let s = ChildFrame::Terminal {
status: TerminalStatus::Suspended,
result: None,
error: None,
transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
};
assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
let areq = ChildFrame::ApprovalRequest {
id: "a1".into(),
body: serde_json::json!({
"tool_name": "Write",
"permission_type": "WriteFile",
"resource": "/tmp/x",
"question": "approve?",
}),
};
assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
let areply = ParentFrame::ApprovalReply {
id: "a1".into(),
approved: true,
};
assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
}
#[test]
fn run_frame_tag_is_stable() {
let f = ParentFrame::Run(RunSpec {
assignment: "a".into(),
logical_session: None,
project_id: None,
reasoning_effort: Some("high".into()),
permission_policy: None,
messages: Vec::new(),
activation_run_id: None,
initial_session_messages: Vec::new(),
secrets: Default::default(),
});
let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
assert_eq!(v["kind"], "run");
assert_eq!(v["assignment"], "a");
assert!(v.get("secrets").is_none());
}
#[test]
fn run_secret_round_trips_but_debug_output_is_redacted() {
let secret = SecretValue::new("bcx1_secret-570");
assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
assert!(!format!(
"{:?}",
RunSecrets {
codex_provider_token: Some(secret.clone()),
}
)
.contains("secret-570"));
let frame = ParentFrame::Run(RunSpec {
assignment: "a".into(),
logical_session: None,
project_id: None,
reasoning_effort: None,
permission_policy: None,
messages: Vec::new(),
activation_run_id: None,
initial_session_messages: Vec::new(),
secrets: RunSecrets {
codex_provider_token: Some(secret),
},
});
let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
assert_eq!(decoded, frame);
}
#[test]
fn permission_policy_context_round_trips_at_run_boundary() {
let context = PermissionPolicyContext {
revision: 9,
bypass_permissions: true,
session_id: "child-1".into(),
workspace_path: Some("/workspace/project".into()),
inherit_session_grants: false,
policy: serde_json::json!({"enabled":true,"durable_rules":[]}),
};
let frame = ParentFrame::Run(RunSpec {
assignment: "work".into(),
logical_session: None,
project_id: None,
reasoning_effort: None,
permission_policy: Some(context.clone()),
messages: Vec::new(),
activation_run_id: None,
initial_session_messages: Vec::new(),
secrets: Default::default(),
});
let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
assert_eq!(decoded, frame);
let ParentFrame::Run(run) = decoded else {
panic!("expected run frame");
};
assert_eq!(run.permission_policy, Some(context));
}
#[test]
fn run_frame_without_messages_parses_backward_compat() {
let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
match parsed {
ParentFrame::Run(spec) => {
assert_eq!(spec.assignment, "x");
assert!(spec.messages.is_empty());
}
other => panic!("expected run frame, got {other:?}"),
}
}
#[test]
fn run_frame_round_trips_typed_project_identity() {
let frame = ParentFrame::Run(RunSpec {
assignment: "work".into(),
logical_session: None,
project_id: Some(ProjectId::parse("project-1").unwrap()),
reasoning_effort: None,
permission_policy: None,
messages: Vec::new(),
activation_run_id: None,
initial_session_messages: Vec::new(),
secrets: Default::default(),
});
let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
assert_eq!(decoded, frame);
}
#[test]
fn run_frame_rejects_unsafe_project_identity() {
let error =
ParentFrame::from_text(r#"{"kind":"run","assignment":"x","project_id":"../other"}"#)
.unwrap_err();
assert!(error.to_string().contains("invalid project id"));
}
}