mod banner;
mod cancel;
mod dock;
mod footer;
mod hooks;
mod overlay;
mod registry;
mod renderers;
mod tools;
mod ui;
pub mod events;
pub mod markdown;
use crate::{
core::{
provider::{AgentMessage, ContentBlock, Usage},
session_manager::Session,
tools::{ToolError, ToolResult},
},
error::Result,
};
use futures_util::future::BoxFuture;
use serde_json::Value;
use std::{
collections::HashSet,
sync::{
Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
},
};
use strum_macros::{EnumString, IntoStaticStr};
pub use super::{
provider::api_impls::{RegisteredClassifierApi, RegisteredImageApi},
virtual_models::{
FailedRoute, ModelRoute, ModelRouteReason, ModelRouteRequest, RouteTarget,
VirtualModelDefinition, VirtualModelRouter,
},
};
pub use banner::{BannerCtx, BannerExtension, BannerLine, BannerSpan};
pub use cancel::{
BackgroundCancelGuard, background_work_count, cancel_background_work,
register_background_cancel,
};
pub use dock::{DockLine, DockSection, DockSpan, dock_sections};
pub use footer::{FooterCtx, FooterExtension, FooterLine, FooterSpan, git_branch};
pub use hooks::ExtensionHook;
pub use markdown::render as render_markdown_lines;
pub use overlay::{
OverlayEditor, OverlayEvent, OverlayInput, OverlayKey, OverlaySize, OverlayView, overlay_event,
overlay_view,
};
pub use registry::{
apply_extension_setting, banner_extension_count, banner_names, command_busy_safe,
command_provider, current_extension_mode, documentation_suppressed, extension_count,
extension_detail_lines, extension_modes, footer_extension_count, footer_names,
is_extension_active, is_extension_enabled, panel_entries, persist_extension_states,
register_banner_extension, register_banner_extension_arc, register_extension,
register_extension_arc, register_footer_extension, register_footer_extension_arc,
registered_commands, registration_issues, reload_from_settings, set_all_extensions_disabled,
set_extension_enabled, set_extension_mode, settings_for, unregister_extension,
unsatisfied_prerequisites,
};
pub use registry::{registered, registered_banners, registered_footers};
pub use renderers::{
RegisteredToolRenderer, RichLine, ToolRender, ToolRenderCtx, ToolRenderer,
active_tool_renderer_count, register_tool_renderer, register_tool_renderers, render_tool_block,
tool_renderer_for, tool_renderers_fingerprint, unregister_tool_renderers,
};
pub use tools::{
ExecuteToolFn, ExtensionTool, GrammarSampling, InjectedChildTool, InjectedToolHandler,
MakeSubAgentFn, NestedCallLog, NestedCallLogHandle, SubAgentControls, SubAgentEventSink,
SubAgentRunner, SubAgentSpec, ToolAnnotations, ToolExecCtx, ToolExposure, ToolLoadout,
ToolLoadoutChanges, unavailable_tool_exec,
};
pub use ui::{
ExtensionUiRequest, RichSpan, SelectOption, UiNotifyLevel, ask_pending, ask_prompt, next_ui_id,
render_custom_message, request_show_dock, request_ui, set_ui_ready, submit_ui_choice,
take_pending_ui, ui_ready,
};
pub type DeclaredMcpServer = (String, Value);
static ACTIVATED_DEFERRED_TOOLS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
static DEFERRED_ACTIVATION_DIRTY: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionSuggestion {
pub name: String,
pub description: String,
pub insert: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExtensionSuggestions {
pub items: Vec<ExtensionSuggestion>,
pub exclusive: bool,
}
pub fn suggestion_candidates(query: &str, line_prefix: &str) -> ExtensionSuggestions {
let mut out = ExtensionSuggestions::default();
for ext in registered() {
if ext.hooks().contains(&ExtensionHook::Suggestions) {
let s = ext.suggestions(query, line_prefix);
out.items.extend(s.items);
out.exclusive |= s.exclusive;
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliFlagDef {
pub name: &'static str,
pub description: &'static str,
pub takes_value: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionKeybinding {
pub id: String,
pub keys: Vec<String>,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionCommand {
pub name: String,
pub description: String,
pub busy_safe: bool,
pub subcommands: Vec<SubcommandDef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubcommandDef {
pub name: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionSetting {
pub key: String,
pub label: String,
pub description: String,
pub value: String,
pub choices: Vec<String>,
}
impl ExtensionSetting {
pub fn cycle(
key: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
value: impl Into<String>,
choices: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
key: key.into(),
label: label.into(),
description: description.into(),
value: value.into(),
choices: choices.into_iter().map(Into::into).collect(),
}
}
pub fn next_value(&self) -> Option<&str> {
if self.choices.is_empty() {
return None;
}
let idx = self
.choices
.iter()
.position(|c| c == &self.value)
.unwrap_or(usize::MAX);
let next = if idx == usize::MAX {
0
} else {
(idx + 1) % self.choices.len()
};
self.choices.get(next).map(String::as_str)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
pub enum ExtensionMode {
Minimal,
Dev,
Creator,
All,
}
impl ExtensionMode {
pub const ALL: [ExtensionMode; 4] = [
ExtensionMode::Minimal,
ExtensionMode::Dev,
ExtensionMode::Creator,
ExtensionMode::All,
];
pub fn as_str(&self) -> &'static str {
self.into()
}
pub fn parse(s: &str) -> ExtensionMode {
s.parse().unwrap_or(ExtensionMode::All)
}
pub fn level(&self) -> u8 {
match self {
ExtensionMode::Minimal => 0,
ExtensionMode::Dev | ExtensionMode::Creator => 1,
ExtensionMode::All => 2,
}
}
pub fn usable_in(self, current: ExtensionMode) -> bool {
self.level() < current.level() || self == current
}
pub fn covered_modes(self) -> Vec<ExtensionMode> {
ExtensionMode::ALL
.into_iter()
.filter(|m| self.usable_in(*m))
.collect()
}
pub fn next(self) -> ExtensionMode {
match self {
ExtensionMode::All => ExtensionMode::Dev,
ExtensionMode::Dev => ExtensionMode::Creator,
ExtensionMode::Creator => ExtensionMode::Minimal,
ExtensionMode::Minimal => ExtensionMode::All,
}
}
}
#[derive(Debug, Clone)]
pub struct ForkProjectInfo {
pub plugin_name: String,
pub plugin_version: String,
pub url: String,
}
pub trait Extension: Send + Sync {
fn name(&self) -> &str;
fn fork_project(&self) -> Option<ForkProjectInfo> {
None
}
fn description(&self) -> &str {
""
}
fn prerequisite_tools(&self) -> Vec<String> {
Vec::new()
}
fn modes(&self) -> Vec<ExtensionMode> {
Vec::new()
}
fn default_enabled(&self) -> bool {
true
}
fn suppress_documentation(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool>;
fn virtual_models(&self) -> Vec<VirtualModelDefinition> {
Vec::new()
}
fn tool_renderers(&self) -> Vec<RegisteredToolRenderer> {
Vec::new()
}
fn image_apis(&self) -> Vec<RegisteredImageApi> {
Vec::new()
}
fn classifier_apis(&self) -> Vec<RegisteredClassifierApi> {
Vec::new()
}
fn mcp_servers(&self) -> Vec<DeclaredMcpServer> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
Vec::new()
}
fn cli_flags(&self) -> Vec<CliFlagDef> {
Vec::new()
}
fn apply_cli_flag(&self, _name: &str, _value: bool) {}
fn apply_cli_flag_value(&self, _name: &str, _value: &str) {}
fn keybindings(&self) -> Vec<ExtensionKeybinding> {
Vec::new()
}
fn on_before_compact(&self, _tokens_before: u32, _reason: &str) {}
fn custom_summarize(
&self,
_system_prompt: &str,
_conversation: &str,
_summary_instructions: &str,
) -> Option<String> {
None
}
fn on_compact_failure(&self, _reason: &str) {}
fn on_ui_choice(&self, _id: u64, _choice: Option<String>) -> Option<String> {
None
}
fn on_user_prompt(&self, _text: &str) -> Option<UserPromptAction> {
None
}
fn on_user_prompt_with_context(
&self,
text: &str,
_messages: &[AgentMessage],
_busy: bool,
) -> Option<UserPromptAction> {
self.on_user_prompt(text)
}
fn on_user_submit(&self, _text: &str, _busy: bool) {}
fn on_exec_ctx(&self, _ctx: &ToolExecCtx) {}
fn on_session_start(&self, _cwd: &str, _messages: &[AgentMessage], _session: Option<&Session>) {
}
fn on_session_switched(&self, _session_path: Option<&str>, _messages: &[AgentMessage]) {}
fn on_registered(&self) {}
fn hooks(&self) -> Vec<ExtensionHook> {
Vec::new()
}
fn filter_tools(&self, selected: Vec<String>) -> Vec<String> {
selected
}
fn filter_extension_tools(&self, tools: Vec<ExtensionTool>) -> Vec<ExtensionTool> {
tools
}
fn hosts_scripts(&self) -> bool {
false
}
fn prepare_loadout(&self, _loadout: &ToolLoadout) -> ToolLoadoutChanges {
ToolLoadoutChanges::default()
}
fn on_agent_event(&self, _event: &Value) {}
fn provider_stream_event(&self, _event: &Value) {}
fn on_boundary(&self, _event: &Value) -> Option<BoundaryOutcome> {
None
}
fn cache_warming_decision(&self, _event: &Value) -> Option<&'static str> {
None
}
fn on_extension_event(&self, _name: &str, _payload: &Value) {}
fn suggestions(&self, _query: &str, _line_prefix: &str) -> ExtensionSuggestions {
ExtensionSuggestions::default()
}
fn settings(&self) -> Vec<ExtensionSetting> {
Vec::new()
}
fn apply_setting(&self, _key: &str, _value: &str) -> std::result::Result<(), String> {
Err(format!("unknown setting: {_key}"))
}
fn dock_lines(&self) -> Vec<DockLine> {
Vec::new()
}
fn wants_redraw(&self) -> bool {
false
}
fn render_custom_message(&self, _custom_type: &str, _data: &Value) -> Option<Vec<RichSpan>> {
None
}
fn overlay_view(&self, _id: u64, _cols: u16) -> Option<OverlayView> {
None
}
fn on_overlay_event(&self, _id: u64, _ev: &OverlayEvent) -> bool {
false
}
fn on_enabled_changed(&self, _enabled: bool) {}
fn execute_tool(
&self,
name: &str,
_args: &Value,
) -> std::result::Result<ToolResult, ToolError> {
Err(ToolError(format!(
"extension {}: unknown tool: {}",
self.name(),
name
)))
}
fn execute_tool_async(
&self,
name: String,
args: Value,
_ctx: ToolExecCtx,
) -> BoxFuture<'static, std::result::Result<ToolResult, ToolError>> {
let r = self.execute_tool(&name, &args);
Box::pin(async move { r })
}
fn transform_context(&self, _messages: &mut Vec<AgentMessage>) -> Result<()> {
Ok(())
}
fn transform_context_with_system(&self, _messages: &mut Vec<AgentMessage>) -> Result<()> {
Ok(())
}
fn before_tool_call(
&self,
_name: &str,
_args: &Value,
) -> std::result::Result<Option<Value>, ToolError> {
Ok(None)
}
fn after_tool_call(
&self,
_name: &str,
_args: &Value,
result: &str,
) -> std::result::Result<String, ToolError> {
Ok(result.to_string())
}
fn before_tool_call_ext(
&self,
name: &str,
args: &Value,
_assistant_message: Option<&AgentMessage>,
_context: &[AgentMessage],
_all_args: &Value,
) -> std::result::Result<BeforeToolCallOutcome, ToolError> {
match self.before_tool_call(name, args) {
Ok(Some(new_args)) => Ok(BeforeToolCallOutcome {
block: false,
reason: None,
terminate: false,
args: Some(new_args),
}),
Ok(None) => Ok(BeforeToolCallOutcome::allow()),
Err(e) => Ok(BeforeToolCallOutcome {
block: true,
reason: Some(e.0),
terminate: false,
args: None,
}),
}
}
fn before_tool_call_async(
&self,
name: String,
args: Value,
assistant_message: Option<AgentMessage>,
context: Vec<AgentMessage>,
all_args: Value,
) -> BoxFuture<'static, std::result::Result<BeforeToolCallOutcome, ToolError>> {
let outcome = self.before_tool_call_ext(
&name,
&args,
assistant_message.as_ref(),
&context,
&all_args,
);
Box::pin(async move { outcome })
}
fn after_tool_call_ext(
&self,
name: &str,
args: &Value,
result_text: &str,
_is_error: bool,
) -> std::result::Result<AfterToolCallOutcome, ToolError> {
Ok(AfterToolCallOutcome {
text: self.after_tool_call(name, args, result_text)?,
content: None,
details: None,
is_error: None,
usage: None,
terminate: None,
})
}
}
#[derive(Debug, Clone)]
pub struct BeforeToolCallOutcome {
pub block: bool,
pub reason: Option<String>,
pub terminate: bool,
pub args: Option<Value>,
}
impl BeforeToolCallOutcome {
pub fn allow() -> Self {
BeforeToolCallOutcome {
block: false,
reason: None,
terminate: false,
args: None,
}
}
}
#[derive(Debug, Clone)]
pub struct AfterToolCallOutcome {
pub text: String,
pub content: Option<Vec<ContentBlock>>,
pub details: Option<Value>,
pub is_error: Option<bool>,
pub usage: Option<Usage>,
pub terminate: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserPromptAction {
Continue,
Rewrite(String),
Handled,
}
#[derive(Debug, Clone)]
pub struct ContextEditDraft {
pub target_id: String,
pub replacement: Option<Vec<ContentBlock>>,
}
#[derive(Debug, Clone)]
pub struct BoundaryCompaction {
pub summary: String,
pub tokens_before: Option<u64>,
pub details: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct BoundaryOutcome {
pub r#continue: bool,
pub end: bool,
pub append: Vec<AgentMessage>,
pub edits: Vec<ContextEditDraft>,
pub compactions: Vec<BoundaryCompaction>,
}
impl BoundaryOutcome {
pub fn continue_once() -> Self {
BoundaryOutcome {
r#continue: true,
..Default::default()
}
}
pub fn continue_with(append: Vec<AgentMessage>) -> Self {
BoundaryOutcome {
r#continue: true,
append,
..Default::default()
}
}
pub fn end() -> Self {
BoundaryOutcome {
end: true,
..Default::default()
}
}
pub fn with_omit(mut self, target_id: impl Into<String>) -> Self {
self.edits.push(ContextEditDraft {
target_id: target_id.into(),
replacement: None,
});
self
}
pub fn with_content_replace(
mut self,
target_id: impl Into<String>,
content: Vec<ContentBlock>,
) -> Self {
self.edits.push(ContextEditDraft {
target_id: target_id.into(),
replacement: Some(content),
});
self
}
pub fn with_retain_none_compaction(mut self, summary: impl Into<String>) -> Self {
self.compactions.push(BoundaryCompaction {
summary: summary.into(),
tokens_before: None,
details: None,
});
self
}
pub fn has_drafts(&self) -> bool {
!self.edits.is_empty() || !self.compactions.is_empty()
}
}
pub fn has_context_with_system_handlers() -> bool {
registered()
.iter()
.any(|ext| ext.hooks().contains(&ExtensionHook::ContextWithSystem))
}
pub fn dispatch_context_with_system(messages: &mut Vec<AgentMessage>) -> Result<()> {
for ext in registered() {
if ext.hooks().contains(&ExtensionHook::ContextWithSystem) {
ext.transform_context_with_system(messages)?;
}
}
Ok(())
}
pub fn has_boundary_handlers() -> bool {
registered()
.iter()
.any(|ext| ext.hooks().contains(&ExtensionHook::Boundary))
}
pub fn dispatch_boundary(event: &Value) -> BoundaryOutcome {
let mut out = BoundaryOutcome::default();
for ext in registered() {
if !ext.hooks().contains(&ExtensionHook::Boundary) {
continue;
}
if let Some(d) = ext.on_boundary(event) {
out.r#continue |= d.r#continue;
out.end |= d.end;
out.append.extend(d.append);
out.edits.extend(d.edits);
out.compactions.extend(d.compactions);
}
}
out
}
pub fn dispatch_cache_warming_decision(event: &Value) -> Option<String> {
let mut last: Option<String> = None;
for ext in registered() {
if !ext.hooks().contains(&ExtensionHook::CacheWarmingDecision) {
continue;
}
if let Some(action) = ext.cache_warming_decision(event)
&& matches!(action, "warm" | "stop")
{
last = Some(action.to_string());
}
}
last
}
fn activated_deferred() -> &'static Mutex<HashSet<String>> {
ACTIVATED_DEFERRED_TOOLS.get_or_init(|| Mutex::new(HashSet::new()))
}
pub fn is_deferred_tool_activated(name: &str) -> bool {
activated_deferred().lock().unwrap().contains(name)
}
pub fn activate_deferred_tools(names: &[String]) {
let mut set = activated_deferred().lock().unwrap();
for name in names {
if set.insert(name.clone()) {
DEFERRED_ACTIVATION_DIRTY.store(true, Ordering::Relaxed);
}
}
}
pub fn take_deferred_activation_dirty() -> bool {
DEFERRED_ACTIVATION_DIRTY.swap(false, Ordering::Relaxed)
}
pub fn restore_deferred_activations(messages: &[AgentMessage]) -> bool {
let pending: Vec<String> = deferred_tools()
.into_iter()
.filter(|t| !is_deferred_tool_activated(&t.name))
.map(|t| t.name)
.collect();
if pending.is_empty() {
return false;
}
let mut names: Vec<String> = Vec::new();
for msg in messages {
for block in &msg.content {
if let ContentBlock::ToolCall { name, .. } = block
&& pending.contains(name)
&& !names.contains(name)
{
names.push(name.clone());
}
}
}
if names.is_empty() {
return false;
}
activate_deferred_tools(&names);
true
}
pub fn clear_deferred_activations() {
activated_deferred().lock().unwrap().clear();
DEFERRED_ACTIVATION_DIRTY.store(false, Ordering::Relaxed);
}
pub fn is_nested_call_event(event: &Value) -> bool {
event
.get("parentToolCallId")
.and_then(|v| v.as_str())
.is_some()
}
pub fn deferred_tools() -> Vec<ExtensionTool> {
let mut out: Vec<ExtensionTool> = Vec::new();
for ext in registered() {
let tools = ext.tools();
if !tools.iter().any(|t| t.exposure == ToolExposure::Deferred) {
continue;
}
for tool in ext.filter_extension_tools(tools) {
if tool.exposure == ToolExposure::Deferred && !out.iter().any(|t| t.name == tool.name) {
out.push(tool);
}
}
}
out
}
pub fn unactivated_deferred_tools() -> Vec<ExtensionTool> {
deferred_tools()
.into_iter()
.filter(|t| !is_deferred_tool_activated(&t.name))
.collect()
}
pub fn has_provider_stream_event_handlers() -> bool {
registered()
.iter()
.any(|ext| ext.hooks().contains(&ExtensionHook::ProviderStreamEvent))
}
pub fn dispatch_provider_stream_event(
provider: &str,
api: &str,
model: &str,
data: &serde_json::Value,
) {
let event = serde_json::json!({
"type": "provider_stream_event",
"provider": provider,
"api": api,
"model": model,
"data": data,
});
for ext in registered() {
if ext.hooks().contains(&ExtensionHook::ProviderStreamEvent) {
ext.provider_stream_event(&event);
}
}
}
pub fn has_cache_warming_decision_handlers() -> bool {
registered()
.iter()
.any(|ext| ext.hooks().contains(&ExtensionHook::CacheWarmingDecision))
}
pub fn wants_redraw() -> bool {
registered().iter().any(|ext| ext.wants_redraw())
}
pub fn dispatch_user_prompt(
text: &str,
messages: &[AgentMessage],
busy: bool,
) -> Option<UserPromptAction> {
for ext in registered() {
if !ext.hooks().contains(&ExtensionHook::UserPrompt) {
continue;
}
if let Some(action) = ext.on_user_prompt_with_context(text, messages, busy) {
return Some(action);
}
}
None
}
pub fn dispatch_user_submit(text: &str, busy: bool) {
for ext in registered() {
if ext.hooks().contains(&ExtensionHook::UserPrompt) {
ext.on_user_submit(text, busy);
}
}
}
pub fn dispatch_exec_ctx(ctx: &ToolExecCtx) {
for ext in registered() {
ext.on_exec_ctx(ctx);
}
}
pub fn declared_mcp_servers() -> Vec<(String, String, Value)> {
let mut out: Vec<(String, String, Value)> = Vec::new();
for ext in registered() {
let owner = ext.name().to_string();
for (name, entry) in ext.mcp_servers() {
if out.iter().any(|(_, existing, _)| *existing == name) {
continue;
}
out.push((owner.clone(), name, entry));
}
}
out
}
pub fn dispatch_agent_event(event: &Value) {
for ext in registered() {
if ext.hooks().contains(&ExtensionHook::AgentEvent) {
ext.on_agent_event(event);
}
}
for ext in registered_footers() {
ext.on_agent_event(event);
}
}
pub fn dispatch_session_switched(path: Option<&str>, messages: &[AgentMessage]) {
for ext in registered() {
ext.on_session_switched(path, messages);
}
dispatch_footer_session(path, messages);
}
pub fn dispatch_footer_session(path: Option<&str>, messages: &[AgentMessage]) {
for ext in registered_footers() {
ext.on_session_switched(path, messages);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Result;
use std::sync::Arc;
struct Echo;
impl Extension for Echo {
fn name(&self) -> &str {
"echo"
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![ExtensionTool::simple(
"echo",
"echo the input",
serde_json::json!({ "type": "object" }),
"echo the input",
)]
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![
ExtensionHook::TransformContext,
ExtensionHook::BeforeToolCall,
ExtensionHook::AfterToolCall,
]
}
fn execute_tool(
&self,
name: &str,
args: &Value,
) -> std::result::Result<ToolResult, ToolError> {
if name != "echo" {
return Err(ToolError("no such tool".to_string()));
}
Ok(ToolResult::text(
args.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
))
}
fn transform_context(&self, messages: &mut Vec<AgentMessage>) -> Result<()> {
if messages.is_empty() {
messages.push(AgentMessage::user_text("injected"));
}
Ok(())
}
fn before_tool_call(
&self,
_n: &str,
_args: &Value,
) -> std::result::Result<Option<Value>, ToolError> {
Ok(Some(serde_json::json!({"text": "rewritten"})))
}
fn after_tool_call(
&self,
_n: &str,
_args: &Value,
result: &str,
) -> std::result::Result<String, ToolError> {
Ok(format!("{result} (wrapped)"))
}
}
#[test]
fn registration_issues_warn_on_shadowed_and_invalid_declarations() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct First;
impl Extension for First {
fn name(&self) -> &str {
"collision-first"
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![ExtensionTool::simple(
"collide-tool",
"d",
serde_json::json!({ "type": "object" }),
"s",
)]
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: "collide-cmd".to_string(),
description: String::new(),
busy_safe: true,
subcommands: Vec::new(),
}]
}
fn cli_flags(&self) -> Vec<CliFlagDef> {
vec![CliFlagDef {
name: "collide-flag",
description: "",
takes_value: false,
}]
}
}
struct Second;
impl Extension for Second {
fn name(&self) -> &str {
"collision-second"
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![ExtensionTool::simple(
"collide-tool",
"d",
serde_json::json!({ "type": "object" }),
"s",
)]
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: "collide-cmd".to_string(),
description: String::new(),
busy_safe: true,
subcommands: Vec::new(),
}]
}
fn cli_flags(&self) -> Vec<CliFlagDef> {
vec![CliFlagDef {
name: "collide-flag",
description: "",
takes_value: false,
}]
}
}
register_extension(First);
register_extension(Second);
let warnings = crate::core::extensions::registration_issues();
let joined = warnings.join("\n");
assert!(
joined.contains("collision-second")
&& joined.contains("`collision-first`")
&& joined.contains("tool `collide-tool`"),
"应告警同名工具:{joined}"
);
assert!(joined.contains("command `/collide-cmd`"), "{joined}");
assert!(joined.contains("flag `--collide-flag`"), "{joined}");
assert!(unregister_extension("collision-first"));
assert!(unregister_extension("collision-second"));
let after = crate::core::extensions::registration_issues().join("\n");
assert!(!after.contains("collide-tool"), "注销后不再告警:{after}");
}
#[test]
fn invalid_command_names_are_reported_and_ignored() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct Bad;
impl Extension for Bad {
fn name(&self) -> &str {
"bad-command-names"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![
ExtensionCommand {
name: String::new(),
description: String::new(),
busy_safe: true,
subcommands: Vec::new(),
},
ExtensionCommand {
name: "bad/name".to_string(),
description: String::new(),
busy_safe: true,
subcommands: Vec::new(),
},
ExtensionCommand {
name: "good-cmd".to_string(),
description: String::new(),
busy_safe: true,
subcommands: vec![
SubcommandDef {
name: "",
description: "bad sub",
},
SubcommandDef {
name: "ok sub",
description: "bad sub",
},
],
},
]
}
}
register_extension(Bad);
let joined = crate::core::extensions::registration_issues().join("\n");
assert!(
joined.contains("bad-command-names") && joined.contains("invalid name"),
"应告警非法命令名:{joined}"
);
assert!(
joined.contains("subcommand") && joined.contains("/good-cmd"),
"应告警非法子命令名:{joined}"
);
let commands = crate::core::extensions::registered_commands();
assert!(
!commands
.iter()
.any(|c| c.name.is_empty() || c.name == "bad/name"),
"非法命令名不应进入候选列表:{:?}",
commands.iter().map(|c| &c.name).collect::<Vec<_>>()
);
let good = commands
.iter()
.find(|c| c.name == "good-cmd")
.expect("合法命令仍应保留");
assert!(good.subcommands.is_empty(), "非法子命令应被丢弃");
assert!(crate::core::extensions::command_provider("").is_none());
assert!(crate::core::extensions::command_provider("bad/name").is_none());
assert!(unregister_extension("bad-command-names"));
}
#[test]
fn register_and_dispatch() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
register_extension(Echo);
let exts = registered();
let echo = exts
.iter()
.find(|e| e.name() == "echo")
.expect("echo registered");
let out = echo.execute_tool("echo", &serde_json::json!({"text": "hi"}));
assert!(matches!(out, Ok(ToolResult { text: t, .. }) if t == "hi"));
let bad = echo.execute_tool("nope", &serde_json::json!({}));
assert!(bad.is_err());
assert!(unregister_extension("echo"));
}
#[test]
fn user_prompt_dispatch_gates_and_forwards_actions() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
set_extension_mode(ExtensionMode::All);
let _sub = crate::extensions::subagent::test_lock();
struct Silent;
impl Extension for Silent {
fn name(&self) -> &str {
"prompt-silent"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn on_user_prompt(&self, _t: &str) -> Option<UserPromptAction> {
Some(UserPromptAction::Handled)
}
}
struct Claiming;
impl Extension for Claiming {
fn name(&self) -> &str {
"prompt-claiming"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::UserPrompt]
}
fn on_user_prompt(&self, text: &str) -> Option<UserPromptAction> {
match text {
"claim" => Some(UserPromptAction::Handled),
"rewrite" => Some(UserPromptAction::Rewrite("RW".into())),
_ => None,
}
}
fn on_user_submit(&self, _text: &str, _busy: bool) {
SUBMITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
static SUBMITS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
register_extension(Silent);
assert_eq!(
dispatch_user_prompt("claim", &[], false),
None,
"未声明 UserPrompt 的扩展不得被询问"
);
register_extension(Claiming);
assert_eq!(
dispatch_user_prompt("claim", &[], false),
Some(UserPromptAction::Handled)
);
assert_eq!(
dispatch_user_prompt("rewrite", &[], false),
Some(UserPromptAction::Rewrite("RW".into()))
);
assert_eq!(dispatch_user_prompt("pass", &[], false), None);
assert_eq!(SUBMITS.load(std::sync::atomic::Ordering::Relaxed), 0);
dispatch_user_submit("claim", false);
dispatch_user_submit("rewrite", true);
assert_eq!(
SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
2,
"提交广播不看拦截结果,也不止一次"
);
assert!(unregister_extension("prompt-claiming"));
assert!(unregister_extension("prompt-silent"));
}
#[test]
fn exec_ctx_dispatch_reaches_all_registered() {
use std::sync::atomic::AtomicUsize;
static CALLS: AtomicUsize = AtomicUsize::new(0);
struct CtxExt;
impl Extension for CtxExt {
fn name(&self) -> &str {
"ctx-ext"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn on_exec_ctx(&self, _ctx: &ToolExecCtx) {
CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
set_extension_mode(ExtensionMode::All);
let _sub = crate::extensions::subagent::test_lock();
register_extension(CtxExt);
let ctx = ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: "/tmp/x".to_string(),
make_sub_agent: std::sync::Arc::new(|_| Err("unused".to_string())),
parent_abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
};
let before = CALLS.load(std::sync::atomic::Ordering::Relaxed);
dispatch_exec_ctx(&ctx);
assert!(CALLS.load(std::sync::atomic::Ordering::Relaxed) > before);
assert!(unregister_extension("ctx-ext"));
}
#[test]
fn enable_disable_filters_registered() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct ToggleExt;
impl Extension for ToggleExt {
fn name(&self) -> &str {
"toggle-ext"
}
fn description(&self) -> &str {
"toggle description"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
register_extension(ToggleExt);
assert!(is_extension_enabled("toggle-ext"));
assert!(
panel_entries()
.iter()
.any(|(n, e, _m)| n == "toggle-ext" && *e)
);
assert!(set_extension_enabled("toggle-ext", false));
assert!(!is_extension_enabled("toggle-ext"));
assert!(registered().iter().all(|e| e.name() != "toggle-ext"));
let lines = extension_detail_lines("toggle-ext");
assert!(
lines.iter().any(|l| l.contains("toggle description")),
"detail lines: {:?}",
lines
);
assert!(set_extension_enabled("toggle-ext", true));
assert!(registered().iter().any(|e| e.name() == "toggle-ext"));
assert!(unregister_extension("toggle-ext"));
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn extension_detail_lines_survives_registry_reentry() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct ReentrantDetailExt;
impl Extension for ReentrantDetailExt {
fn name(&self) -> &str {
"reentrant-detail-ext"
}
fn tools(&self) -> Vec<ExtensionTool> {
let _ = registered();
Vec::new()
}
}
register_extension(ReentrantDetailExt);
let lines =
crate::test_support::run_with_timeout(std::time::Duration::from_secs(10), || {
extension_detail_lines("reentrant-detail-ext")
});
assert!(
lines.iter().any(|l| l.starts_with(" mode: ")),
"详情行应正常生成: {lines:?}"
);
assert!(unregister_extension("reentrant-detail-ext"));
}
#[test]
fn extension_detail_lines_show_namespace_exposure_and_annotations() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct AnnotatedExt;
impl Extension for AnnotatedExt {
fn name(&self) -> &str {
"annotated-tools-ext"
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![
ExtensionTool::simple("plain_tool", "plain", serde_json::json!({}), "")
.with_namespace("ns")
.with_annotations(ToolAnnotations {
read_only: true,
..Default::default()
}),
ExtensionTool::simple("inner_tool", "inner", serde_json::json!({}), "")
.with_exposure(ToolExposure::Hidden),
]
}
}
register_extension(AnnotatedExt);
let lines = extension_detail_lines("annotated-tools-ext");
let tools_line = lines
.iter()
.find(|l| l.starts_with(" tools: "))
.unwrap_or_else(|| panic!("缺少 tools 行: {lines:?}"));
assert!(
tools_line.contains("ns/plain_tool (read-only)"),
"{tools_line}"
);
assert!(tools_line.contains("inner_tool (hidden)"), "{tools_line}");
assert!(unregister_extension("annotated-tools-ext"));
}
#[test]
fn extension_detail_lines_blank_after_description_and_fork_info() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct ForkExt;
impl Extension for ForkExt {
fn name(&self) -> &str {
"fork-detail-ext"
}
fn description(&self) -> &str {
"fork detail description"
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "pi-demo".to_string(),
plugin_version: "9.9.9".to_string(),
url: String::new(),
})
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
register_extension(ForkExt);
let lines = extension_detail_lines("fork-detail-ext");
assert_eq!(lines[0], " fork detail description", "首行应为描述");
assert_eq!(lines[1], "", "描述后应有一行空行: {lines:?}");
let fork_idx = lines
.iter()
.position(|l| l == " forked from: pi-demo v9.9.9")
.unwrap_or_else(|| panic!("缺少 fork 来源行: {lines:?}"));
assert!(
fork_idx > 0 && lines[fork_idx - 1].is_empty(),
"fork 来源前应有空行: {lines:?}"
);
assert!(
lines.iter().all(|l| !l.contains("fork url")),
"空 url 不应渲染 fork url 行: {lines:?}"
);
assert!(unregister_extension("fork-detail-ext"));
}
#[test]
fn extension_detail_lines_report_prerequisite_tools() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct Provider;
impl Extension for Provider {
fn name(&self) -> &str {
"prereq-provider"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Minimal]
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![ExtensionTool::simple(
"prereq_tool",
"d",
serde_json::json!({}),
"s",
)]
}
}
struct Consumer;
impl Extension for Consumer {
fn name(&self) -> &str {
"prereq-consumer"
}
fn prerequisite_tools(&self) -> Vec<String> {
vec!["prereq_tool".to_string(), "missing_tool".to_string()]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
register_extension(Consumer);
let lines = extension_detail_lines("prereq-consumer");
assert!(
lines.iter().any(|l| l
== " requires: prereq_tool (no extension provides it), missing_tool (no extension provides it)"),
"detail lines: {lines:?}"
);
register_extension(Provider);
let lines = extension_detail_lines("prereq-consumer");
assert!(
lines.iter().any(|l| l
== " requires: prereq-provider (prereq_tool), missing_tool (no extension provides it)"),
"detail lines: {lines:?}"
);
assert!(set_extension_enabled("prereq-provider", false));
let lines = extension_detail_lines("prereq-consumer");
assert!(
lines.iter().any(|l| l
== " requires: prereq-provider (prereq_tool) - enable it, missing_tool (no extension provides it)"),
"detail lines: {lines:?}"
);
assert!(
extension_detail_lines("prereq-provider")
.iter()
.all(|l| !l.starts_with(" requires:")),
"无前置工具时不应渲染 requires 行"
);
let warnings = unsatisfied_prerequisites();
assert!(
warnings.iter().any(|w| w.contains("`prereq-consumer`")
&& w.contains("prereq-provider (prereq_tool), which is not enabled")),
"warnings: {warnings:?}"
);
assert!(
warnings
.iter()
.all(|w| !w.starts_with("extension `prereq-provider`")),
"warnings: {warnings:?}"
);
assert!(unregister_extension("prereq-consumer"));
assert!(unregister_extension("prereq-provider"));
}
#[test]
fn default_disabled_extension_requires_explicit_opt_in() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_extension_states(&[], &[]).ok();
struct OptInExt;
impl Extension for OptInExt {
fn name(&self) -> &str {
"opt-in-ext"
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
register_extension(OptInExt);
assert!(!is_extension_enabled("opt-in-ext"));
assert!(registered().iter().all(|e| e.name() != "opt-in-ext"));
assert!(
panel_entries()
.iter()
.any(|(n, e, _)| n == "opt-in-ext" && !*e),
"面板应列出但为未勾选"
);
assert!(set_extension_enabled("opt-in-ext", true));
assert!(is_extension_enabled("opt-in-ext"));
persist_extension_states().unwrap();
assert!(
crate::core::settings_manager::read_enabled_extensions()
.contains(&"opt-in-ext".to_string()),
"开启应写 enabledExtensions"
);
assert!(
!crate::core::settings_manager::read_disabled_extensions()
.contains(&"opt-in-ext".to_string())
);
assert!(unregister_extension("opt-in-ext"));
register_extension(OptInExt);
assert!(is_extension_enabled("opt-in-ext"));
assert!(registered().iter().any(|e| e.name() == "opt-in-ext"));
assert!(set_extension_enabled("opt-in-ext", false));
persist_extension_states().unwrap();
assert!(
!crate::core::settings_manager::read_enabled_extensions()
.contains(&"opt-in-ext".to_string())
);
assert!(
!crate::core::settings_manager::read_disabled_extensions()
.contains(&"opt-in-ext".to_string())
);
assert!(unregister_extension("opt-in-ext"));
crate::core::settings_manager::write_extension_states(&[], &[]).ok();
}
#[test]
fn footer_extensions_appear_in_panel() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
struct FooterSpy;
impl FooterExtension for FooterSpy {
fn name(&self) -> &str {
"footer-spy"
}
fn description(&self) -> &str {
"footer desc"
}
fn render(&self, _ctx: &FooterCtx) -> Vec<FooterLine> {
Vec::new()
}
}
register_footer_extension(FooterSpy);
assert!(
panel_entries()
.iter()
.any(|(n, e, _m)| n == "footer-spy" && *e)
);
assert!(set_extension_enabled("footer-spy", false));
assert!(
registered_footers()
.iter()
.all(|e| e.name() != "footer-spy")
);
let lines = extension_detail_lines("footer-spy");
assert!(
lines.iter().any(|l| l.contains("footer extension")),
"detail lines: {:?}",
lines
);
assert!(set_extension_enabled("footer-spy", true));
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn footer_extensions_are_mutually_exclusive() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct FooterF1;
impl FooterExtension for FooterF1 {
fn name(&self) -> &str {
"footer-f1"
}
fn render(&self, _ctx: &FooterCtx) -> Vec<FooterLine> {
Vec::new()
}
}
struct FooterF2;
impl FooterExtension for FooterF2 {
fn name(&self) -> &str {
"footer-f2"
}
fn render(&self, _ctx: &FooterCtx) -> Vec<FooterLine> {
Vec::new()
}
}
register_footer_extension(FooterF1);
register_footer_extension(FooterF2);
assert!(!is_extension_enabled("footer-f1"), "f2 注册互斥禁用 f1");
assert!(is_extension_enabled("footer-f2"));
assert!(set_extension_enabled("footer-f1", true));
assert!(is_extension_enabled("footer-f1"));
assert!(!is_extension_enabled("footer-f2"), "启用 f1 互斥禁用 f2");
assert!(set_extension_enabled("footer-f1", false));
assert!(!is_extension_enabled("footer-f2"), "禁用 f1 不应动 f2");
let names = footer_names();
assert!(
names.contains(&"footer-f1".to_string()) && names.contains(&"footer-f2".to_string())
);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn unknown_extension_toggle_reports_not_found() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
assert!(!set_extension_enabled("no-such-ext", false));
assert!(!is_extension_enabled("no-such-ext"));
assert!(extension_detail_lines("no-such-ext").is_empty());
}
#[test]
fn banner_extensions_appear_in_panel() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct BannerSpy;
impl BannerExtension for BannerSpy {
fn name(&self) -> &str {
"banner-spy"
}
fn description(&self) -> &str {
"banner desc"
}
fn render(&self, _ctx: &BannerCtx) -> Vec<BannerLine> {
Vec::new()
}
}
register_banner_extension(BannerSpy);
assert!(
panel_entries()
.iter()
.any(|(n, e, _m)| n == "banner-spy" && *e)
);
assert!(banner_names().contains(&"banner-spy".to_string()));
assert!(set_extension_enabled("banner-spy", false));
assert!(
registered_banners()
.iter()
.all(|e| e.name() != "banner-spy")
);
let lines = extension_detail_lines("banner-spy");
assert!(
lines.iter().any(|l| l.contains("banner extension")),
"detail lines: {:?}",
lines
);
assert!(set_extension_enabled("banner-spy", true));
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn banner_extensions_are_mutually_exclusive() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct BannerF1;
impl BannerExtension for BannerF1 {
fn name(&self) -> &str {
"banner-f1"
}
fn render(&self, _ctx: &BannerCtx) -> Vec<BannerLine> {
Vec::new()
}
}
struct BannerF2;
impl BannerExtension for BannerF2 {
fn name(&self) -> &str {
"banner-f2"
}
fn render(&self, _ctx: &BannerCtx) -> Vec<BannerLine> {
Vec::new()
}
}
register_banner_extension(BannerF1);
register_banner_extension(BannerF2);
assert!(!is_extension_enabled("banner-f1"), "f2 注册互斥禁用 f1");
assert!(is_extension_enabled("banner-f2"));
assert!(set_extension_enabled("banner-f1", true));
assert!(is_extension_enabled("banner-f1"));
assert!(!is_extension_enabled("banner-f2"), "启用 f1 互斥禁用 f2");
assert!(set_extension_enabled("banner-f1", false));
assert!(!is_extension_enabled("banner-f2"), "禁用 f1 不应动 f2");
assert!(registered_banners().is_empty(), "全部禁用 → 无 banner 渲染");
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn toggling_banner_does_not_touch_footer_state() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_extension_states(&[], &[]).ok();
struct FooterIso;
impl FooterExtension for FooterIso {
fn name(&self) -> &str {
"footer-iso"
}
fn render(&self, _ctx: &FooterCtx) -> Vec<FooterLine> {
Vec::new()
}
}
struct BannerIso;
impl BannerExtension for BannerIso {
fn name(&self) -> &str {
"banner-iso"
}
fn render(&self, _ctx: &BannerCtx) -> Vec<BannerLine> {
Vec::new()
}
}
register_footer_extension(FooterIso);
register_banner_extension(BannerIso);
assert!(is_extension_enabled("footer-iso"));
assert!(set_extension_enabled("banner-iso", false));
assert!(
is_extension_enabled("footer-iso"),
"禁用 banner 不应影响 footer"
);
assert!(set_extension_enabled("banner-iso", true));
assert!(
is_extension_enabled("footer-iso"),
"启用 banner 不应禁用 footer"
);
persist_extension_states().unwrap();
let disabled = crate::core::settings_manager::read_disabled_extensions();
assert!(
!disabled.contains(&"footer-iso".to_string()),
"启用中的 footer 泄漏进 disabledExtensions: {disabled:?}"
);
assert!(set_extension_enabled("banner-iso", false));
assert!(set_extension_enabled("footer-iso", false));
crate::core::settings_manager::write_extension_states(&[], &[]).ok();
}
#[test]
fn mode_levels_filter_extensions_by_priority() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_extension_mode("all").ok();
struct MinimalExt;
impl Extension for MinimalExt {
fn name(&self) -> &str {
"minimal-ext"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Minimal]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
struct DevExt;
impl Extension for DevExt {
fn name(&self) -> &str {
"dev-ext"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
struct CreatorExt;
impl Extension for CreatorExt {
fn name(&self) -> &str {
"creator-ext"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
struct SiblingExt;
impl Extension for SiblingExt {
fn name(&self) -> &str {
"sibling-ext"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
struct AllOnlyExt;
impl Extension for AllOnlyExt {
fn name(&self) -> &str {
"all-only-ext"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
}
register_extension(MinimalExt);
register_extension(DevExt);
register_extension(CreatorExt);
register_extension(SiblingExt);
register_extension(AllOnlyExt);
let available =
|| -> Vec<String> { registered().iter().map(|e| e.name().to_string()).collect() };
let has = |names: &[String], n: &str| names.iter().any(|x| x == n);
set_extension_mode(ExtensionMode::All);
let names = available();
for n in [
"minimal-ext",
"dev-ext",
"creator-ext",
"sibling-ext",
"all-only-ext",
] {
assert!(has(&names, n), "All 模式下 {n} 应可用: {names:?}");
}
set_extension_mode(ExtensionMode::Dev);
let names = available();
for n in ["minimal-ext", "dev-ext", "sibling-ext"] {
assert!(has(&names, n), "Dev 模式下 {n} 应可用: {names:?}");
}
assert!(
!has(&names, "creator-ext"),
"并排的 Creator 不可用: {names:?}"
);
assert!(!has(&names, "all-only-ext"), "{names:?}");
set_extension_mode(ExtensionMode::Creator);
let names = available();
for n in ["minimal-ext", "creator-ext", "sibling-ext"] {
assert!(has(&names, n), "Creator 模式下 {n} 应可用: {names:?}");
}
assert!(!has(&names, "dev-ext"), "并排的 Dev 不可用: {names:?}");
assert!(!has(&names, "all-only-ext"), "{names:?}");
set_extension_mode(ExtensionMode::Minimal);
let names = available();
assert!(has(&names, "minimal-ext"), "{names:?}");
for n in ["dev-ext", "creator-ext", "sibling-ext", "all-only-ext"] {
assert!(!has(&names, n), "{n} 不应在 Minimal 可用: {names:?}");
}
let all: Vec<String> = panel_entries().iter().map(|(n, _, _)| n.clone()).collect();
assert!(all.iter().any(|n| n == "all-only-ext"), "{all:?}");
assert!(is_extension_enabled("all-only-ext"));
assert_eq!(extension_modes("minimal-ext"), vec![ExtensionMode::Minimal]);
assert_eq!(extension_modes("dev-ext"), vec![ExtensionMode::Dev]);
assert_eq!(extension_modes("creator-ext"), vec![ExtensionMode::Creator]);
assert_eq!(
extension_modes("sibling-ext"),
vec![ExtensionMode::Dev, ExtensionMode::Creator]
);
assert_eq!(extension_modes("all-only-ext"), Vec::<ExtensionMode>::new());
assert_eq!(extension_modes("no-such-ext"), Vec::<ExtensionMode>::new());
set_extension_mode(ExtensionMode::All);
assert!(has(&available(), "all-only-ext"));
crate::core::settings_manager::write_extension_mode("all").ok();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
for n in [
"minimal-ext",
"dev-ext",
"creator-ext",
"sibling-ext",
"all-only-ext",
] {
assert!(unregister_extension(n));
}
}
#[test]
fn enabled_changed_respects_extension_mode() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_all_extensions_disabled(false);
struct Probe {
log: Mutex<Vec<bool>>,
}
impl Extension for Probe {
fn name(&self) -> &str {
"mode-callback-probe"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn on_enabled_changed(&self, enabled: bool) {
self.log.lock().unwrap().push(enabled);
}
}
set_extension_mode(ExtensionMode::Minimal);
let probe = Arc::new(Probe {
log: Mutex::new(Vec::new()),
});
register_extension_arc(probe.clone());
assert_eq!(
*probe.log.lock().unwrap(),
vec![false],
"Minimal 模式下注册不得通知启用"
);
set_extension_mode(ExtensionMode::Dev);
assert_eq!(*probe.log.lock().unwrap(), vec![false, true]);
set_extension_mode(ExtensionMode::Minimal);
assert_eq!(*probe.log.lock().unwrap(), vec![false, true, false]);
assert!(is_extension_enabled("mode-callback-probe"));
assert!(!is_extension_active("mode-callback-probe"));
set_extension_mode(ExtensionMode::Minimal);
assert_eq!(probe.log.lock().unwrap().len(), 3);
assert!(set_extension_enabled("mode-callback-probe", true));
assert_eq!(*probe.log.lock().unwrap(), vec![false, true, false, false]);
set_extension_mode(ExtensionMode::Dev);
assert!(is_extension_active("mode-callback-probe"));
assert_eq!(
*probe.log.lock().unwrap(),
vec![false, true, false, false, true]
);
assert!(set_extension_enabled("mode-callback-probe", false));
assert!(!is_extension_active("mode-callback-probe"));
assert!(unregister_extension("mode-callback-probe"));
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_extension_mode("all").ok();
}
#[test]
fn mode_levels_are_ordered_and_tab_cycles_descending() {
assert_eq!(ExtensionMode::Minimal.level(), 0);
assert_eq!(ExtensionMode::Dev.level(), 1);
assert_eq!(ExtensionMode::Creator.level(), 1);
assert_eq!(ExtensionMode::All.level(), 2);
assert!(!ExtensionMode::Dev.usable_in(ExtensionMode::Creator));
assert!(!ExtensionMode::Creator.usable_in(ExtensionMode::Dev));
assert_eq!(
ExtensionMode::Minimal.covered_modes(),
ExtensionMode::ALL.to_vec()
);
assert_eq!(
ExtensionMode::Dev.covered_modes(),
vec![ExtensionMode::Dev, ExtensionMode::All]
);
assert_eq!(ExtensionMode::All.covered_modes(), vec![ExtensionMode::All]);
let mut m = ExtensionMode::All;
let mut seen = Vec::new();
for _ in 0..4 {
seen.push(m.as_str());
m = m.next();
}
assert_eq!(seen, vec!["all", "dev", "creator", "minimal"]);
assert_eq!(m, ExtensionMode::All, "循环一圈回到起点");
for m in ExtensionMode::ALL {
assert_eq!(ExtensionMode::parse(m.as_str()), m);
}
assert_eq!(ExtensionMode::parse("bogus"), ExtensionMode::All);
}
#[test]
fn hooks_declared_and_invoked() {
let ext = Echo;
assert_eq!(ext.hooks().len(), 3);
let mut msgs = Vec::new();
ext.transform_context(&mut msgs).expect("transform");
assert_eq!(msgs.len(), 1);
assert!(
ext.before_tool_call("x", &serde_json::json!({}))
.unwrap()
.is_some()
);
assert_eq!(
ext.after_tool_call("x", &serde_json::json!({}), "r")
.unwrap(),
"r (wrapped)"
);
}
#[test]
fn agent_event_gated_by_hook_declaration() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
use std::sync::atomic::{AtomicUsize, Ordering};
static WITH: AtomicUsize = AtomicUsize::new(0);
static WITHOUT: AtomicUsize = AtomicUsize::new(0);
struct WithHook;
impl Extension for WithHook {
fn name(&self) -> &str {
"agent-event-with"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::AgentEvent]
}
fn on_agent_event(&self, _e: &Value) {
WITH.fetch_add(1, Ordering::Relaxed);
}
}
struct WithoutHook;
impl Extension for WithoutHook {
fn name(&self) -> &str {
"agent-event-without"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn on_agent_event(&self, _e: &Value) {
WITHOUT.fetch_add(1, Ordering::Relaxed);
}
}
register_extension(WithHook);
register_extension(WithoutHook);
dispatch_agent_event(&serde_json::json!({ "type": "x" }));
let with = WITH.load(Ordering::Relaxed);
let without = WITHOUT.load(Ordering::Relaxed);
assert!(unregister_extension("agent-event-with"));
assert!(unregister_extension("agent-event-without"));
assert!(with >= 1, "声明 AgentEvent 应收到事件");
assert_eq!(without, 0, "未声明 AgentEvent 不应收到事件");
}
#[test]
fn dock_sections_gated_by_dock_hook() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
set_extension_mode(ExtensionMode::All);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
struct WithDock;
impl Extension for WithDock {
fn name(&self) -> &str {
"dock-with"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::Dock]
}
fn dock_lines(&self) -> Vec<DockLine> {
vec![vec![DockSpan::plain("hello")]]
}
}
struct WithoutDock;
impl Extension for WithoutDock {
fn name(&self) -> &str {
"dock-without"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn dock_lines(&self) -> Vec<DockLine> {
vec![vec![DockSpan::plain("nope")]]
}
}
register_extension(WithDock);
register_extension(WithoutDock);
let has_with = dock_sections().iter().any(|s| s.provider == "dock-with");
let has_without = dock_sections().iter().any(|s| s.provider == "dock-without");
assert!(unregister_extension("dock-with"));
assert!(unregister_extension("dock-without"));
assert!(has_with, "声明 Dock 的扩展应有停靠段");
assert!(!has_without, "未声明 Dock 的扩展不应被采集");
}
#[test]
fn suggestion_candidates_gated_by_hook() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct Suggesting;
impl Extension for Suggesting {
fn name(&self) -> &str {
"suggests"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::Suggestions]
}
fn suggestions(&self, query: &str, _line_prefix: &str) -> ExtensionSuggestions {
ExtensionSuggestions {
items: vec![ExtensionSuggestion {
name: format!("@{query}"),
description: "agent".to_string(),
insert: query.to_string(),
}],
exclusive: false,
}
}
}
struct Silent;
impl Extension for Silent {
fn name(&self) -> &str {
"silent"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn suggestions(&self, _query: &str, _line_prefix: &str) -> ExtensionSuggestions {
ExtensionSuggestions {
items: vec![ExtensionSuggestion {
name: "@nope".to_string(),
description: String::new(),
insert: "nope".to_string(),
}],
exclusive: false,
}
}
}
register_extension(Suggesting);
register_extension(Silent);
let items = suggestion_candidates("explore", "/agents stop @explore");
assert!(unregister_extension("suggests"));
assert!(unregister_extension("silent"));
assert_eq!(items.items.len(), 1, "{items:?}");
assert_eq!(items.items[0].name, "@explore");
assert!(!items.exclusive, "未声明 exclusive 时不独占");
}
}
#[cfg(test)]
mod cache_warming_decision_tests {
use super::*;
struct WarmForcing;
impl Extension for WarmForcing {
fn name(&self) -> &str {
"warm-forcing"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::CacheWarmingDecision]
}
fn cache_warming_decision(&self, _event: &Value) -> Option<&'static str> {
Some("warm")
}
}
struct Vetoing;
impl Extension for Vetoing {
fn name(&self) -> &str {
"vetoing"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::CacheWarmingDecision]
}
fn cache_warming_decision(&self, _event: &Value) -> Option<&'static str> {
Some("stop")
}
}
struct Silent;
impl Extension for Silent {
fn name(&self) -> &str {
"silent-cache"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::CacheWarmingDecision]
}
}
#[test]
fn last_defined_action_wins_and_silent_handlers_ignored() {
let event = serde_json::json!({
"type": "cache_warming_decision",
"warmCost": 0.03,
"missCost": 0.345,
"continuationProbability": 1.0,
"action": "warm",
});
assert_eq!(dispatch_cache_warming_decision(&event), None);
assert!(!has_cache_warming_decision_handlers());
register_extension(Silent);
assert_eq!(dispatch_cache_warming_decision(&event), None, "None 不覆盖");
register_extension(WarmForcing);
assert_eq!(
dispatch_cache_warming_decision(&event).as_deref(),
Some("warm")
);
register_extension(Vetoing);
assert_eq!(
dispatch_cache_warming_decision(&event).as_deref(),
Some("stop"),
"最后一个给出 action 的扩展生效"
);
assert!(has_cache_warming_decision_handlers());
assert!(unregister_extension("warm-forcing"));
assert!(unregister_extension("vetoing"));
assert!(unregister_extension("silent-cache"));
}
}