1use std::sync::Arc;
4
5use bamboo_agent_core::{AgentError, AgentEvent, AgentHook, Message, Session};
6use bamboo_domain::{
7 AgentHookPoint, AgentRuntimeState, AgentStatusState, HookCheckpoint, HookPayload, HookResult,
8 SessionEndStatus, SuspensionState,
9};
10use chrono::Utc;
11use tokio::sync::mpsc;
12
13pub use bamboo_hooks::{
14 test_lifecycle_handler, test_lifecycle_shell_command, HookRunOutcome, LifecycleHookEvent,
15 LifecycleHookTestOutput, LifecycleScriptRunner, ScriptHook, ShellCommandHook, ShellHookEvent,
16 ShellHookTestOutput,
17};
18
19#[derive(Clone)]
24pub struct HookRunner {
25 dispatcher: bamboo_hooks::HookDispatcher,
26 plugin_root: Option<std::path::PathBuf>,
27}
28
29impl HookRunner {
30 pub fn new() -> Self {
31 Self {
32 dispatcher: bamboo_hooks::HookDispatcher::new(),
33 plugin_root: None,
34 }
35 }
36
37 pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
38 self.dispatcher.register(hook);
39 }
40
41 pub fn with_lifecycle_config(
44 &self,
45 config: &bamboo_config::LifecycleHooksConfig,
46 fallback_cwd: Option<std::path::PathBuf>,
47 ) -> Self {
48 Self {
49 plugin_root: fallback_cwd.as_ref().map(|root| root.join("plugins")),
50 dispatcher: self.dispatcher.with_lifecycle_config(config, fallback_cwd),
51 }
52 }
53
54 pub fn mark_user_prompt_prechecked(session: &mut Session, prompt: &str) {
58 session.metadata.insert(
59 "runtime.plugin_prompt_prechecked".into(),
60 prompt_fingerprint(prompt),
61 );
62 }
63
64 pub(crate) async fn apply_portable_user_prompt(
67 &self,
68 session: &mut Session,
69 prompt: &str,
70 ) -> Result<String, AgentError> {
71 let prechecked = session.metadata.remove("runtime.plugin_prompt_prechecked");
72 if prechecked.as_deref() == Some(prompt_fingerprint(prompt).as_str()) {
73 return Ok(prompt.to_owned());
74 }
75 let Some(root) = &self.plugin_root else {
76 return Ok(prompt.to_owned());
77 };
78 let report = bamboo_hooks::portable::run(
79 root,
80 AgentHookPoint::BeforeSessionSetup,
81 &HookPayload::Prompt {
82 prompt: prompt.to_owned(),
83 },
84 session,
85 )
86 .await;
87 for error in &report.errors {
88 tracing::warn!(%error, "plugin prompt hook compatibility failure");
89 }
90 match report.decision {
91 HookResult::Deny { reason } | HookResult::Abort { reason } => {
92 return Err(AgentError::Tool(format!(
93 "UserPromptSubmit hook rejected prompt: {reason}"
94 )));
95 }
96 _ => {}
97 }
98 let contexts = report
99 .contexts
100 .into_iter()
101 .map(|context| context.rendered_text())
102 .collect::<Vec<_>>();
103 if contexts.is_empty() {
104 Ok(prompt.to_owned())
105 } else {
106 Ok(format!(
107 "{prompt}\n\n<user_prompt_submit_context>\n{}\n</user_prompt_submit_context>",
108 contexts.join("\n\n---\n\n")
109 ))
110 }
111 }
112
113 pub async fn run_hooks(
118 &self,
119 point: AgentHookPoint,
120 payload: &HookPayload,
121 session: &Session,
122 runtime_state: &mut AgentRuntimeState,
123 event_tx: Option<&mpsc::Sender<AgentEvent>>,
124 ) -> HookRunOutcome {
125 self.run_hooks_with_tool_input(point, payload, session, runtime_state, event_tx, None)
126 .await
127 }
128
129 pub async fn run_hooks_with_tool_input(
130 &self,
131 point: AgentHookPoint,
132 payload: &HookPayload,
133 session: &Session,
134 runtime_state: &mut AgentRuntimeState,
135 event_tx: Option<&mpsc::Sender<AgentEvent>>,
136 original_tool_input: Option<&serde_json::Value>,
137 ) -> HookRunOutcome {
138 self.run_hooks_with_inputs(
139 point,
140 payload,
141 session,
142 runtime_state,
143 event_tx,
144 bamboo_hooks::portable::PortableInputs {
145 original_tool_input,
146 ..Default::default()
147 },
148 )
149 .await
150 }
151
152 pub async fn run_hooks_with_inputs(
153 &self,
154 point: AgentHookPoint,
155 payload: &HookPayload,
156 session: &Session,
157 runtime_state: &mut AgentRuntimeState,
158 event_tx: Option<&mpsc::Sender<AgentEvent>>,
159 inputs: bamboo_hooks::portable::PortableInputs<'_>,
160 ) -> HookRunOutcome {
161 let report = self.dispatcher.run_hooks(point, payload, session).await;
162 let mut outcome = record_dispatch_report(report, runtime_state, event_tx).await;
163 if let Some(root) = &self.plugin_root {
164 if matches!(
165 outcome.decision,
166 HookResult::Continue | HookResult::Allow | HookResult::Mutated
167 ) {
168 let mut portable_payload = payload.clone();
169 if let Some(name) = inputs.resolved_tool_name {
170 match &mut portable_payload {
171 HookPayload::ToolExecution { tool_name, .. }
172 | HookPayload::ToolResult { tool_name, .. } => *tool_name = name.to_owned(),
173 _ => {}
174 }
175 }
176 let plugin = bamboo_hooks::portable::run_with_inputs(
177 root,
178 point,
179 &portable_payload,
180 session,
181 inputs.original_tool_input,
182 inputs.final_assistant_content,
183 )
184 .await;
185 for error in plugin.errors {
186 tracing::warn!(%error, "plugin hook compatibility failure");
187 runtime_state.checkpoints.push(HookCheckpoint {
188 hook_point: format!("plugin:{point:?}"),
189 timestamp: Utc::now(),
190 result: error.chars().take(1024).collect(),
191 duration_ms: 0,
192 });
193 }
194 if !matches!(plugin.decision, HookResult::Continue) {
195 outcome.decision = plugin.decision;
196 }
197 outcome.plugin_contexts = plugin.contexts;
198 }
199 }
200 outcome
201 }
202
203 pub async fn run_observer_hooks(
209 &self,
210 point: AgentHookPoint,
211 payload: &HookPayload,
212 session: &Session,
213 runtime_state: &mut AgentRuntimeState,
214 event_tx: Option<&mpsc::Sender<AgentEvent>>,
215 ) -> HookRunOutcome {
216 let report = self
217 .dispatcher
218 .run_observer_hooks(point, payload, session)
219 .await;
220 record_dispatch_report(report, runtime_state, event_tx).await
221 }
222
223 pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
224 self.dispatcher.has_hooks_for(point)
225 || (self
226 .plugin_root
227 .as_ref()
228 .is_some_and(|root| bamboo_hooks::portable::has_active_hooks_for(root, point)))
229 }
230
231 pub fn len(&self) -> usize {
232 self.dispatcher.len()
233 }
234
235 pub fn is_empty(&self) -> bool {
236 self.dispatcher.is_empty()
237 && !self.plugin_root.as_ref().is_some_and(|root| {
238 [
239 AgentHookPoint::BeforeSessionSetup,
240 AgentHookPoint::BeforeToolExecution,
241 AgentHookPoint::AfterToolExecution,
242 AgentHookPoint::BeforeFinalize,
243 ]
244 .into_iter()
245 .any(|point| bamboo_hooks::portable::has_active_hooks_for(root, point))
246 })
247 }
248}
249
250fn prompt_fingerprint(prompt: &str) -> String {
251 use sha2::{Digest, Sha256};
252 hex::encode(Sha256::digest(prompt.as_bytes()))
253}
254
255async fn record_dispatch_report(
256 report: bamboo_hooks::HookDispatchReport,
257 runtime_state: &mut AgentRuntimeState,
258 event_tx: Option<&mpsc::Sender<AgentEvent>>,
259) -> HookRunOutcome {
260 let bamboo_hooks::HookDispatchReport {
261 outcome,
262 executions,
263 } = report;
264 for execution in executions {
265 runtime_state.checkpoints.push(HookCheckpoint {
266 hook_point: format!("{:?}", execution.point),
267 timestamp: Utc::now(),
268 result: format!("{:?}", execution.result),
269 duration_ms: execution.duration_ms,
270 });
271 if let Some(event_tx) = event_tx {
272 let _ = event_tx
273 .send(AgentEvent::HookLifecycle {
274 hook_name: execution.hook_name,
275 point: execution.point,
276 phase: "completed".to_string(),
277 duration_ms: execution.duration_ms,
278 decision: execution.result,
279 })
280 .await;
281 }
282 }
283 outcome
284}
285
286pub(crate) async fn run_session_end_hooks(
290 runner: &HookRunner,
291 result: &Result<(), AgentError>,
292 session: &mut Session,
293 event_tx: &mpsc::Sender<AgentEvent>,
294) {
295 let suspended_non_terminal = result.is_ok()
296 && session
297 .metadata
298 .get("runtime.suspend_reason")
299 .is_some_and(|reason| !reason.trim().is_empty());
300 if suspended_non_terminal || !runner.has_hooks_for(AgentHookPoint::AfterSessionEnd) {
301 return;
302 }
303
304 let (status, completion_reason) = match result {
305 Ok(()) => (
306 SessionEndStatus::Completed,
307 session
308 .metadata
309 .get("runtime.completion_reason")
310 .cloned()
311 .or_else(|| Some("completed".to_string())),
312 ),
313 Err(error) if error.is_cancelled() => {
314 (SessionEndStatus::Cancelled, Some(error.to_string()))
315 }
316 Err(error) => (SessionEndStatus::Failed, Some(error.to_string())),
317 };
318 let mut runtime_state = session
319 .agent_runtime_state
320 .clone()
321 .unwrap_or_else(|| AgentRuntimeState::new(&session.id));
322 runner
323 .run_observer_hooks(
324 AgentHookPoint::AfterSessionEnd,
325 &HookPayload::SessionEnd {
326 status,
327 completion_reason,
328 },
329 session,
330 &mut runtime_state,
331 Some(event_tx),
332 )
333 .await;
334 session.agent_runtime_state = Some(runtime_state);
335}
336
337pub(crate) fn apply_hook_outcome(
340 point: AgentHookPoint,
341 outcome: HookRunOutcome,
342 session: &mut Session,
343 runtime_state: &mut AgentRuntimeState,
344) -> Result<(), AgentError> {
345 inject_plugin_contexts(session, outcome.plugin_contexts);
346 if matches!(point, AgentHookPoint::AfterSessionSetup) {
347 runtime_state.hook_contexts.extend(
348 outcome
349 .injected_contexts
350 .into_iter()
351 .filter(|text| !text.trim().is_empty()),
352 );
353 } else {
354 inject_contexts(session, point, outcome.injected_contexts);
355 }
356
357 match outcome.decision {
358 HookResult::Continue
359 | HookResult::Mutated
360 | HookResult::Allow
361 | HookResult::InjectContext { .. } => Ok(()),
362 HookResult::Suspend { reason } => {
363 let hook_point = format!("{point:?}");
364 runtime_state.status = AgentStatusState::Suspended;
365 runtime_state.suspension = Some(SuspensionState {
366 reason: reason.clone(),
367 suspended_at: Utc::now(),
368 resumable: true,
369 hook_point: Some(hook_point.clone()),
370 });
371 session.metadata.insert(
372 "runtime.suspend_reason".to_string(),
373 "hook_suspended".to_string(),
374 );
375 Err(AgentError::HookSuspended(format!("{hook_point}: {reason}")))
376 }
377 HookResult::Abort { reason } => Err(AgentError::Tool(format!(
378 "hook aborted at {point:?}: {reason}"
379 ))),
380 HookResult::Deny { reason } => Err(AgentError::Tool(format!(
381 "hook denied lifecycle seam {point:?}: {reason}"
382 ))),
383 HookResult::Ask => Err(AgentError::Tool(format!(
384 "hook requested parent approval at non-tool seam {point:?}"
385 ))),
386 HookResult::WithContext { result, text } => apply_hook_outcome(
387 point,
388 HookRunOutcome {
389 decision: *result,
390 injected_contexts: vec![text],
391 plugin_contexts: vec![],
392 },
393 session,
394 runtime_state,
395 ),
396 }
397}
398
399pub(crate) fn inject_plugin_contexts(
402 session: &mut Session,
403 contexts: Vec<bamboo_hooks::portable::PluginContext>,
404) {
405 if contexts.is_empty() {
406 return;
407 }
408 let key = "runtime.plugin_hook_contexts";
409 let mut chunks: Vec<String> = session
410 .metadata
411 .get(key)
412 .and_then(|value| serde_json::from_str(value).ok())
413 .unwrap_or_default();
414 chunks.extend(contexts.into_iter().map(|context| context.rendered_text()));
415 while chunks.iter().map(String::len).sum::<usize>() + chunks.len().saturating_sub(1) * 2
416 > bamboo_hooks::portable::PORTABLE_CONTEXT_BYTES
417 {
418 if chunks.is_empty() {
419 break;
420 }
421 chunks.remove(0);
422 }
423 session.metadata.insert(
424 key.into(),
425 serde_json::to_string(&chunks).expect("string serialization"),
426 );
427}
428
429pub(crate) fn inject_contexts(
430 session: &mut Session,
431 point: AgentHookPoint,
432 injected_contexts: Vec<String>,
433) {
434 for text in injected_contexts {
435 if text.trim().is_empty() {
436 continue;
437 }
438 let block =
439 format!("\n\n<agent_hook_context point=\"{point:?}\">\n{text}\n</agent_hook_context>");
440 if let Some(system_message) = session
441 .messages
442 .iter_mut()
443 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
444 {
445 system_message.content.push_str(&block);
446 system_message.never_compress = true;
447 } else {
448 let mut message = Message::system(block.trim().to_string());
449 message.never_compress = true;
450 message.metadata = Some(serde_json::json!({
451 "runtime_kind": "hook_context",
452 "hook_point": point,
453 }));
454 session.add_message(message);
455 }
456 }
457}
458
459pub(crate) fn merge_session_hook_checkpoints(
463 session: &Session,
464 runtime_state: &mut AgentRuntimeState,
465) {
466 let Some(session_state) = session.agent_runtime_state.as_ref() else {
467 return;
468 };
469 for checkpoint in &session_state.checkpoints {
470 if !runtime_state.checkpoints.contains(checkpoint) {
471 runtime_state.checkpoints.push(checkpoint.clone());
472 }
473 }
474 if matches!(session_state.status, AgentStatusState::Suspended) {
475 runtime_state.status = AgentStatusState::Suspended;
476 runtime_state.suspension = session_state.suspension.clone();
477 }
478}
479
480impl Default for HookRunner {
481 fn default() -> Self {
482 Self::new()
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 struct ContinueHook {
492 point: AgentHookPoint,
493 pri: u32,
494 name: String,
495 }
496
497 #[async_trait::async_trait]
498 impl AgentHook for ContinueHook {
499 fn point(&self) -> AgentHookPoint {
500 self.point
501 }
502
503 async fn run(
504 &self,
505 _point: AgentHookPoint,
506 _payload: &HookPayload,
507 _session: &Session,
508 ) -> HookResult {
509 HookResult::Continue
510 }
511
512 fn priority(&self) -> u32 {
513 self.pri
514 }
515
516 fn name(&self) -> &str {
517 &self.name
518 }
519 }
520
521 struct AbortHook;
523
524 #[async_trait::async_trait]
525 impl AgentHook for AbortHook {
526 fn point(&self) -> AgentHookPoint {
527 AgentHookPoint::BeforeLlmCall
528 }
529
530 async fn run(
531 &self,
532 _point: AgentHookPoint,
533 _payload: &HookPayload,
534 _session: &Session,
535 ) -> HookResult {
536 HookResult::Abort {
537 reason: "test abort".to_string(),
538 }
539 }
540
541 fn name(&self) -> &str {
542 "abort_hook"
543 }
544 }
545
546 fn test_session() -> Session {
547 Session::new("test", "test-model")
548 }
549
550 #[tokio::test]
551 async fn empty_runner_returns_continue() {
552 let runner = HookRunner::new();
553 let mut state = AgentRuntimeState::new("run-1");
554 let session = test_session();
555 let (tx, _rx) = mpsc::channel(4);
556
557 let result = runner
558 .run_hooks(
559 AgentHookPoint::BeforeRound,
560 &HookPayload::Round { round: 1 },
561 &session,
562 &mut state,
563 Some(&tx),
564 )
565 .await;
566
567 assert_eq!(result.decision, HookResult::Continue);
568 assert!(state.checkpoints.is_empty());
569 }
570
571 #[tokio::test]
572 async fn hooks_run_in_priority_order() {
573 let mut runner = HookRunner::new();
574 runner.register(Arc::new(ContinueHook {
575 point: AgentHookPoint::BeforeRound,
576 pri: 200,
577 name: "slow".to_string(),
578 }));
579 runner.register(Arc::new(ContinueHook {
580 point: AgentHookPoint::BeforeRound,
581 pri: 50,
582 name: "fast".to_string(),
583 }));
584
585 let mut state = AgentRuntimeState::new("run-2");
586 let session = test_session();
587 let (tx, mut rx) = mpsc::channel(4);
588
589 let result = runner
590 .run_hooks(
591 AgentHookPoint::BeforeRound,
592 &HookPayload::Round { round: 1 },
593 &session,
594 &mut state,
595 Some(&tx),
596 )
597 .await;
598
599 assert_eq!(result.decision, HookResult::Continue);
600 assert_eq!(state.checkpoints.len(), 2);
601 assert!(state.checkpoints[0].result.contains("Continue"));
603 assert!(matches!(
604 rx.recv().await,
605 Some(AgentEvent::HookLifecycle { hook_name, .. }) if hook_name == "fast"
606 ));
607 }
608
609 #[tokio::test]
610 async fn abort_short_circuits() {
611 let mut runner = HookRunner::new();
612 runner.register(Arc::new(AbortHook));
613
614 let mut state = AgentRuntimeState::new("run-3");
615 let session = test_session();
616 let (tx, _rx) = mpsc::channel(4);
617
618 let result = runner
619 .run_hooks(
620 AgentHookPoint::BeforeLlmCall,
621 &HookPayload::None,
622 &session,
623 &mut state,
624 Some(&tx),
625 )
626 .await;
627
628 assert!(matches!(result.decision, HookResult::Abort { .. }));
629 assert_eq!(state.checkpoints.len(), 1);
630 }
631
632 #[tokio::test]
633 async fn wrong_point_hooks_are_skipped() {
634 let mut runner = HookRunner::new();
635 runner.register(Arc::new(AbortHook)); let mut state = AgentRuntimeState::new("run-4");
638 let session = test_session();
639 let (tx, _rx) = mpsc::channel(4);
640
641 let result = runner
642 .run_hooks(
643 AgentHookPoint::AfterRound,
644 &HookPayload::Round { round: 1 },
645 &session,
646 &mut state,
647 Some(&tx),
648 )
649 .await;
650
651 assert_eq!(result.decision, HookResult::Continue);
652 assert!(state.checkpoints.is_empty());
653 }
654
655 struct RecordingSessionEndHook {
656 payloads: Arc<std::sync::Mutex<Vec<HookPayload>>>,
657 }
658
659 #[async_trait::async_trait]
660 impl AgentHook for RecordingSessionEndHook {
661 fn point(&self) -> AgentHookPoint {
662 AgentHookPoint::AfterSessionEnd
663 }
664
665 async fn run(
666 &self,
667 _point: AgentHookPoint,
668 payload: &HookPayload,
669 _session: &Session,
670 ) -> HookResult {
671 self.payloads.lock().unwrap().push(payload.clone());
672 HookResult::Deny {
675 reason: "ignored cleanup decision".to_string(),
676 }
677 }
678 }
679
680 #[tokio::test]
681 async fn session_end_fires_for_completed_failed_and_cancelled_and_ignores_decisions() {
682 for (result, expected_status) in [
683 (Ok(()), SessionEndStatus::Completed),
684 (
685 Err(AgentError::Tool("terminal failure".to_string())),
686 SessionEndStatus::Failed,
687 ),
688 (Err(AgentError::Cancelled), SessionEndStatus::Cancelled),
689 ] {
690 let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
691 let mut runner = HookRunner::new();
692 runner.register(Arc::new(RecordingSessionEndHook {
693 payloads: payloads.clone(),
694 }));
695 runner.register(Arc::new(RecordingSessionEndHook {
696 payloads: payloads.clone(),
697 }));
698 let mut session = test_session();
699 let (tx, _rx) = mpsc::channel(4);
700
701 run_session_end_hooks(&runner, &result, &mut session, &tx).await;
702
703 let recorded = payloads.lock().unwrap();
704 assert_eq!(
705 recorded.len(),
706 2,
707 "a denied observer must not suppress later cleanup hooks"
708 );
709 assert!(recorded.iter().all(|payload| matches!(
710 payload,
711 HookPayload::SessionEnd { status, .. } if *status == expected_status
712 )));
713 assert_eq!(
714 session
715 .agent_runtime_state
716 .as_ref()
717 .map(|state| state.checkpoints.len()),
718 Some(2)
719 );
720 }
721 }
722
723 #[tokio::test]
724 async fn session_end_skips_suspended_non_terminal_runs() {
725 let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
726 let mut runner = HookRunner::new();
727 runner.register(Arc::new(RecordingSessionEndHook {
728 payloads: payloads.clone(),
729 }));
730 let mut session = test_session();
731 session.metadata.insert(
732 "runtime.suspend_reason".to_string(),
733 "waiting_for_children".to_string(),
734 );
735 let (tx, _rx) = mpsc::channel(4);
736
737 run_session_end_hooks(&runner, &Ok(()), &mut session, &tx).await;
738
739 assert!(payloads.lock().unwrap().is_empty());
740 assert!(session.agent_runtime_state.is_none());
741 }
742}
743
744#[cfg(test)]
745mod portable_context_tests {
746 use super::*;
747 #[test]
748 fn retained_plugin_snapshot_is_bounded_without_damaging_native_context() {
749 let mut session = Session::new("fixture", "model");
750 let mut state = AgentRuntimeState::new("run");
751 state.hook_contexts = vec!["native context".into()];
752 session.agent_runtime_state = Some(state);
753 for _ in 0..100 {
754 inject_plugin_contexts(
755 &mut session,
756 vec![bamboo_hooks::portable::PluginContext {
757 source: "fixture@1.0.0:hooks.json:PreToolUse".into(),
758 text: "x".repeat(200),
759 }],
760 );
761 }
762 let chunks: Vec<String> =
763 serde_json::from_str(&session.metadata["runtime.plugin_hook_contexts"]).unwrap();
764 assert!(chunks.join("\n\n").len() <= bamboo_hooks::portable::PORTABLE_CONTEXT_BYTES);
765 assert!(chunks
766 .iter()
767 .all(|chunk| chunk.starts_with("Plugin hook supplemental context")));
768 assert!(session.messages.is_empty());
769 let block =
770 crate::runtime::runner::session_setup::prompt_envelope::build_agent_hook_context_block(
771 &session,
772 )
773 .unwrap();
774 assert!(block.content.starts_with("native context"));
775 }
776 #[test]
777 fn plugin_context_is_chronological_sourced_and_compressible() {
778 let mut session = Session::new("test", "model");
779 let mut system = Message::system("host authority");
780 system.never_compress = true;
781 session.add_message(system);
782 session.add_message(Message::user("original prompt"));
783 inject_plugin_contexts(
784 &mut session,
785 vec![bamboo_hooks::portable::PluginContext {
786 source: "fixture@0.1.0:hooks.json:PreToolUse".into(),
787 text: "plugin content".into(),
788 }],
789 );
790 assert_eq!(session.messages[0].content, "host authority");
791 assert!(session.messages[0].never_compress);
792 assert_eq!(session.messages.len(), 2);
793 let block =
794 crate::runtime::runner::session_setup::prompt_envelope::build_agent_hook_context_block(
795 &session,
796 )
797 .unwrap();
798 assert!(block.content.contains("fixture@0.1.0"));
799 assert!(block.content.contains("untrusted"));
800 let event = bamboo_domain::ModelContextEvent {
801 id: "fixture".into(),
802 epoch: 0,
803 sequence: 0,
804 anchor_message_id: None,
805 block_type: block.block_type,
806 revision: 1,
807 supersedes_revision: None,
808 kind: bamboo_domain::ModelContextEventKind::Snapshot,
809 content_sha256: bamboo_domain::model_context_block_sha256(&block),
810 rendered_text: block.render_runtime_context_text(),
811 };
812 let message = event.render_message();
813 assert!(matches!(message.role, bamboo_agent_core::Role::User));
814 assert!(!message.never_compress);
815 }
816}