Skip to main content

bamboo_engine/runtime/hooks/
mod.rs

1//! Hook runner — dispatches registered hooks at lifecycle points.
2
3use 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/// Engine adapter around the standalone hook dispatcher.
20///
21/// `bamboo-hooks` owns matching and handler execution. This adapter translates
22/// completed executions into engine checkpoints and lifecycle events.
23#[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    /// Clone this registry and append configured handlers from one frozen config
42    /// snapshot. The original registry remains reusable by future runs.
43    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    /// Record the exact accepted prompt from the server submission seam. The
55    /// shared loop consumes this once, so server and embedded runs cannot fire
56    /// portable UserPromptSubmit twice for the same admitted prompt.
57    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    /// Shared CLI/SDK fallback, before session preparation or provider calls.
65    /// Native submission hooks keep their existing server-only invocation.
66    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    /// Run all hooks matching the given point.
114    ///
115    /// Records checkpoints in `runtime_state`. Returns the first
116    /// `Suspend` or `Abort` result, or the aggregate result otherwise.
117    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    /// Run every matching hook while recording checkpoints/events, but never
204    /// short-circuit on a control decision. Observer/advisory seams such as
205    /// `SessionEnd`, `PreCompact`, and `Notification` use this so a command's
206    /// control-shaped output cannot suppress later hooks or reverse an
207    /// operation that must proceed for correctness.
208    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
286/// Fire cleanup/notification hooks after a terminal run. Decisions and context
287/// are intentionally ignored: `SessionEnd` observes a settled outcome and may
288/// not reverse it. Suspended runs are non-terminal and do not fire this event.
289pub(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
337/// Apply context injections and non-tool control decisions consistently across
338/// lifecycle seams.
339pub(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
399/// Stage bounded untrusted snapshots for the existing chronological context
400/// ledger. Do not insert messages into an unfinished assistant/tool group.
401pub(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
459/// Merge hook checkpoints produced through a session-local seam (notably
460/// compression) into the runner-owned state without losing checkpoints written
461/// directly by tool/round seams.
462pub(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    /// A no-op hook that always returns Continue.
491    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    /// A hook that always returns Abort.
522    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        // Lower priority runs first
602        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)); // registered for BeforeLlmCall
636
637        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            // Decisions at SessionEnd are observability-only and must not
673            // change the already-settled terminal outcome.
674            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}