use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use futures::FutureExt;
use nexil::tape::{AsyncTapeStore, TapeStore};
use crate::smart_router::RouteDecision;
use crate::types::{Envelope, MessageHandler, PromptValue, State};
#[derive(Debug, thiserror::Error)]
pub enum HookError {
#[error("{hook_point} failed in plugin '{plugin}': {source}")]
Plugin {
plugin: String,
hook_point: &'static str,
source: anyhow::Error,
},
#[error("hook panicked in plugin '{0}'")]
Panic(String),
}
impl HookError {
fn wrap(plugin: String, hook_point: &'static str, e: HookError) -> Self {
let source = match e {
HookError::Plugin { source, .. } => source,
other => anyhow::anyhow!("{other}"),
};
HookError::Plugin {
plugin,
hook_point,
source,
}
}
}
macro_rules! call_notify_all {
($iter:expr, $hook_name:literal, |$p:ident| $call:expr) => {
for $p in $iter {
let name = $p.plugin_name().to_owned();
let result = std::panic::AssertUnwindSafe($call).catch_unwind().await;
if result.is_err() {
tracing::error!(plugin = %name, concat!("hook.", $hook_name, " panicked"));
}
}
};
}
macro_rules! call_sync_all {
($iter:expr, $hook_name:literal, |$p:ident| $call:expr) => {
for $p in $iter {
let name = $p.plugin_name().to_owned();
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $call)).is_err() {
tracing::error!(plugin = %name, concat!("hook.", $hook_name, " panicked"));
}
}
};
}
fn preview_text(text: &str) -> String {
const LIMIT: usize = 1000;
let mut chars = text.chars();
let preview: String = chars.by_ref().take(LIMIT).collect();
let normalized = preview.replace('\n', "\\n");
if chars.next().is_some() {
format!("{normalized}...(truncated)")
} else {
normalized
}
}
fn preview_json(value: &Envelope) -> String {
preview_text(&value.to_string())
}
fn trace_hook_call(plugin: &str, session_id: &str, hook: &str, input: &str) {
tracing::info!(target: "eli_trace", plugin = %plugin, session_id = %session_id, input = %input, "hook.{hook}.call");
}
fn trace_hook_return(plugin: &str, session_id: &str, hook: &str, output: &str) {
tracing::info!(target: "eli_trace", plugin = %plugin, session_id = %session_id, output = %output, "hook.{hook}.return");
}
fn trace_hook_none(plugin: &str, session_id: &str, hook: &str) {
tracing::info!(target: "eli_trace", plugin = %plugin, session_id = %session_id, "hook.{hook}.none");
}
#[async_trait]
pub trait ChannelHook: Send + Sync {
fn name(&self) -> &str;
async fn start(&self, stop: tokio::sync::watch::Receiver<bool>) -> anyhow::Result<()>;
async fn stop(&self) -> anyhow::Result<()>;
fn needs_debounce(&self) -> bool {
false
}
async fn send(&self, _message: Envelope) -> anyhow::Result<()> {
Ok(())
}
}
pub enum TapeStoreKind {
Sync(Arc<dyn TapeStore>),
Async(Arc<dyn AsyncTapeStore>),
}
#[async_trait]
#[allow(unused_variables)]
pub trait EliHookSpec: Send + Sync {
fn plugin_name(&self) -> &str {
"unnamed"
}
fn classify_inbound(&self, message: &Envelope) -> Option<RouteDecision> {
None
}
async fn resolve_session(&self, message: &Envelope) -> Result<Option<String>, HookError> {
Ok(None)
}
async fn load_state(
&self,
message: &Envelope,
session_id: &str,
) -> Result<Option<State>, HookError> {
Ok(None)
}
async fn build_user_prompt(
&self,
message: &Envelope,
session_id: &str,
state: &State,
) -> Option<PromptValue> {
None
}
async fn run_model(
&self,
prompt: &PromptValue,
session_id: &str,
state: &State,
) -> Result<Option<String>, HookError> {
Ok(None)
}
async fn save_state(
&self,
session_id: &str,
state: &State,
message: &Envelope,
model_output: &str,
) {
}
async fn render_outbound(
&self,
message: &Envelope,
session_id: &str,
state: &State,
model_output: &str,
) -> Option<Vec<Envelope>> {
None
}
async fn dispatch_outbound(&self, message: &Envelope) -> Option<bool> {
None
}
fn register_cli_commands(&self, app: &mut clap::Command) {}
async fn on_error(&self, stage: &str, error: &anyhow::Error, message: Option<&Envelope>) {}
fn build_system_prompt(&self, prompt_text: &str, state: &State) -> Option<String> {
None
}
fn wrap_tool(&self, tool: &nexil::Tool) -> nexil::ToolAction {
nexil::ToolAction::Keep
}
fn provide_tape_store(&self) -> Option<TapeStoreKind> {
None
}
fn provide_channels(&self, message_handler: MessageHandler) -> Vec<Box<dyn ChannelHook>> {
Vec::new()
}
}
pub struct HookRuntime {
plugins: Vec<Arc<dyn EliHookSpec>>,
}
impl HookRuntime {
pub fn new(plugins: Vec<Arc<dyn EliHookSpec>>) -> Self {
Self { plugins }
}
pub fn register(&mut self, plugin: Arc<dyn EliHookSpec>) {
self.plugins.push(plugin);
}
fn reversed(&self) -> impl Iterator<Item = &Arc<dyn EliHookSpec>> {
self.plugins.iter().rev()
}
pub fn call_classify_inbound(&self, message: &Envelope) -> Option<RouteDecision> {
for plugin in self.reversed() {
let name = plugin.plugin_name().to_owned();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.classify_inbound(message)
})) {
Ok(Some(decision)) => {
tracing::info!(
target: "eli_trace",
plugin = %name,
decision = ?decision,
"hook.classify_inbound"
);
return Some(decision);
}
Ok(None) => {}
Err(_) => tracing::error!(plugin = %name, "hook.classify_inbound panicked"),
}
}
None
}
pub fn call_build_system_prompt(&self, prompt_text: &str, state: &State) -> Option<String> {
for plugin in self.reversed() {
let name = plugin.plugin_name().to_owned();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.build_system_prompt(prompt_text, state)
})) {
Ok(Some(prompt)) => return Some(prompt),
Ok(None) => {}
Err(_) => tracing::error!(plugin = %name, "hook.build_system_prompt panicked"),
}
}
None
}
pub fn call_wrap_tools(&self, tools: Vec<nexil::Tool>) -> Vec<nexil::Tool> {
let mut result = tools;
for plugin in self.plugins.iter() {
let name = plugin.plugin_name().to_owned();
result = result
.into_iter()
.filter_map(|tool| {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.wrap_tool(&tool)
})) {
Ok(nexil::ToolAction::Keep) => Some(tool),
Ok(nexil::ToolAction::Remove) => {
tracing::info!(
plugin = %name,
tool = %tool.name,
"hook.wrap_tool removed tool"
);
None
}
Ok(nexil::ToolAction::Replace(wrapped)) => Some(wrapped),
Err(_) => {
tracing::error!(plugin = %name, "hook.wrap_tool panicked");
Some(tool)
}
}
})
.collect();
}
result
}
pub async fn call_resolve_session(
&self,
message: &Envelope,
) -> Result<Option<String>, HookError> {
let session_id = "<resolving>";
for p in self.reversed() {
let name = p.plugin_name().to_owned();
trace_hook_call(&name, session_id, "resolve_session", &preview_json(message));
let result = std::panic::AssertUnwindSafe(p.resolve_session(message))
.catch_unwind()
.await;
match result {
Ok(Ok(Some(val))) => {
trace_hook_return(&name, &val, "resolve_session", &preview_text(&val));
return Ok(Some(val));
}
Ok(Ok(None)) => {
trace_hook_none(&name, session_id, "resolve_session");
continue;
}
Ok(Err(e)) => {
tracing::warn!(plugin = %name, error = %e, "hook.resolve_session failed");
return Err(HookError::wrap(name, "resolve_session", e));
}
Err(_) => {
tracing::warn!(plugin = %name, "hook.resolve_session panicked");
return Err(HookError::Panic(name));
}
}
}
Ok(None)
}
pub async fn call_load_state(
&self,
message: &Envelope,
session_id: &str,
) -> Result<Vec<Option<State>>, HookError> {
let mut results = Vec::new();
for p in self.plugins.iter() {
let name = p.plugin_name().to_owned();
trace_hook_call(&name, session_id, "load_state", &preview_json(message));
let result = std::panic::AssertUnwindSafe(p.load_state(message, session_id))
.catch_unwind()
.await;
match result {
Ok(Ok(val)) => {
let preview = format!("{} keys", val.as_ref().map_or(0, |s| s.len()));
trace_hook_return(&name, session_id, "load_state", &preview);
results.push(val);
}
Ok(Err(e)) => {
tracing::warn!(plugin = %name, error = %e, "hook.load_state failed");
return Err(HookError::wrap(name, "load_state", e));
}
Err(_) => {
tracing::warn!(plugin = %name, "hook.load_state panicked");
return Err(HookError::Panic(name));
}
}
}
Ok(results)
}
pub async fn call_build_user_prompt(
&self,
message: &Envelope,
session_id: &str,
state: &State,
) -> Option<PromptValue> {
for plugin in self.reversed() {
let name = plugin.plugin_name().to_owned();
trace_hook_call(
&name,
session_id,
"build_user_prompt",
&preview_json(message),
);
let result =
std::panic::AssertUnwindSafe(plugin.build_user_prompt(message, session_id, state))
.catch_unwind()
.await;
match result {
Ok(Some(val)) => {
trace_hook_return(
&name,
session_id,
"build_user_prompt",
&preview_text(&val.as_text()),
);
return Some(val);
}
Ok(None) => {
trace_hook_none(&name, session_id, "build_user_prompt");
continue;
}
Err(_) => {
tracing::error!(plugin = %name, session_id = %session_id, "hook.build_user_prompt panicked");
continue;
}
}
}
None
}
pub async fn call_run_model(
&self,
prompt: &PromptValue,
session_id: &str,
state: &State,
) -> Result<Option<String>, HookError> {
for plugin in self.reversed() {
let name = plugin.plugin_name().to_owned();
trace_hook_call(
&name,
session_id,
"run_model",
&preview_text(&prompt.as_text()),
);
let result = std::panic::AssertUnwindSafe(plugin.run_model(prompt, session_id, state))
.catch_unwind()
.await;
match result {
Ok(Ok(Some(val))) => {
trace_hook_return(&name, session_id, "run_model", &preview_text(&val));
return Ok(Some(val));
}
Ok(Ok(None)) => {
trace_hook_none(&name, session_id, "run_model");
continue;
}
Ok(Err(e)) => {
tracing::warn!(plugin = %name, error = %e, "hook.run_model failed");
return Err(HookError::wrap(name, "run_model", e));
}
Err(_) => {
tracing::warn!(plugin = %name, "hook.run_model panicked");
return Err(HookError::Panic(name));
}
}
}
Ok(None)
}
pub async fn call_save_state(
&self,
session_id: &str,
state: &State,
message: &Envelope,
model_output: &str,
) {
for p in self.plugins.iter() {
let name = p.plugin_name().to_owned();
trace_hook_call(&name, session_id, "save_state", &preview_text(model_output));
let result = std::panic::AssertUnwindSafe(p.save_state(
session_id,
state,
message,
model_output,
))
.catch_unwind()
.await;
match result {
Ok(()) => trace_hook_return(&name, session_id, "save_state", "ok"),
Err(_) => {
tracing::error!(plugin = %name, "hook.save_state panicked");
}
}
}
}
pub async fn call_render_outbound(
&self,
message: &Envelope,
session_id: &str,
state: &State,
model_output: &str,
) -> Vec<Vec<Envelope>> {
let mut results = Vec::new();
for plugin in self.plugins.iter() {
let name = plugin.plugin_name().to_owned();
trace_hook_call(
&name,
session_id,
"render_outbound",
&preview_text(model_output),
);
let result = std::panic::AssertUnwindSafe(plugin.render_outbound(
message,
session_id,
state,
model_output,
))
.catch_unwind()
.await;
match result {
Ok(Some(batch)) => {
let preview = batch.first().map(preview_json).unwrap_or_default();
trace_hook_return(&name, session_id, "render_outbound", &preview);
results.push(batch);
}
Ok(None) => trace_hook_none(&name, session_id, "render_outbound"),
Err(_) => {
tracing::error!(plugin = %name, "hook.render_outbound panicked");
}
}
}
results
}
pub async fn call_dispatch_outbound(&self, message: &Envelope) {
let session_id = message
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
for p in self.plugins.iter() {
let name = p.plugin_name().to_owned();
trace_hook_call(
&name,
session_id,
"dispatch_outbound",
&preview_json(message),
);
let result = std::panic::AssertUnwindSafe(p.dispatch_outbound(message))
.catch_unwind()
.await;
match result {
Ok(Some(delivered)) => {
trace_hook_return(
&name,
session_id,
"dispatch_outbound",
if delivered {
"delivered"
} else {
"not_delivered"
},
);
}
Ok(None) => trace_hook_none(&name, session_id, "dispatch_outbound"),
Err(_) => {
tracing::error!(plugin = %name, "hook.dispatch_outbound panicked");
}
}
}
}
pub fn call_register_cli_commands(&self, app: &mut clap::Command) {
call_sync_all!(self.plugins.iter(), "register_cli_commands", |p| p
.register_cli_commands(app));
}
pub async fn notify_error(
&self,
stage: &str,
error: &anyhow::Error,
message: Option<&Envelope>,
) {
call_notify_all!(self.plugins.iter(), "on_error", |p| p
.on_error(stage, error, message));
}
pub fn call_provide_tape_store(&self) -> Option<TapeStoreKind> {
for plugin in self.reversed() {
let name = plugin.plugin_name().to_owned();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.provide_tape_store()
})) {
Ok(Some(store)) => return Some(store),
Ok(None) => {}
Err(_) => tracing::error!(plugin = %name, "hook.provide_tape_store panicked"),
}
}
None
}
pub fn call_provide_channels(
&self,
message_handler: MessageHandler,
) -> Vec<Box<dyn ChannelHook>> {
let mut channels = Vec::new();
call_sync_all!(self.plugins.iter(), "provide_channels", |p| {
channels.append(&mut p.provide_channels(message_handler.clone()));
});
channels
}
pub fn hook_report(&self) -> HashMap<String, Vec<String>> {
let hook_names = [
"classify_inbound",
"resolve_session",
"load_state",
"build_user_prompt",
"build_system_prompt",
"run_model",
"save_state",
"render_outbound",
"dispatch_outbound",
"register_cli_commands",
"on_error",
"wrap_tool",
"provide_tape_store",
"provide_channels",
];
let mut report = HashMap::new();
let names: Vec<String> = self
.plugins
.iter()
.map(|p| p.plugin_name().to_string())
.collect();
for hook_name in &hook_names {
if !names.is_empty() {
report.insert(hook_name.to_string(), names.clone());
}
}
report
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Arc;
struct HighPriorityPlugin;
#[async_trait]
impl EliHookSpec for HighPriorityPlugin {
fn plugin_name(&self) -> &str {
"high"
}
async fn resolve_session(&self, _message: &Envelope) -> Result<Option<String>, HookError> {
Ok(Some("high-session".into()))
}
fn build_system_prompt(&self, _prompt_text: &str, _state: &State) -> Option<String> {
Some("high-prompt".into())
}
async fn render_outbound(
&self,
_message: &Envelope,
_session_id: &str,
_state: &State,
_model_output: &str,
) -> Option<Vec<Envelope>> {
Some(vec![json!({"content": "high-out"})])
}
}
struct LowPriorityPlugin;
#[async_trait]
impl EliHookSpec for LowPriorityPlugin {
fn plugin_name(&self) -> &str {
"low"
}
async fn resolve_session(&self, _message: &Envelope) -> Result<Option<String>, HookError> {
Ok(Some("low-session".into()))
}
fn build_system_prompt(&self, _prompt_text: &str, _state: &State) -> Option<String> {
Some("low-prompt".into())
}
}
struct ReturnsNonePlugin;
#[async_trait]
impl EliHookSpec for ReturnsNonePlugin {
fn plugin_name(&self) -> &str {
"none-plugin"
}
}
struct ErrorObserver {
observed: std::sync::Mutex<Vec<String>>,
}
#[async_trait]
impl EliHookSpec for ErrorObserver {
fn plugin_name(&self) -> &str {
"error-observer"
}
async fn on_error(&self, stage: &str, _error: &anyhow::Error, _message: Option<&Envelope>) {
self.observed
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(stage.to_owned());
}
}
struct FailingErrorObserver;
#[async_trait]
impl EliHookSpec for FailingErrorObserver {
fn plugin_name(&self) -> &str {
"failing-observer"
}
async fn on_error(
&self,
_stage: &str,
_error: &anyhow::Error,
_message: Option<&Envelope>,
) {
panic!("observer panic");
}
}
struct PanicSessionPlugin;
#[async_trait]
impl EliHookSpec for PanicSessionPlugin {
fn plugin_name(&self) -> &str {
"panic-session"
}
async fn resolve_session(&self, _message: &Envelope) -> Result<Option<String>, HookError> {
panic!("resolve_session panic");
}
}
#[tokio::test]
async fn test_call_first_returns_last_registered_non_none() {
let rt = HookRuntime::new(vec![
Arc::new(LowPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(HighPriorityPlugin),
]);
let msg = json!({"content": "hello"});
let result = rt.call_resolve_session(&msg).await.unwrap();
assert_eq!(result, Some("high-session".into()));
}
#[tokio::test]
async fn test_call_first_skips_none_and_returns_next() {
let rt = HookRuntime::new(vec![
Arc::new(LowPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(ReturnsNonePlugin),
]);
let msg = json!({"content": "hello"});
let result = rt.call_resolve_session(&msg).await.unwrap();
assert_eq!(result, Some("low-session".into()));
}
#[tokio::test]
async fn test_call_first_returns_none_when_all_return_none() {
let rt = HookRuntime::new(vec![Arc::new(ReturnsNonePlugin) as Arc<dyn EliHookSpec>]);
let msg = json!({"content": "hello"});
let result = rt.call_resolve_session(&msg).await.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_call_first_propagates_panic_as_error() {
let rt = HookRuntime::new(vec![
Arc::new(LowPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(PanicSessionPlugin),
]);
let msg = json!({"content": "hello"});
let result = rt.call_resolve_session(&msg).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, HookError::Panic(ref name) if name == "panic-session"));
}
#[tokio::test]
async fn test_call_build_system_prompt_returns_first_result() {
let rt = HookRuntime::new(vec![
Arc::new(LowPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(HighPriorityPlugin),
]);
let state = State::new();
let result = rt.call_build_system_prompt("hello", &state);
assert_eq!(result, Some("high-prompt".into()));
}
#[tokio::test]
async fn test_call_build_system_prompt_skips_none_results() {
let rt = HookRuntime::new(vec![
Arc::new(LowPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(ReturnsNonePlugin),
]);
let state = State::new();
let result = rt.call_build_system_prompt("hello", &state);
assert_eq!(result, Some("low-prompt".into()));
}
#[tokio::test]
async fn test_call_render_outbound_collects_all() {
let rt = HookRuntime::new(vec![
Arc::new(HighPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(ReturnsNonePlugin),
]);
let msg = json!({"content": "hello"});
let state = State::new();
let result = rt.call_render_outbound(&msg, "s1", &state, "output").await;
assert_eq!(result.len(), 1);
assert_eq!(result[0][0], json!({"content": "high-out"}));
}
#[tokio::test]
async fn test_notify_error_calls_all_observers() {
let observer = Arc::new(ErrorObserver {
observed: std::sync::Mutex::new(Vec::new()),
});
let rt = HookRuntime::new(vec![
Arc::new(FailingErrorObserver) as Arc<dyn EliHookSpec>,
observer.clone() as Arc<dyn EliHookSpec>,
]);
let err = anyhow::anyhow!("test error");
rt.notify_error("turn", &err, None).await;
let observed = observer.observed.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(*observed, vec!["turn"]);
}
#[tokio::test]
async fn test_notify_error_with_message() {
let observer = Arc::new(ErrorObserver {
observed: std::sync::Mutex::new(Vec::new()),
});
let rt = HookRuntime::new(vec![observer.clone() as Arc<dyn EliHookSpec>]);
let err = anyhow::anyhow!("test error");
let msg = json!({"content": "hello"});
rt.notify_error("pipeline", &err, Some(&msg)).await;
let observed = observer.observed.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(*observed, vec!["pipeline"]);
}
#[test]
fn test_hook_report_lists_all_registered_plugins() {
let rt = HookRuntime::new(vec![
Arc::new(LowPriorityPlugin) as Arc<dyn EliHookSpec>,
Arc::new(HighPriorityPlugin),
]);
let report = rt.hook_report();
assert!(report.contains_key("resolve_session"));
assert_eq!(report["resolve_session"], vec!["low", "high"]);
assert!(report.contains_key("build_system_prompt"));
}
#[test]
fn test_hook_report_empty_when_no_plugins() {
let rt = HookRuntime::new(vec![]);
let report = rt.hook_report();
assert!(report.is_empty());
}
#[test]
fn test_register_adds_plugin() {
let mut rt = HookRuntime::new(vec![]);
assert!(rt.hook_report().is_empty());
rt.register(Arc::new(LowPriorityPlugin));
let report = rt.hook_report();
assert_eq!(report["resolve_session"], vec!["low"]);
}
struct PanicLoadStatePlugin;
#[async_trait]
impl EliHookSpec for PanicLoadStatePlugin {
fn plugin_name(&self) -> &str {
"panic-load-state"
}
async fn load_state(
&self,
_message: &Envelope,
_session_id: &str,
) -> Result<Option<State>, HookError> {
panic!("load_state panic");
}
}
struct ErrorLoadStatePlugin;
#[async_trait]
impl EliHookSpec for ErrorLoadStatePlugin {
fn plugin_name(&self) -> &str {
"error-load-state"
}
async fn load_state(
&self,
_message: &Envelope,
_session_id: &str,
) -> Result<Option<State>, HookError> {
Err(HookError::Plugin {
plugin: "error-load-state".into(),
hook_point: "load_state",
source: anyhow::anyhow!("state unavailable"),
})
}
}
#[tokio::test]
async fn test_call_load_state_propagates_panic_as_error() {
let rt = HookRuntime::new(vec![Arc::new(PanicLoadStatePlugin) as Arc<dyn EliHookSpec>]);
let msg = json!({"content": "hello"});
let result = rt.call_load_state(&msg, "s1").await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), HookError::Panic(ref name) if name == "panic-load-state")
);
}
#[tokio::test]
async fn test_call_load_state_propagates_plugin_error() {
let rt = HookRuntime::new(vec![Arc::new(ErrorLoadStatePlugin) as Arc<dyn EliHookSpec>]);
let msg = json!({"content": "hello"});
let result = rt.call_load_state(&msg, "s1").await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), HookError::Plugin { ref hook_point, .. } if *hook_point == "load_state")
);
}
struct PanicRunModelPlugin;
#[async_trait]
impl EliHookSpec for PanicRunModelPlugin {
fn plugin_name(&self) -> &str {
"panic-run-model"
}
async fn run_model(
&self,
_prompt: &PromptValue,
_session_id: &str,
_state: &State,
) -> Result<Option<String>, HookError> {
panic!("run_model panic");
}
}
struct ErrorRunModelPlugin;
#[async_trait]
impl EliHookSpec for ErrorRunModelPlugin {
fn plugin_name(&self) -> &str {
"error-run-model"
}
async fn run_model(
&self,
_prompt: &PromptValue,
_session_id: &str,
_state: &State,
) -> Result<Option<String>, HookError> {
Err(HookError::Plugin {
plugin: "error-run-model".into(),
hook_point: "run_model",
source: anyhow::anyhow!("model unavailable"),
})
}
}
#[tokio::test]
async fn test_call_run_model_propagates_panic_as_error() {
let rt = HookRuntime::new(vec![Arc::new(PanicRunModelPlugin) as Arc<dyn EliHookSpec>]);
let prompt = PromptValue::Text("hello".into());
let state = State::new();
let result = rt.call_run_model(&prompt, "s1", &state).await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), HookError::Panic(ref name) if name == "panic-run-model")
);
}
#[tokio::test]
async fn test_call_run_model_propagates_plugin_error() {
let rt = HookRuntime::new(vec![Arc::new(ErrorRunModelPlugin) as Arc<dyn EliHookSpec>]);
let prompt = PromptValue::Text("hello".into());
let state = State::new();
let result = rt.call_run_model(&prompt, "s1", &state).await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), HookError::Plugin { ref hook_point, .. } if *hook_point == "run_model")
);
}
struct PanicBuildPromptPlugin;
#[async_trait]
impl EliHookSpec for PanicBuildPromptPlugin {
fn plugin_name(&self) -> &str {
"panic-build-prompt"
}
async fn build_user_prompt(
&self,
_message: &Envelope,
_session_id: &str,
_state: &State,
) -> Option<PromptValue> {
panic!("build_user_prompt panic");
}
}
struct BuildPromptFallbackPlugin;
#[async_trait]
impl EliHookSpec for BuildPromptFallbackPlugin {
fn plugin_name(&self) -> &str {
"build-prompt-fallback"
}
async fn build_user_prompt(
&self,
_message: &Envelope,
_session_id: &str,
_state: &State,
) -> Option<PromptValue> {
Some(PromptValue::Text("fallback-prompt".into()))
}
}
#[tokio::test]
async fn test_call_build_user_prompt_skips_panicking_plugin() {
let rt = HookRuntime::new(vec![
Arc::new(BuildPromptFallbackPlugin) as Arc<dyn EliHookSpec>,
Arc::new(PanicBuildPromptPlugin),
]);
let msg = json!({"content": "hello"});
let state = State::new();
let result = rt.call_build_user_prompt(&msg, "s1", &state).await;
assert!(result.is_some());
assert_eq!(result.unwrap().as_text(), "fallback-prompt");
}
}