feat(repl): add .prompt command with live staged tab-completion
Implements plans/mcp-resources-prompts-design.md §5.1/§5.4 (T6): - .prompt <server> <name> [key=value ...] fetches an MCP prompt and submits the result as chat input via Input::from_str + ask(), never through REPL line parsing; GetPromptResult messages are flattened into one user-role block with unconditional [user]/[assistant] labels - missing required prompt arguments are collected interactively - .list prompts renders server/name/description/args via the unified catalog (CatalogItem gains an arguments field), degrading per server - staged live tab-completion: enabled+running+prompts-capable servers (no RPC), then live prompt names, then key= argument suggestions with (required) markers; 2s timeout per RPC, all errors degrade to silent empty suggestions, ctx read guard dropped before blocking - the enabled-server alias expansion is factored into a shared helper used by both tool-scope rebuild and completion - BREAKING: the former .prompt <text> temp-role builtin is renamed to .temp-role <text> (behavior preserved); .prompt now belongs to MCP prompts, and a user macro named prompt or temp-role is shadowed
This commit is contained in:
@@ -565,6 +565,34 @@ mod tests {
|
|||||||
assert_eq!(state_of(&policy, "a"), &MacroState::Enabled);
|
assert_eq!(state_of(&policy, "a"), &MacroState::Enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_macro_is_shadowed_by_builtin() {
|
||||||
|
let policy = MacroPolicy::effective_with(
|
||||||
|
globals(&["prompt"]),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&crate::repl::builtin_command_names(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state_of(&policy, "prompt"), &MacroState::ShadowedBuiltin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temp_role_macro_is_shadowed_by_builtin() {
|
||||||
|
let policy = MacroPolicy::effective_with(
|
||||||
|
globals(&["temp-role"]),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&crate::repl::builtin_command_names(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state_of(&policy, "temp-role"), &MacroState::ShadowedBuiltin);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn locked_wins_over_shadowed_builtin() {
|
fn locked_wins_over_shadowed_builtin() {
|
||||||
let l = list(&["a"]);
|
let l = list(&["a"]);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ pub use self::skill_policy::SkillPolicy;
|
|||||||
pub use self::skill_registry::SkillRegistry;
|
pub use self::skill_registry::SkillRegistry;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use self::tool_scope::test_fixtures;
|
pub(crate) use self::tool_scope::test_fixtures;
|
||||||
|
pub use self::tool_scope::{McpPromptCompletion, flatten_prompt_messages};
|
||||||
pub use self::update::run_self_update;
|
pub use self::update::run_self_update;
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
||||||
|
|||||||
+112
-22
@@ -5,7 +5,7 @@ use super::skill::{SKILL_SCAFFOLD, Skill};
|
|||||||
use super::skill_policy::SkillPolicy;
|
use super::skill_policy::SkillPolicy;
|
||||||
use super::skill_registry::SkillRegistry;
|
use super::skill_registry::SkillRegistry;
|
||||||
use super::todo::TodoList;
|
use super::todo::TodoList;
|
||||||
use super::tool_scope::{McpRuntime, ToolScope};
|
use super::tool_scope::{McpPromptCompletion, McpRuntime, ToolScope, format_prompt_arguments};
|
||||||
use super::{
|
use super::{
|
||||||
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
||||||
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel,
|
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel,
|
||||||
@@ -23,8 +23,8 @@ use crate::function::{
|
|||||||
user_interaction::USER_FUNCTION_PREFIX,
|
user_interaction::USER_FUNCTION_PREFIX,
|
||||||
};
|
};
|
||||||
use crate::mcp::{
|
use crate::mcp::{
|
||||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired, is_auth_required_error,
|
CatalogItem, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired,
|
||||||
is_mcp_meta_function, mcp_meta_function_names,
|
McpServersConfig, is_auth_required_error, is_mcp_meta_function, mcp_meta_function_names,
|
||||||
};
|
};
|
||||||
use crate::rag::Rag;
|
use crate::rag::Rag;
|
||||||
use crate::supervisor::Supervisor;
|
use crate::supervisor::Supervisor;
|
||||||
@@ -77,6 +77,29 @@ fn installed_bundle_names() -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expand_enabled_mcp_server_ids(
|
||||||
|
app: &AppConfig,
|
||||||
|
mcp_config: &McpServersConfig,
|
||||||
|
enabled_mcp_servers: &[String],
|
||||||
|
) -> Vec<String> {
|
||||||
|
if enabled_mcp_servers.iter().any(|s| s.trim() == "all") {
|
||||||
|
return mcp_config.mcp_servers.keys().cloned().collect();
|
||||||
|
}
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for item in enabled_mcp_servers.iter().map(|s| s.trim()) {
|
||||||
|
if mcp_config.mcp_servers.contains_key(item) {
|
||||||
|
ids.push(item.to_string());
|
||||||
|
} else if let Some(mapped) = app.mapping_mcp_servers.get(item) {
|
||||||
|
for mapped_id in mapped.split(',').map(|s| s.trim()) {
|
||||||
|
if mcp_config.mcp_servers.contains_key(mapped_id) {
|
||||||
|
ids.push(mapped_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AutoContinueConfig {
|
pub struct AutoContinueConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub max_continues: usize,
|
pub max_continues: usize,
|
||||||
@@ -139,6 +162,20 @@ pub(crate) fn asset_table(header: &[&str]) -> Table {
|
|||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prompt_asset_rows(items: &[CatalogItem]) -> Vec<[String; 4]> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
[
|
||||||
|
item.server.clone(),
|
||||||
|
item.name.clone(),
|
||||||
|
item.description.clone(),
|
||||||
|
format_prompt_arguments(item.arguments.as_deref().unwrap_or_default()),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn complete_skills_with_descriptions(names: Vec<String>) -> Vec<(String, Option<String>)> {
|
fn complete_skills_with_descriptions(names: Vec<String>) -> Vec<(String, Option<String>)> {
|
||||||
names
|
names
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2779,7 +2816,7 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
"bundles" => bundles::list_installed_bundles(),
|
"bundles" => bundles::list_installed_bundles(),
|
||||||
_ => bail!(
|
_ => bail!(
|
||||||
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers, bundles"
|
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, prompts, tools, mcp-servers, bundles"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3663,6 +3700,40 @@ impl RequestContext {
|
|||||||
fuzzy_filter(values, |v| v.0.as_str(), filter)
|
fuzzy_filter(values, |v| v.0.as_str(), filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mcp_prompt_completion(&self, args: &[&str]) -> McpPromptCompletion {
|
||||||
|
let app = self.app.config.as_ref();
|
||||||
|
let enabled_ids = match &self.app.mcp_config {
|
||||||
|
Some(mcp_config) => {
|
||||||
|
let mut servers = self
|
||||||
|
.enabled_mcp_servers_for_current_scope(app, true)
|
||||||
|
.unwrap_or_default();
|
||||||
|
servers.extend(self.skill_registry.loaded_mcp_servers());
|
||||||
|
expand_enabled_mcp_server_ids(app, mcp_config, &servers)
|
||||||
|
}
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
self.tool_scope
|
||||||
|
.mcp_runtime
|
||||||
|
.prompt_completion(&enabled_ids, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_prompt_assets(&self) -> Result<()> {
|
||||||
|
let items = self.tool_scope.mcp_runtime.prompt_catalog().await;
|
||||||
|
if items.is_empty() {
|
||||||
|
println!("No prompts found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut table = asset_table(&["server", "name", "description", "args"]);
|
||||||
|
for row in prompt_asset_rows(&items) {
|
||||||
|
table.add_row(row.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Prompts:");
|
||||||
|
println!("{table}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn rebuild_tool_scope(
|
async fn rebuild_tool_scope(
|
||||||
&mut self,
|
&mut self,
|
||||||
app: &AppConfig,
|
app: &AppConfig,
|
||||||
@@ -3706,24 +3777,7 @@ impl RequestContext {
|
|||||||
&& let Some(mcp_config) = &self.app.mcp_config
|
&& let Some(mcp_config) = &self.app.mcp_config
|
||||||
{
|
{
|
||||||
let server_ids: Vec<String> = match &enabled_mcp_servers {
|
let server_ids: Vec<String> = match &enabled_mcp_servers {
|
||||||
Some(servers) if servers.iter().any(|s| s.trim() == "all") => {
|
Some(servers) => expand_enabled_mcp_server_ids(app, mcp_config, servers),
|
||||||
mcp_config.mcp_servers.keys().cloned().collect()
|
|
||||||
}
|
|
||||||
Some(servers) => {
|
|
||||||
let mut ids = Vec::new();
|
|
||||||
for item in servers.iter().map(|s| s.trim()) {
|
|
||||||
if mcp_config.mcp_servers.contains_key(item) {
|
|
||||||
ids.push(item.to_string());
|
|
||||||
} else if let Some(mapped) = app.mapping_mcp_servers.get(item) {
|
|
||||||
for mapped_id in mapped.split(',').map(|s| s.trim()) {
|
|
||||||
if mcp_config.mcp_servers.contains_key(mapped_id) {
|
|
||||||
ids.push(mapped_id.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
None => vec![],
|
None => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4632,6 +4686,7 @@ mod tests {
|
|||||||
use crate::utils;
|
use crate::utils;
|
||||||
use crate::utils::get_env_name;
|
use crate::utils::get_env_name;
|
||||||
use crate::vault::Vault;
|
use crate::vault::Vault;
|
||||||
|
use rmcp::model::PromptArgument;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -4801,6 +4856,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_asset_rows_assembles_columns() {
|
||||||
|
let items = vec![
|
||||||
|
CatalogItem {
|
||||||
|
name: "summarize".to_string(),
|
||||||
|
server: "docs".to_string(),
|
||||||
|
description: "Summarize a document".to_string(),
|
||||||
|
arguments: Some(vec![
|
||||||
|
PromptArgument::new("path").with_required(true),
|
||||||
|
PromptArgument::new("style"),
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
CatalogItem {
|
||||||
|
name: "greet".to_string(),
|
||||||
|
server: "misc".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let rows = prompt_asset_rows(&items);
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
rows[0],
|
||||||
|
[
|
||||||
|
"docs",
|
||||||
|
"summarize",
|
||||||
|
"Summarize a document",
|
||||||
|
"path (required), style"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(rows[1], ["misc", "greet", "", ""]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_role_returns_standalone_role() {
|
fn extract_role_returns_standalone_role() {
|
||||||
let mut ctx = create_test_ctx();
|
let mut ctx = create_test_ctx();
|
||||||
|
|||||||
+478
-9
@@ -4,8 +4,9 @@ use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry, Mcp
|
|||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use bm25::{Document, Language, SearchEngineBuilder};
|
use bm25::{Document, Language, SearchEngineBuilder};
|
||||||
use rmcp::model::{
|
use rmcp::model::{
|
||||||
CallToolRequestParams, CallToolResult, Prompt, ReadResourceRequestParams, ReadResourceResult,
|
CallToolRequestParams, CallToolResult, ContentBlock, GetPromptRequestParams, GetPromptResult,
|
||||||
Resource, ResourceTemplate, Tool,
|
Prompt, PromptArgument, PromptMessage, ReadResourceRequestParams, ReadResourceResult, Resource,
|
||||||
|
ResourceTemplate, Role, Tool,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -28,6 +29,18 @@ impl Default for ToolScope {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum McpPromptCompletion {
|
||||||
|
Ready(Vec<(String, Option<String>)>),
|
||||||
|
PromptNames {
|
||||||
|
server: Arc<ConnectedServer>,
|
||||||
|
},
|
||||||
|
ArgumentKeys {
|
||||||
|
server: Arc<ConnectedServer>,
|
||||||
|
prompt: String,
|
||||||
|
typed_keys: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct McpRuntime {
|
pub struct McpRuntime {
|
||||||
pub servers: HashMap<String, Arc<ConnectedServer>>,
|
pub servers: HashMap<String, Arc<ConnectedServer>>,
|
||||||
@@ -291,6 +304,130 @@ impl McpRuntime {
|
|||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_prompts(&self, server: &str) -> Result<Vec<Prompt>> {
|
||||||
|
let server_handle = self
|
||||||
|
.get(server)
|
||||||
|
.cloned()
|
||||||
|
.with_context(|| format!("Prompt MCP server does not exist: {server}"))?;
|
||||||
|
|
||||||
|
server_handle.list_all_prompts().await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn prompt(
|
||||||
|
&self,
|
||||||
|
server: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: HashMap<String, String>,
|
||||||
|
) -> Result<GetPromptResult> {
|
||||||
|
let server_handle = self
|
||||||
|
.get(server)
|
||||||
|
.cloned()
|
||||||
|
.with_context(|| format!("Prompt MCP server does not exist: {server}"))?;
|
||||||
|
|
||||||
|
let mut request = GetPromptRequestParams::new(name.to_owned());
|
||||||
|
if !arguments.is_empty() {
|
||||||
|
request.arguments = Some(
|
||||||
|
arguments
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key, Value::String(value)))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
server_handle.get_prompt(request).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn prompt_catalog(&self) -> Vec<CatalogItem> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for features in self.server_features() {
|
||||||
|
if !features.prompts {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(server_handle) = self.get(&features.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match server_handle.list_all_prompts().await {
|
||||||
|
Ok(prompts) => items.extend(
|
||||||
|
prompts
|
||||||
|
.into_iter()
|
||||||
|
.map(|prompt| prompt_catalog_item(&features.name, prompt)),
|
||||||
|
),
|
||||||
|
Err(e) => warn!(
|
||||||
|
"Failed to list prompts on MCP server {}: {e}",
|
||||||
|
features.name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_completion(
|
||||||
|
&self,
|
||||||
|
enabled_servers: &[String],
|
||||||
|
args: &[&str],
|
||||||
|
) -> McpPromptCompletion {
|
||||||
|
match args {
|
||||||
|
[] | [_] => McpPromptCompletion::Ready(
|
||||||
|
self.server_features()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|features| features.prompts && enabled_servers.contains(&features.name))
|
||||||
|
.map(|features| (features.name, None))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
[server, _] => match self.get(server) {
|
||||||
|
Some(handle) => McpPromptCompletion::PromptNames {
|
||||||
|
server: Arc::clone(handle),
|
||||||
|
},
|
||||||
|
None => McpPromptCompletion::Ready(vec![]),
|
||||||
|
},
|
||||||
|
[server, prompt, rest @ ..] => match self.get(server) {
|
||||||
|
Some(handle) => McpPromptCompletion::ArgumentKeys {
|
||||||
|
server: Arc::clone(handle),
|
||||||
|
prompt: (*prompt).to_string(),
|
||||||
|
typed_keys: rest
|
||||||
|
.iter()
|
||||||
|
.filter_map(|arg| arg.split_once('=').map(|(key, _)| key.to_string()))
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
None => McpPromptCompletion::Ready(vec![]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flatten_prompt_messages(messages: &[PromptMessage]) -> String {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.map(|message| {
|
||||||
|
let label = match message.role {
|
||||||
|
Role::User => "[user]",
|
||||||
|
Role::Assistant => "[assistant]",
|
||||||
|
};
|
||||||
|
let content = match &message.content {
|
||||||
|
ContentBlock::Text(text) => text.text.clone(),
|
||||||
|
ContentBlock::Image(_) => "[image content omitted]".to_string(),
|
||||||
|
ContentBlock::Audio(_) => "[audio content omitted]".to_string(),
|
||||||
|
_ => "[resource content omitted]".to_string(),
|
||||||
|
};
|
||||||
|
format!("{label}\n{content}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_prompt_arguments(arguments: &[PromptArgument]) -> String {
|
||||||
|
arguments
|
||||||
|
.iter()
|
||||||
|
.map(|arg| {
|
||||||
|
if arg.required == Some(true) {
|
||||||
|
format!("{} (required)", arg.name)
|
||||||
|
} else {
|
||||||
|
arg.name.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn catalog_key(item: &CatalogItem) -> String {
|
fn catalog_key(item: &CatalogItem) -> String {
|
||||||
@@ -326,6 +463,7 @@ fn resource_catalog_item(server: &str, resource: Resource) -> CatalogItem {
|
|||||||
uri: Some(resource.uri),
|
uri: Some(resource.uri),
|
||||||
mime_type: resource.mime_type,
|
mime_type: resource.mime_type,
|
||||||
size: resource.size,
|
size: resource.size,
|
||||||
|
arguments: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +476,7 @@ fn resource_template_catalog_item(server: &str, template: ResourceTemplate) -> C
|
|||||||
uri: Some(template.uri_template),
|
uri: Some(template.uri_template),
|
||||||
mime_type: template.mime_type,
|
mime_type: template.mime_type,
|
||||||
size: None,
|
size: None,
|
||||||
|
arguments: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +486,7 @@ fn prompt_catalog_item(server: &str, prompt: Prompt) -> CatalogItem {
|
|||||||
name: prompt.name,
|
name: prompt.name,
|
||||||
server: server.to_string(),
|
server: server.to_string(),
|
||||||
description: prompt.description.unwrap_or_default(),
|
description: prompt.description.unwrap_or_default(),
|
||||||
|
arguments: prompt.arguments,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,14 +510,15 @@ pub(crate) mod test_fixtures {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use rmcp::model::{
|
use rmcp::model::{
|
||||||
ErrorData, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
ErrorData, GetPromptResponse, ListPromptsResult, ListResourceTemplatesResult,
|
||||||
ListToolsResult, PaginatedRequestParams, PromptArgument, PromptsCapability,
|
ListResourcesResult, ListToolsResult, PaginatedRequestParams, PromptsCapability,
|
||||||
ReadResourceResponse, ResourceContents, ResourcesCapability, ServerCapabilities,
|
ReadResourceResponse, ResourceContents, ResourcesCapability, ServerCapabilities,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
};
|
};
|
||||||
use rmcp::service::{RequestContext, RunningService};
|
use rmcp::service::{RequestContext, RunningService};
|
||||||
use rmcp::{RoleServer, ServerHandler, ServiceExt};
|
use rmcp::{RoleServer, ServerHandler, ServiceExt};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub(crate) const FIXTURE_LOG_URI: &str = "file:///app.log";
|
pub(crate) const FIXTURE_LOG_URI: &str = "file:///app.log";
|
||||||
pub(crate) const FIXTURE_LOG_TEXT: &str = "début of the log\n\
|
pub(crate) const FIXTURE_LOG_TEXT: &str = "début of the log\n\
|
||||||
@@ -397,8 +538,12 @@ pub(crate) mod test_fixtures {
|
|||||||
pub(crate) resources_capability: bool,
|
pub(crate) resources_capability: bool,
|
||||||
pub(crate) prompts_capability: bool,
|
pub(crate) prompts_capability: bool,
|
||||||
pub(crate) fail_resource_listings: bool,
|
pub(crate) fail_resource_listings: bool,
|
||||||
|
pub(crate) fail_prompt_listings: bool,
|
||||||
|
pub(crate) fail_get_prompt: bool,
|
||||||
|
pub(crate) prompt_delay: Option<Duration>,
|
||||||
pub(crate) list_resources_calls: Arc<AtomicUsize>,
|
pub(crate) list_resources_calls: Arc<AtomicUsize>,
|
||||||
pub(crate) list_prompts_calls: Arc<AtomicUsize>,
|
pub(crate) list_prompts_calls: Arc<AtomicUsize>,
|
||||||
|
pub(crate) get_prompt_calls: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FixtureServer {
|
impl Default for FixtureServer {
|
||||||
@@ -408,8 +553,12 @@ pub(crate) mod test_fixtures {
|
|||||||
resources_capability: false,
|
resources_capability: false,
|
||||||
prompts_capability: false,
|
prompts_capability: false,
|
||||||
fail_resource_listings: false,
|
fail_resource_listings: false,
|
||||||
|
fail_prompt_listings: false,
|
||||||
|
fail_get_prompt: false,
|
||||||
|
prompt_delay: None,
|
||||||
list_resources_calls: Arc::default(),
|
list_resources_calls: Arc::default(),
|
||||||
list_prompts_calls: Arc::default(),
|
list_prompts_calls: Arc::default(),
|
||||||
|
get_prompt_calls: Arc::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,6 +667,12 @@ pub(crate) mod test_fixtures {
|
|||||||
_context: RequestContext<RoleServer>,
|
_context: RequestContext<RoleServer>,
|
||||||
) -> Result<ListPromptsResult, ErrorData> {
|
) -> Result<ListPromptsResult, ErrorData> {
|
||||||
self.list_prompts_calls.fetch_add(1, Ordering::SeqCst);
|
self.list_prompts_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if let Some(delay) = self.prompt_delay {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
if self.fail_prompt_listings {
|
||||||
|
return Err(ErrorData::internal_error("prompt listing exploded", None));
|
||||||
|
}
|
||||||
Ok(ListPromptsResult::with_all_items(vec![Prompt::new(
|
Ok(ListPromptsResult::with_all_items(vec![Prompt::new(
|
||||||
"summarize",
|
"summarize",
|
||||||
Some("Summarize a document"),
|
Some("Summarize a document"),
|
||||||
@@ -529,22 +684,71 @@ pub(crate) mod test_fixtures {
|
|||||||
]),
|
]),
|
||||||
)]))
|
)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_prompt(
|
||||||
|
&self,
|
||||||
|
request: GetPromptRequestParams,
|
||||||
|
_context: RequestContext<RoleServer>,
|
||||||
|
) -> Result<GetPromptResponse, ErrorData> {
|
||||||
|
self.get_prompt_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if let Some(delay) = self.prompt_delay {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
if self.fail_get_prompt {
|
||||||
|
return Err(ErrorData::internal_error("get_prompt exploded", None));
|
||||||
|
}
|
||||||
|
let messages = match request.name.as_str() {
|
||||||
|
"summarize" => {
|
||||||
|
let path = request
|
||||||
|
.arguments
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|args| args.get("path"))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
vec![
|
||||||
|
PromptMessage::new_text(Role::User, format!("Summarize {path}")),
|
||||||
|
PromptMessage::new_text(Role::Assistant, "In which style?"),
|
||||||
|
PromptMessage::new_text(Role::User, "Concise."),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"hostile" => vec![
|
||||||
|
PromptMessage::new_text(Role::User, "!rm -rf /"),
|
||||||
|
PromptMessage::new_text(Role::User, ".session hijack"),
|
||||||
|
],
|
||||||
|
other => {
|
||||||
|
return Err(ErrorData::invalid_params(
|
||||||
|
format!("Unknown prompt: {other}"),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(GetPromptResponse::Complete(GetPromptResult::new(messages)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn add_fixture_server(
|
||||||
|
runtime: &mut McpRuntime,
|
||||||
|
name: &str,
|
||||||
|
fixture: FixtureServer,
|
||||||
|
) -> RunningService<RoleServer, FixtureServer> {
|
||||||
|
let (client_io, server_io) = tokio::io::duplex(4096);
|
||||||
|
let (server, client) = tokio::join!(fixture.serve(server_io), ().serve(client_io));
|
||||||
|
runtime.insert(name.to_string(), Arc::new(client.unwrap()));
|
||||||
|
server.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn fixture_runtime(
|
pub(crate) async fn fixture_runtime(
|
||||||
fixture: FixtureServer,
|
fixture: FixtureServer,
|
||||||
) -> (McpRuntime, RunningService<RoleServer, FixtureServer>) {
|
) -> (McpRuntime, RunningService<RoleServer, FixtureServer>) {
|
||||||
let (client_io, server_io) = tokio::io::duplex(4096);
|
|
||||||
let (server, client) = tokio::join!(fixture.serve(server_io), ().serve(client_io));
|
|
||||||
let mut runtime = McpRuntime::new();
|
let mut runtime = McpRuntime::new();
|
||||||
runtime.insert("fixture".to_string(), Arc::new(client.unwrap()));
|
let server = add_fixture_server(&mut runtime, "fixture", fixture).await;
|
||||||
(runtime, server.unwrap())
|
(runtime, server)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::test_fixtures::{FixtureServer, fixture_runtime};
|
use super::test_fixtures::{FixtureServer, add_fixture_server, fixture_runtime};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::function::ToolCall;
|
use crate::function::ToolCall;
|
||||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
@@ -908,4 +1112,269 @@ mod tests {
|
|||||||
"file:///missing not found in fixture MCP server resource catalog"
|
"file:///missing not found in fixture MCP server resource catalog"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_returns_result_and_counts_calls() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let get_prompt_calls = Arc::clone(&fixture.get_prompt_calls);
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let result = runtime
|
||||||
|
.prompt(
|
||||||
|
"fixture",
|
||||||
|
"summarize",
|
||||||
|
HashMap::from([("path".to_string(), "notes.txt".to_string())]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.messages.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
result.messages[0]
|
||||||
|
.content
|
||||||
|
.as_text()
|
||||||
|
.map(|t| t.text.as_str()),
|
||||||
|
Some("Summarize notes.txt")
|
||||||
|
);
|
||||||
|
assert_eq!(get_prompt_calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_missing_server_errors() {
|
||||||
|
let runtime = McpRuntime::new();
|
||||||
|
|
||||||
|
let err = runtime
|
||||||
|
.prompt("ghost", "summarize", HashMap::new())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert_eq!(err, "Prompt MCP server does not exist: ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_surfaces_server_failure() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
fail_get_prompt: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let err = runtime
|
||||||
|
.prompt("fixture", "summarize", HashMap::new())
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.contains("get_prompt exploded"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flatten_prompt_messages_labels_every_message() {
|
||||||
|
let messages = vec![
|
||||||
|
PromptMessage::new_text(Role::User, "Summarize notes.txt"),
|
||||||
|
PromptMessage::new_text(Role::Assistant, "In which style?"),
|
||||||
|
PromptMessage::new_text(Role::User, "Concise."),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
flatten_prompt_messages(&messages),
|
||||||
|
"[user]\nSummarize notes.txt\n\n[assistant]\nIn which style?\n\n[user]\nConcise."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flatten_prompt_messages_labels_single_message() {
|
||||||
|
let messages = vec![PromptMessage::new_text(Role::User, "hello")];
|
||||||
|
|
||||||
|
assert_eq!(flatten_prompt_messages(&messages), "[user]\nhello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flatten_prompt_messages_replaces_non_text_content() {
|
||||||
|
let messages = vec![PromptMessage::new(
|
||||||
|
Role::User,
|
||||||
|
ContentBlock::image("aGk=", "image/png"),
|
||||||
|
)];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
flatten_prompt_messages(&messages),
|
||||||
|
"[user]\n[image content omitted]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flattened_hostile_prompt_cannot_start_a_command_line() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let result = runtime
|
||||||
|
.prompt("fixture", "hostile", HashMap::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let flattened = flatten_prompt_messages(&result.messages);
|
||||||
|
|
||||||
|
assert!(flattened.starts_with("[user]"));
|
||||||
|
assert!(!flattened.starts_with('!'));
|
||||||
|
assert!(!flattened.starts_with('.'));
|
||||||
|
assert!(flattened.contains("!rm -rf /"));
|
||||||
|
assert!(flattened.contains(".session hijack"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_prompt_arguments_marks_required() {
|
||||||
|
let arguments = vec![
|
||||||
|
PromptArgument::new("path").with_required(true),
|
||||||
|
PromptArgument::new("style"),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_prompt_arguments(&arguments),
|
||||||
|
"path (required), style"
|
||||||
|
);
|
||||||
|
assert_eq!(format_prompt_arguments(&[]), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_catalog_carries_arguments() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let items = runtime.prompt_catalog().await;
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
let item = &items[0];
|
||||||
|
assert_eq!(item.kind, CatalogItemKind::Prompt);
|
||||||
|
assert_eq!(item.server, "fixture");
|
||||||
|
assert_eq!(item.name, "summarize");
|
||||||
|
assert_eq!(item.description, "Summarize a document");
|
||||||
|
let arguments = item.arguments.as_deref().unwrap();
|
||||||
|
assert_eq!(format_prompt_arguments(arguments), "path (required), style");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_catalog_degrades_when_one_server_fails() {
|
||||||
|
install_warn_collector();
|
||||||
|
let healthy = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let failing = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
fail_prompt_listings: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (mut runtime, _server) = fixture_runtime(healthy).await;
|
||||||
|
let _failing_server = add_fixture_server(&mut runtime, "broken", failing).await;
|
||||||
|
|
||||||
|
let items = runtime.prompt_catalog().await;
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].server, "fixture");
|
||||||
|
let messages = warn_messages().lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.any(|msg| msg.contains("Failed to list prompts on MCP server broken")),
|
||||||
|
"missing prompt-listing warning in: {messages:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_catalog_skips_servers_without_prompts_capability() {
|
||||||
|
let fixture = FixtureServer::default();
|
||||||
|
let prompts_calls = Arc::clone(&fixture.list_prompts_calls);
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let items = runtime.prompt_catalog().await;
|
||||||
|
|
||||||
|
assert!(items.is_empty());
|
||||||
|
assert_eq!(prompts_calls.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_completion_stage_one_uses_local_state_only() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let list_prompts_calls = Arc::clone(&fixture.list_prompts_calls);
|
||||||
|
let get_prompt_calls = Arc::clone(&fixture.get_prompt_calls);
|
||||||
|
let (mut runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
let _tools_only =
|
||||||
|
add_fixture_server(&mut runtime, "tools-only", FixtureServer::default()).await;
|
||||||
|
|
||||||
|
let stage =
|
||||||
|
runtime.prompt_completion(&["fixture".to_string(), "tools-only".to_string()], &[""]);
|
||||||
|
|
||||||
|
let McpPromptCompletion::Ready(values) = stage else {
|
||||||
|
panic!("stage one must not require an RPC");
|
||||||
|
};
|
||||||
|
assert_eq!(values, vec![("fixture".to_string(), None)]);
|
||||||
|
assert_eq!(list_prompts_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(get_prompt_calls.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_completion_stage_one_excludes_disabled_servers() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let McpPromptCompletion::Ready(values) = runtime.prompt_completion(&[], &[""]) else {
|
||||||
|
panic!("stage one must not require an RPC");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(values.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_completion_unknown_server_is_empty() {
|
||||||
|
let (runtime, _server) = fixture_runtime(FixtureServer::default()).await;
|
||||||
|
|
||||||
|
for args in [
|
||||||
|
["ghost", ""].as_slice(),
|
||||||
|
["ghost", "summarize", ""].as_slice(),
|
||||||
|
] {
|
||||||
|
let McpPromptCompletion::Ready(values) = runtime.prompt_completion(&[], args) else {
|
||||||
|
panic!("unknown server must degrade to empty suggestions");
|
||||||
|
};
|
||||||
|
assert!(values.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_completion_later_stages_carry_typed_keys() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
|
||||||
|
let stage = runtime.prompt_completion(&[], &["fixture", "summarize", "path=x", "sty"]);
|
||||||
|
|
||||||
|
let McpPromptCompletion::ArgumentKeys {
|
||||||
|
prompt, typed_keys, ..
|
||||||
|
} = stage
|
||||||
|
else {
|
||||||
|
panic!("expected the argument-key stage");
|
||||||
|
};
|
||||||
|
assert_eq!(prompt, "summarize");
|
||||||
|
assert_eq!(typed_keys, vec!["path".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,7 @@ use futures_util::{StreamExt, TryStreamExt, stream};
|
|||||||
use http::{HeaderName, HeaderValue};
|
use http::{HeaderName, HeaderValue};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use indoc::formatdoc;
|
use indoc::formatdoc;
|
||||||
use rmcp::model::ServerCapabilities;
|
use rmcp::model::{PromptArgument, ServerCapabilities};
|
||||||
use rmcp::service::RunningService;
|
use rmcp::service::RunningService;
|
||||||
use rmcp::transport::StreamableHttpClientTransport;
|
use rmcp::transport::StreamableHttpClientTransport;
|
||||||
use rmcp::transport::TokioChildProcess;
|
use rmcp::transport::TokioChildProcess;
|
||||||
@@ -123,6 +123,8 @@ pub struct CatalogItem {
|
|||||||
pub mime_type: Option<String>,
|
pub mime_type: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub size: Option<u64>,
|
pub size: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub arguments: Option<Vec<PromptArgument>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
+240
-1
@@ -1,11 +1,17 @@
|
|||||||
use super::{REPL_COMMANDS, ReplCommand};
|
use super::{REPL_COMMANDS, ReplCommand};
|
||||||
|
|
||||||
use crate::{config::RequestContext, utils::fuzzy_filter};
|
use crate::config::{McpPromptCompletion, RequestContext};
|
||||||
|
use crate::mcp::ConnectedServer;
|
||||||
|
use crate::utils::fuzzy_filter;
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use reedline::{Completer, Span, Suggestion};
|
use reedline::{Completer, Span, Suggestion};
|
||||||
|
use rmcp::model::Prompt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const PROMPT_COMPLETION_RPC_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
impl Completer for ReplCompleter {
|
impl Completer for ReplCompleter {
|
||||||
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
|
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
|
||||||
@@ -29,6 +35,22 @@ impl Completer for ReplCompleter {
|
|||||||
return suggestions;
|
return suggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd == ".prompt" && parts_len > 1 {
|
||||||
|
let span = Span::new(parts[parts_len - 1].1, pos);
|
||||||
|
let args: Vec<&str> = parts.iter().skip(1).map(|(v, _)| *v).collect();
|
||||||
|
let filter = args.last().copied().unwrap_or_default().to_string();
|
||||||
|
let stage = {
|
||||||
|
let ctx = self.ctx.read();
|
||||||
|
ctx.mcp_prompt_completion(&args)
|
||||||
|
};
|
||||||
|
return complete_prompt_stage(stage, &filter, PROMPT_COMPLETION_RPC_TIMEOUT)
|
||||||
|
.iter()
|
||||||
|
.map(|(value, description)| {
|
||||||
|
create_suggestion(value, description.as_deref().unwrap_or_default(), span)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
let ctx = self.ctx.read();
|
let ctx = self.ctx.read();
|
||||||
let state = ctx.state();
|
let state = ctx.state();
|
||||||
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
|
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
|
||||||
@@ -141,6 +163,57 @@ fn create_suggestion(value: &str, description: &str, span: Span) -> Suggestion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn complete_prompt_stage(
|
||||||
|
stage: McpPromptCompletion,
|
||||||
|
filter: &str,
|
||||||
|
rpc_timeout: Duration,
|
||||||
|
) -> Vec<(String, Option<String>)> {
|
||||||
|
let values = match stage {
|
||||||
|
McpPromptCompletion::Ready(values) => values,
|
||||||
|
McpPromptCompletion::PromptNames { server } => list_prompts_blocking(server, rpc_timeout)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|prompt| (prompt.name, prompt.description))
|
||||||
|
.collect(),
|
||||||
|
McpPromptCompletion::ArgumentKeys {
|
||||||
|
server,
|
||||||
|
prompt,
|
||||||
|
typed_keys,
|
||||||
|
} => list_prompts_blocking(server, rpc_timeout)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.name == prompt)
|
||||||
|
.and_then(|candidate| candidate.arguments)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|arg| !typed_keys.contains(&arg.name))
|
||||||
|
.map(|arg| {
|
||||||
|
let description = match (arg.required == Some(true), arg.description) {
|
||||||
|
(true, Some(description)) => Some(format!("{description} (required)")),
|
||||||
|
(true, None) => Some("(required)".to_string()),
|
||||||
|
(false, description) => description,
|
||||||
|
};
|
||||||
|
(format!("{}=", arg.name), description)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
fuzzy_filter(values, |(value, _)| value.as_str(), filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_prompts_blocking(
|
||||||
|
server: Arc<ConnectedServer>,
|
||||||
|
rpc_timeout: Duration,
|
||||||
|
) -> Option<Vec<Prompt>> {
|
||||||
|
let fut = async move { tokio::time::timeout(rpc_timeout, server.list_all_prompts()).await };
|
||||||
|
// block_in_place is only sound because the REPL's read_line runs inside the
|
||||||
|
// main-thread block_on of the multi-thread runtime.
|
||||||
|
let result = match tokio::runtime::Handle::try_current().ok() {
|
||||||
|
Some(handle) => tokio::task::block_in_place(|| handle.block_on(fut)),
|
||||||
|
None => tokio::runtime::Runtime::new().ok()?.block_on(fut),
|
||||||
|
};
|
||||||
|
result.ok()?.ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn split_line(line: &str) -> Vec<(&str, usize)> {
|
fn split_line(line: &str) -> Vec<(&str, usize)> {
|
||||||
let mut parts = vec![];
|
let mut parts = vec![];
|
||||||
let mut part_start = None;
|
let mut part_start = None;
|
||||||
@@ -178,3 +251,169 @@ fn test_split_line() {
|
|||||||
vec![(".set", 0), ("highlight", 5), ("t", 15)],
|
vec![(".set", 0), ("highlight", 5), ("t", 15)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod prompt_completion_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
fn prompts_fixture() -> FixtureServer {
|
||||||
|
FixtureServer {
|
||||||
|
prompts_capability: true,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn stage_two_lists_prompt_names_with_descriptions() {
|
||||||
|
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::PromptNames { server },
|
||||||
|
"",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![(
|
||||||
|
"summarize".to_string(),
|
||||||
|
Some("Summarize a document".to_string())
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn stage_three_suggests_argument_keys_with_required_marker() {
|
||||||
|
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::ArgumentKeys {
|
||||||
|
server,
|
||||||
|
prompt: "summarize".to_string(),
|
||||||
|
typed_keys: vec![],
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"path=".to_string(),
|
||||||
|
Some("Document path (required)".to_string())
|
||||||
|
),
|
||||||
|
("style=".to_string(), None),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn stage_three_excludes_typed_keys_and_fuzzy_filters() {
|
||||||
|
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::ArgumentKeys {
|
||||||
|
server: Arc::clone(&server),
|
||||||
|
prompt: "summarize".to_string(),
|
||||||
|
typed_keys: vec!["path".to_string()],
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
assert_eq!(values, vec![("style=".to_string(), None)]);
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::ArgumentKeys {
|
||||||
|
server,
|
||||||
|
prompt: "summarize".to_string(),
|
||||||
|
typed_keys: vec![],
|
||||||
|
},
|
||||||
|
"sty",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
assert_eq!(values, vec![("style=".to_string(), None)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn stage_three_unknown_prompt_is_empty() {
|
||||||
|
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::ArgumentKeys {
|
||||||
|
server,
|
||||||
|
prompt: "ghost".to_string(),
|
||||||
|
typed_keys: vec![],
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(values.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn slow_listing_times_out_to_empty() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
prompt_delay: Some(Duration::from_millis(200)),
|
||||||
|
..prompts_fixture()
|
||||||
|
};
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::PromptNames { server },
|
||||||
|
"",
|
||||||
|
Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(values.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn failed_listing_is_swallowed_without_retry() {
|
||||||
|
let fixture = FixtureServer {
|
||||||
|
fail_prompt_listings: true,
|
||||||
|
..prompts_fixture()
|
||||||
|
};
|
||||||
|
let list_prompts_calls = Arc::clone(&fixture.list_prompts_calls);
|
||||||
|
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::PromptNames { server },
|
||||||
|
"",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(values.is_empty());
|
||||||
|
assert_eq!(list_prompts_calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_without_ambient_runtime_uses_fallback_runtime() {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let (runtime, _server) = rt.block_on(fixture_runtime(prompts_fixture()));
|
||||||
|
let server = runtime.get("fixture").cloned().unwrap();
|
||||||
|
|
||||||
|
let values = complete_prompt_stage(
|
||||||
|
McpPromptCompletion::PromptNames { server },
|
||||||
|
"",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![(
|
||||||
|
"summarize".to_string(),
|
||||||
|
Some("Summarize a document".to_string())
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+146
-11
@@ -13,7 +13,7 @@ use crate::client::{
|
|||||||
};
|
};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
||||||
StateFlags, macro_execute,
|
StateFlags, flatten_prompt_messages, macro_execute,
|
||||||
};
|
};
|
||||||
use crate::config::{AssetCategory, paths};
|
use crate::config::{AssetCategory, paths};
|
||||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||||
@@ -29,6 +29,7 @@ use anyhow::{Context, Result, bail};
|
|||||||
use crossterm::cursor::SetCursorStyle;
|
use crossterm::cursor::SetCursorStyle;
|
||||||
use fancy_regex::Regex;
|
use fancy_regex::Regex;
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
|
use inquire::Text;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use reedline::CursorConfig;
|
use reedline::CursorConfig;
|
||||||
@@ -38,6 +39,8 @@ use reedline::{
|
|||||||
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
|
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
|
||||||
};
|
};
|
||||||
use reedline::{MenuBuilder, Signal};
|
use reedline::{MenuBuilder, Signal};
|
||||||
|
use rmcp::model::PromptArgument;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::{env, process, sync::Arc};
|
use std::{env, process, sync::Arc};
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
@@ -53,7 +56,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
|||||||
4. Continue with the next pending item now. Call tools immediately."
|
4. Continue with the next pending item now. Call tools immediately."
|
||||||
};
|
};
|
||||||
|
|
||||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 62]> = LazyLock::new(|| {
|
||||||
[
|
[
|
||||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||||
@@ -105,6 +108,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
|||||||
ReplCommand::new(".model", "Switch LLM model", AssertState::pass()),
|
ReplCommand::new(".model", "Switch LLM model", AssertState::pass()),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".prompt",
|
".prompt",
|
||||||
|
"Invoke an MCP prompt and submit the result as chat input",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".temp-role",
|
||||||
"Set a temporary role using a prompt",
|
"Set a temporary role using a prompt",
|
||||||
AssertState::False(StateFlags::SESSION | StateFlags::AGENT),
|
AssertState::False(StateFlags::SESSION | StateFlags::AGENT),
|
||||||
),
|
),
|
||||||
@@ -307,7 +315,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
|||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".list",
|
".list",
|
||||||
"List roles, sessions, agents, RAGs, macros, skills, tools, MCP servers, or bundles",
|
"List roles, sessions, agents, RAGs, macros, skills, prompts, tools, MCP servers, or bundles",
|
||||||
AssertState::pass(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
@@ -774,12 +782,46 @@ pub async fn run_repl_command(
|
|||||||
.tool disable <name> # Disable a single tool in the current context"#
|
.tool disable <name> # Disable a single tool in the current context"#
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
".prompt" => match args {
|
".prompt" => {
|
||||||
|
let (words, _) = split_args_text(args.unwrap_or_default(), cfg!(windows));
|
||||||
|
match words.as_slice() {
|
||||||
|
[server, name, rest @ ..] => {
|
||||||
|
let provided = parse_prompt_call_args(rest)?;
|
||||||
|
let prompts = ctx.tool_scope.mcp_runtime.list_prompts(server).await?;
|
||||||
|
let declared = prompts
|
||||||
|
.into_iter()
|
||||||
|
.find(|prompt| prompt.name == *name)
|
||||||
|
.with_context(|| {
|
||||||
|
format!("Prompt '{name}' not found on MCP server '{server}'")
|
||||||
|
})?
|
||||||
|
.arguments
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (mut arguments, missing) = resolve_prompt_args(&declared, provided);
|
||||||
|
for key in missing {
|
||||||
|
let value =
|
||||||
|
Text::new(&format!("{key}:")).prompt().with_context(|| {
|
||||||
|
format!("Failed to read prompt argument '{key}'")
|
||||||
|
})?;
|
||||||
|
arguments.insert(key, value);
|
||||||
|
}
|
||||||
|
let result = ctx
|
||||||
|
.tool_scope
|
||||||
|
.mcp_runtime
|
||||||
|
.prompt(server, name, arguments)
|
||||||
|
.await?;
|
||||||
|
let flattened = flatten_prompt_messages(&result.messages);
|
||||||
|
let input = Input::from_str(ctx, &flattened, None)?;
|
||||||
|
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||||
|
}
|
||||||
|
_ => println!("Usage: .prompt <server> <name> [key=value ...]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
".temp-role" => match args {
|
||||||
Some(text) => {
|
Some(text) => {
|
||||||
let app = Arc::clone(&ctx.app.config);
|
let app = Arc::clone(&ctx.app.config);
|
||||||
ctx.use_prompt(app.as_ref(), text)?;
|
ctx.use_prompt(app.as_ref(), text)?;
|
||||||
}
|
}
|
||||||
None => println!("Usage: .prompt <text>..."),
|
None => println!("Usage: .temp-role <text>..."),
|
||||||
},
|
},
|
||||||
".role" => match args {
|
".role" => match args {
|
||||||
Some(args) => match args.split_once(['\n', ' ']) {
|
Some(args) => match args.split_once(['\n', ' ']) {
|
||||||
@@ -1207,13 +1249,16 @@ pub async fn run_repl_command(
|
|||||||
println!("Usage: .uninstall <bundle-name> [--yes] (see `.uninstall --help`)")
|
println!("Usage: .uninstall <bundle-name> [--yes] (see `.uninstall --help`)")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
".list" => match args {
|
".list" => match args.map(str::trim) {
|
||||||
|
Some("prompts") => {
|
||||||
|
ctx.list_prompt_assets().await?;
|
||||||
|
}
|
||||||
Some(args) => {
|
Some(args) => {
|
||||||
ctx.list_assets(args.trim())?;
|
ctx.list_assets(args)?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!(
|
println!(
|
||||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers|bundles>"
|
"Usage: .list <roles|sessions|agents|rags|macros|skills|prompts|tools|mcp-servers|bundles>"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1737,6 +1782,40 @@ fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_prompt_call_args(words: &[String]) -> Result<HashMap<String, String>> {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
for word in words {
|
||||||
|
let Some((key, value)) = word.split_once('=') else {
|
||||||
|
bail!("Invalid prompt argument '{word}': arguments must be key=value pairs");
|
||||||
|
};
|
||||||
|
args.insert(key.to_string(), unquote_prompt_value(value).to_string());
|
||||||
|
}
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unquote_prompt_value(value: &str) -> &str {
|
||||||
|
let quoted = value.len() >= 2
|
||||||
|
&& ((value.starts_with('"') && value.ends_with('"'))
|
||||||
|
|| (value.starts_with('\'') && value.ends_with('\'')));
|
||||||
|
if quoted {
|
||||||
|
&value[1..value.len() - 1]
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_prompt_args(
|
||||||
|
declared: &[PromptArgument],
|
||||||
|
provided: HashMap<String, String>,
|
||||||
|
) -> (HashMap<String, String>, Vec<String>) {
|
||||||
|
let missing = declared
|
||||||
|
.iter()
|
||||||
|
.filter(|arg| arg.required == Some(true) && !provided.contains_key(&arg.name))
|
||||||
|
.map(|arg| arg.name.clone())
|
||||||
|
.collect();
|
||||||
|
(provided, missing)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
|
pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
|
||||||
let mut words = Vec::new();
|
let mut words = Vec::new();
|
||||||
let mut word = String::new();
|
let mut word = String::new();
|
||||||
@@ -1888,8 +1967,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_61_entries() {
|
fn repl_commands_has_62_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 61);
|
assert_eq!(REPL_COMMANDS.len(), 62);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_prompt_call_args_splits_on_first_equals_and_unquotes() {
|
||||||
|
let words = vec![
|
||||||
|
"path=notes.txt".to_string(),
|
||||||
|
r#"style="a b""#.to_string(),
|
||||||
|
"expr=a=b".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let args = parse_prompt_call_args(&words).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(args["path"], "notes.txt");
|
||||||
|
assert_eq!(args["style"], "a b");
|
||||||
|
assert_eq!(args["expr"], "a=b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_prompt_call_args_rejects_words_without_equals() {
|
||||||
|
let err = parse_prompt_call_args(&["positional".to_string()])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
"Invalid prompt argument 'positional': arguments must be key=value pairs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_prompt_args_reports_missing_required_only() {
|
||||||
|
let declared = vec![
|
||||||
|
PromptArgument::new("path").with_required(true),
|
||||||
|
PromptArgument::new("style"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let (resolved, missing) = resolve_prompt_args(&declared, HashMap::new());
|
||||||
|
assert!(resolved.is_empty());
|
||||||
|
assert_eq!(missing, vec!["path".to_string()]);
|
||||||
|
|
||||||
|
let provided = HashMap::from([("path".to_string(), "notes.txt".to_string())]);
|
||||||
|
let (resolved, missing) = resolve_prompt_args(&declared, provided);
|
||||||
|
assert_eq!(resolved["path"], "notes.txt");
|
||||||
|
assert!(missing.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2105,10 +2228,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_prompt_blocked_in_session_or_agent() {
|
fn repl_commands_prompt_always_available() {
|
||||||
let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".prompt").unwrap();
|
let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".prompt").unwrap();
|
||||||
assert!(cmd.is_valid(StateFlags::empty()));
|
assert!(cmd.is_valid(StateFlags::empty()));
|
||||||
assert!(cmd.is_valid(StateFlags::ROLE));
|
assert!(cmd.is_valid(StateFlags::ROLE));
|
||||||
|
assert!(cmd.is_valid(StateFlags::SESSION));
|
||||||
|
assert!(cmd.is_valid(StateFlags::AGENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repl_commands_temp_role_blocked_in_session_or_agent() {
|
||||||
|
let cmd = REPL_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.name == ".temp-role")
|
||||||
|
.unwrap();
|
||||||
|
assert!(cmd.is_valid(StateFlags::empty()));
|
||||||
|
assert!(cmd.is_valid(StateFlags::ROLE));
|
||||||
assert!(!cmd.is_valid(StateFlags::SESSION));
|
assert!(!cmd.is_valid(StateFlags::SESSION));
|
||||||
assert!(!cmd.is_valid(StateFlags::AGENT));
|
assert!(!cmd.is_valid(StateFlags::AGENT));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user