1use bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AgentRecord {
13 pub agent_id: String,
14 pub role: String,
15 #[serde(default)]
16 pub labels: Vec<String>,
17 pub endpoint: String,
19 pub pid: u32,
20 #[serde(default)]
21 pub version: String,
22 pub started_at: DateTime<Utc>,
23 pub lease_expires_at: DateTime<Utc>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct RunSpec {
30 pub assignment: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub logical_session: Option<LogicalSessionIdentity>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub project_id: Option<ProjectId>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub reasoning_effort: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub permission_policy: Option<PermissionPolicyContext>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub messages: Vec<serde_json::Value>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub activation_run_id: Option<String>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub initial_session_messages: Vec<SessionMessageDelivery>,
65 #[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
68 pub secrets: RunSecrets,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct LogicalSessionIdentity {
74 pub session_id: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub parent_session_id: Option<String>,
77 pub root_session_id: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct SessionMessageDelivery {
85 pub target_session_id: String,
86 pub envelope: SessionMessageEnvelope,
87 pub canonical_claim_generation: u64,
88 pub activation_run_id: String,
89 #[serde(default)]
92 pub activation_policy: SessionActivationPolicy,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct SessionMessageAdmissionConfirmation {
100 pub target_session_id: String,
101 pub envelope_id: String,
102 pub canonical_claim_generation: u64,
103 pub activation_run_id: String,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109pub struct RunSecrets {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub codex_provider_token: Option<SecretValue>,
112}
113
114impl RunSecrets {
115 pub fn is_empty(&self) -> bool {
116 self.codex_provider_token.is_none()
117 }
118}
119
120#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(transparent)]
123pub struct SecretValue(String);
124
125impl SecretValue {
126 pub fn new(value: impl Into<String>) -> Self {
127 Self(value.into())
128 }
129
130 pub fn expose(&self) -> &str {
131 &self.0
132 }
133}
134
135impl std::fmt::Debug for SecretValue {
136 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 formatter.write_str("SecretValue([REDACTED])")
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct PermissionPolicyContext {
145 pub revision: u64,
146 pub bypass_permissions: bool,
147 pub session_id: String,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub workspace_path: Option<String>,
150 #[serde(default)]
153 pub inherit_session_grants: bool,
154 pub policy: serde_json::Value,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(tag = "kind", rename_all = "snake_case")]
160pub enum ParentFrame {
161 Run(RunSpec),
162 Cancel,
163 Message {
164 text: String,
165 },
166 SessionMessage {
167 delivery: SessionMessageDelivery,
168 },
169 ApprovalReply {
175 id: String,
176 approved: bool,
177 },
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(tag = "kind", rename_all = "snake_case")]
183pub enum ChildFrame {
184 Event { event: serde_json::Value },
186 ApprovalRequest { id: String, body: serde_json::Value },
192 SessionMessageAdmitted {
195 confirmation: SessionMessageAdmissionConfirmation,
196 },
197 Terminal {
198 status: TerminalStatus,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 result: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 error: Option<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 transcript: Vec<serde_json::Value>,
208 },
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum TerminalStatus {
214 Completed,
215 Error,
216 Cancelled,
217 Suspended,
221}
222
223impl ParentFrame {
224 pub fn to_text(&self) -> String {
225 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
226 }
227 pub fn from_text(s: &str) -> serde_json::Result<Self> {
228 serde_json::from_str(s)
229 }
230}
231
232impl ChildFrame {
233 pub fn to_text(&self) -> String {
234 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
235 }
236 pub fn from_text(s: &str) -> serde_json::Result<Self> {
237 serde_json::from_str(s)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn parent_frames_round_trip() {
247 for f in [
248 ParentFrame::Run(RunSpec {
249 assignment: "do x".into(),
250 logical_session: None,
251 project_id: None,
252 reasoning_effort: None,
253 permission_policy: None,
254 messages: Vec::new(),
255 activation_run_id: None,
256 initial_session_messages: Vec::new(),
257 secrets: Default::default(),
258 }),
259 ParentFrame::Cancel,
260 ParentFrame::Message { text: "hi".into() },
261 ] {
262 assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
263 }
264 }
265
266 #[test]
267 fn child_frames_round_trip() {
268 let e = ChildFrame::Event {
269 event: serde_json::json!({"type":"token","content":"hi"}),
270 };
271 assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
272 let t = ChildFrame::Terminal {
273 status: TerminalStatus::Completed,
274 result: Some("done".into()),
275 error: None,
276 transcript: Vec::new(),
277 };
278 assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
279
280 let s = ChildFrame::Terminal {
282 status: TerminalStatus::Suspended,
283 result: None,
284 error: None,
285 transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
286 };
287 assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
288
289 let areq = ChildFrame::ApprovalRequest {
291 id: "a1".into(),
292 body: serde_json::json!({
293 "tool_name": "Write",
294 "permission_type": "WriteFile",
295 "resource": "/tmp/x",
296 "question": "approve?",
297 }),
298 };
299 assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
300 let areply = ParentFrame::ApprovalReply {
301 id: "a1".into(),
302 approved: true,
303 };
304 assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
305 }
306
307 #[test]
308 fn run_frame_tag_is_stable() {
309 let f = ParentFrame::Run(RunSpec {
310 assignment: "a".into(),
311 logical_session: None,
312 project_id: None,
313 reasoning_effort: Some("high".into()),
314 permission_policy: None,
315 messages: Vec::new(),
316 activation_run_id: None,
317 initial_session_messages: Vec::new(),
318 secrets: Default::default(),
319 });
320 let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
321 assert_eq!(v["kind"], "run");
322 assert_eq!(v["assignment"], "a");
323 assert!(v.get("secrets").is_none());
324 }
325
326 #[test]
327 fn run_secret_round_trips_but_debug_output_is_redacted() {
328 let secret = SecretValue::new("bcx1_secret-570");
329 assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
330 assert!(!format!(
331 "{:?}",
332 RunSecrets {
333 codex_provider_token: Some(secret.clone()),
334 }
335 )
336 .contains("secret-570"));
337
338 let frame = ParentFrame::Run(RunSpec {
339 assignment: "a".into(),
340 logical_session: None,
341 project_id: None,
342 reasoning_effort: None,
343 permission_policy: None,
344 messages: Vec::new(),
345 activation_run_id: None,
346 initial_session_messages: Vec::new(),
347 secrets: RunSecrets {
348 codex_provider_token: Some(secret),
349 },
350 });
351 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
352 assert_eq!(decoded, frame);
353 }
354
355 #[test]
356 fn permission_policy_context_round_trips_at_run_boundary() {
357 let context = PermissionPolicyContext {
358 revision: 9,
359 bypass_permissions: true,
360 session_id: "child-1".into(),
361 workspace_path: Some("/workspace/project".into()),
362 inherit_session_grants: false,
363 policy: serde_json::json!({"enabled":true,"durable_rules":[]}),
364 };
365 let frame = ParentFrame::Run(RunSpec {
366 assignment: "work".into(),
367 logical_session: None,
368 project_id: None,
369 reasoning_effort: None,
370 permission_policy: Some(context.clone()),
371 messages: Vec::new(),
372 activation_run_id: None,
373 initial_session_messages: Vec::new(),
374 secrets: Default::default(),
375 });
376 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
377 assert_eq!(decoded, frame);
378 let ParentFrame::Run(run) = decoded else {
379 panic!("expected run frame");
380 };
381 assert_eq!(run.permission_policy, Some(context));
382 }
383
384 #[test]
385 fn run_frame_without_messages_parses_backward_compat() {
386 let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
388 match parsed {
389 ParentFrame::Run(spec) => {
390 assert_eq!(spec.assignment, "x");
391 assert!(spec.messages.is_empty());
392 }
393 other => panic!("expected run frame, got {other:?}"),
394 }
395 }
396
397 #[test]
398 fn run_frame_round_trips_typed_project_identity() {
399 let frame = ParentFrame::Run(RunSpec {
400 assignment: "work".into(),
401 logical_session: None,
402 project_id: Some(ProjectId::parse("project-1").unwrap()),
403 reasoning_effort: None,
404 permission_policy: None,
405 messages: Vec::new(),
406 activation_run_id: None,
407 initial_session_messages: Vec::new(),
408 secrets: Default::default(),
409 });
410
411 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
412 assert_eq!(decoded, frame);
413 }
414
415 #[test]
416 fn run_frame_rejects_unsafe_project_identity() {
417 let error =
418 ParentFrame::from_text(r#"{"kind":"run","assignment":"x","project_id":"../other"}"#)
419 .unwrap_err();
420
421 assert!(error.to_string().contains("invalid project id"));
422 }
423}