use std::ffi::c_void;
use std::path::PathBuf;
use std::sync::Arc;
use rpi_agent::agent_tool::AgentTool;
use rpi_agent::types::{TextContentOrImage, ToolResultPartial};
use rpi_extensions::{load_session, PluginDiagnostics, PluginToolAdapter};
use rpi_plugin_sdk::EventTag;
use tokio_util::sync::CancellationToken;
fn locate_stub() -> Option<(PathBuf, PathBuf)> {
let exe = std::env::current_exe().ok()?;
let deps = exe.parent()?; let profile_dir = deps.parent()?; let cdylib = find_cdylib(profile_dir).or_else(|| find_cdylib(deps))?;
let dir = cdylib.parent()?.to_path_buf();
Some((cdylib, dir))
}
fn cdylib_filename() -> &'static str {
if cfg!(windows) {
"plugin_stub.dll"
} else if cfg!(target_os = "macos") {
"libplugin_stub.dylib"
} else {
"libplugin_stub.so"
}
}
fn find_cdylib(dir: &std::path::Path) -> Option<PathBuf> {
let p = dir.join(cdylib_filename());
if p.exists() {
Some(p)
} else {
None
}
}
#[derive(Default)]
struct RecordingDiag {
warnings: std::sync::Mutex<Vec<String>>,
}
impl PluginDiagnostics for RecordingDiag {
fn warn(&self, message: &str) {
self.warnings.lock().unwrap().push(message.to_string());
}
fn unsupported(&self, _message: &str) {}
}
#[tokio::test]
async fn loads_real_cdylib_and_drives_echo_tool() {
let Some((stub_path, dir)) = locate_stub() else {
eprintln!("plugin_stub cdylib not built — skipping (run `cargo build -p plugin-stub`)");
return;
};
eprintln!("smoke: loading {}", stub_path.display());
let diag = Arc::new(RecordingDiag::default());
let warnings = Arc::clone(&diag);
let session = load_session(
&[dir],
Arc::clone(&diag) as Arc<dyn PluginDiagnostics>,
None,
);
let warned = warnings.warnings.lock().unwrap().clone();
assert!(warned.is_empty(), "unexpected load diagnostics: {warned:?}");
assert!(!session.is_empty(), "expected the stub to load");
assert_eq!(session.loaded_paths().len(), 1);
let snapshot = session.snapshot().expect("snapshot present after a load");
let tools = snapshot.tools();
assert_eq!(tools.len(), 1, "echo should be the only registered tool");
let echo = &tools[0];
assert_eq!(echo.tool.name, "echo");
let handlers = snapshot.handlers_for(EventTag::MessageEnd);
assert_eq!(
handlers.len(),
1,
"expected one MessageEnd handler registered"
);
let adapter = PluginToolAdapter::new(echo.tool.clone(), echo.handle(), session.keepalive());
let on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
let signal = CancellationToken::new();
let result = adapter
.execute(
"smoke_call_1",
serde_json::json!({ "text": "hi" }),
signal,
on_update,
)
.await
.expect("echo execute should succeed");
assert_eq!(result.content.len(), 1, "result: {:?}", result.content);
match &result.content[0] {
TextContentOrImage::Text(t) => assert_eq!(t.text, "echo: hi"),
other => panic!("expected text content, got {other:?}"),
}
assert!(!result.terminate, "echo should not terminate the session");
let on_update2: Arc<dyn Fn(ToolResultPartial) + Send + Sync> = Arc::new(|_| {});
let signal2 = CancellationToken::new();
let _user: *mut c_void = std::ptr::null_mut();
let _ = _user;
let result2 = adapter
.execute(
"smoke_call_2",
serde_json::json!({ "text": "again" }),
signal2,
on_update2,
)
.await
.expect("second echo execute should succeed");
match &result2.content[0] {
TextContentOrImage::Text(t) => assert_eq!(t.text, "echo: again"),
other => panic!("expected text content, got {other:?}"),
}
use rpi_agent::events::AgentEmitter;
use rpi_agent::message::AgentMessage;
use rpi_ai::types::{AssistantMessage, Content, Usage};
use rpi_extensions::ExtensionEmitter;
let snapshot = session.snapshot_arc().expect("snapshot present");
let emitter = ExtensionEmitter::new(snapshot, session.keepalive());
let am = AgentMessage::Assistant(Box::new(AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![Content::text("smoke")],
api: rpi_ai::types::Api::AnthropicMessages,
provider: "anthropic".to_string(),
model: "m".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: rpi_ai::types::StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}));
let before = stub_message_end_hits(&stub_path);
emitter.try_emit(rpi_agent::AgentEvent::MessageEnd { message: am });
let after = stub_message_end_hits(&stub_path);
assert_eq!(
after,
before + 1,
"MessageEnd handler in the real cdylib should have fired once"
);
use rpi_extensions::emit_resources_discover;
let snap = session.snapshot_arc().expect("snapshot present");
let handlers = snap.resources_discover();
assert_eq!(
handlers.len(),
1,
"expected exactly one resources_discover handler registered"
);
let discover_before = stub_discover_hits(&stub_path);
let discovered = emit_resources_discover("/cwd", "startup", &snap);
let discover_after = stub_discover_hits(&stub_path);
assert_eq!(
discover_after,
discover_before + 1,
"resources_discover handler in the real cdylib should have fired once"
);
assert_eq!(
discovered.skill_paths.len(),
1,
"one skill path from one handler"
);
assert_eq!(
discovered.skill_paths[0], "plugin-stub-discovered/SKILL.md",
"the canned path the stub advertises must round-trip unchanged"
);
assert!(
discovered.prompt_paths.is_empty(),
"stub returns no promptPaths"
);
assert!(
discovered.theme_paths.is_empty(),
"stub returns no themePaths"
);
use rpi_ai::{Api, Model, Provider, SimpleStreamOptions};
use rpi_plugin_sdk::StbStringRef;
let snap = session.snapshot_arc().expect("snapshot present");
let providers = snap.providers();
assert_eq!(providers.len(), 1, "expected one stub provider registered");
assert_eq!(providers[0].provider_id, "stub-provider");
let markdown_renderers = snap.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
assert_eq!(
markdown_renderers.len(),
1,
"expected one markdown transformer registered"
);
assert_eq!(markdown_renderers[0].name, "stub-uppercase");
let ambient = tokio::runtime::Handle::current();
let pluggable = rpi_extensions::PluggableProvider::from_session(&session, ambient.clone());
assert_eq!(
pluggable.len(),
1,
"one PluggableProvider per registered provider"
);
let provider: Arc<dyn Provider> = pluggable.into_iter().next().unwrap();
assert_eq!(provider.id(), "stub-provider");
let model = Model::new(
"stub-model",
"Stub",
Api::Faux,
"stub-provider",
"https://stub.example",
);
let ctx = rpi_ai::Context::new(Vec::new());
let opts = SimpleStreamOptions::default();
let before = stub_provider_request_hits(&stub_path);
let mut ev_stream = provider.stream_simple(&model, &ctx, &opts).await;
let mut terminal: Option<rpi_ai::AssistantMessageEvent> = None;
while let Some(ev) = ev_stream.next().await {
terminal = Some(ev);
}
let (reason, text, message_provider) = match terminal.expect("a terminal event") {
rpi_ai::AssistantMessageEvent::Done { reason, message } => {
let text = match &message.content[0] {
rpi_ai::types::Content::Text(t) => t.text.clone(),
other => panic!("expected text content, got {other:?}"),
};
(reason, text, message.provider)
}
other => panic!("expected Done terminal, got {other:?}"),
};
let after = stub_provider_request_hits(&stub_path);
assert_eq!(
after,
before + 1,
"the stub's ProviderRequestFn should have run once via spawn_blocking"
);
assert_eq!(reason, rpi_ai::types::DoneReason::Stop);
assert_eq!(text, "from-stub-provider");
assert_eq!(message_provider, "stub-provider");
let renderer = &markdown_renderers[0];
let input = r#"{"markdown":"hello world"}"#;
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (renderer.render_fn)(
StbStringRef::from_str(input),
&mut out as *mut rpi_plugin_sdk::StbString,
renderer.user_data,
);
assert_eq!(rc, 0, "render_fn should succeed");
let transformed = out.to_string_lossy();
out.free_with(Some(renderer.plugin_free_string));
assert!(
transformed.contains(r#""markdown":"HELLO WORLD""#),
"stub uppercases markdown: got {transformed}"
);
let md_hits = stub_markdown_transform_hits(&stub_path);
assert!(md_hits >= 1, "markdown transform fn should have fired");
}
fn stub_discover_hits(stub_path: &std::path::Path) -> usize {
let lib = match unsafe { libloading::Library::new(stub_path) } {
Ok(l) => l,
Err(_) => return 0,
};
type HitFn = extern "C" fn() -> usize;
let sym: libloading::Symbol<HitFn> = match unsafe { lib.get(b"plugin_stub_discover_hits\0") } {
Ok(s) => s,
Err(_) => return 0,
};
let hits = sym();
drop(sym);
drop(lib);
hits
}
fn stub_provider_request_hits(stub_path: &std::path::Path) -> usize {
let lib = match unsafe { libloading::Library::new(stub_path) } {
Ok(l) => l,
Err(_) => return 0,
};
type HitFn = extern "C" fn() -> usize;
let sym: libloading::Symbol<HitFn> =
match unsafe { lib.get(b"plugin_stub_provider_request_hits\0") } {
Ok(s) => s,
Err(_) => return 0,
};
let hits = sym();
drop(sym);
drop(lib);
hits
}
fn stub_markdown_transform_hits(stub_path: &std::path::Path) -> usize {
let lib = match unsafe { libloading::Library::new(stub_path) } {
Ok(l) => l,
Err(_) => return 0,
};
type HitFn = extern "C" fn() -> usize;
let sym: libloading::Symbol<HitFn> =
match unsafe { lib.get(b"plugin_stub_markdown_transform_hits\0") } {
Ok(s) => s,
Err(_) => return 0,
};
let hits = sym();
drop(sym);
drop(lib);
hits
}
fn stub_message_end_hits(stub_path: &std::path::Path) -> usize {
let lib = match unsafe { libloading::Library::new(stub_path) } {
Ok(l) => l,
Err(_) => return 0,
};
type HitFn = extern "C" fn() -> usize;
let sym: libloading::Symbol<HitFn> = match unsafe { lib.get(b"plugin_stub_message_end_hits\0") }
{
Ok(s) => s,
Err(_) => return 0,
};
let hits = sym();
drop(sym);
drop(lib);
hits
}