style: 应用 rustfmt 格式化代码并调整部分代码风格
- 对多个文件应用 rustfmt 格式化,统一代码风格
- 调整长行分割以符合代码规范
- 统一导入语句顺序(std 相关导入在前)
- 使用内联格式字符串(format!("{now}"))
- 移除不必要的空行和尾随空格
- 为部分函数添加 #[must_use] 属性
- 优化测试断言格式以提高可读性
This commit is contained in:
parent
490fffb530
commit
5b353aee26
|
|
@ -40,7 +40,9 @@ impl ProviderClient {
|
||||||
OpenAiCompatConfig::dashscope()
|
OpenAiCompatConfig::dashscope()
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if crate::providers::openai_compat::has_api_key("DASHSCOPE_API_KEY") && !crate::providers::openai_compat::has_api_key("OPENAI_API_KEY") {
|
if crate::providers::openai_compat::has_api_key("DASHSCOPE_API_KEY")
|
||||||
|
&& !crate::providers::openai_compat::has_api_key("OPENAI_API_KEY")
|
||||||
|
{
|
||||||
OpenAiCompatConfig::dashscope()
|
OpenAiCompatConfig::dashscope()
|
||||||
} else {
|
} else {
|
||||||
OpenAiCompatConfig::openai()
|
OpenAiCompatConfig::openai()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
use std::backtrace::Backtrace;
|
||||||
use std::env::VarError;
|
use std::env::VarError;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::backtrace::Backtrace;
|
|
||||||
|
|
||||||
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
|
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
|
||||||
"something went wrong while processing your request",
|
"something went wrong while processing your request",
|
||||||
|
|
@ -77,10 +77,7 @@ pub enum ApiError {
|
||||||
impl ApiError {
|
impl ApiError {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
pub fn missing_credentials(
|
pub fn missing_credentials(provider: &'static str, env_vars: &'static [&'static str]) -> Self {
|
||||||
provider: &'static str,
|
|
||||||
env_vars: &'static [&'static str],
|
|
||||||
) -> Self {
|
|
||||||
Self::MissingCredentials {
|
Self::MissingCredentials {
|
||||||
provider,
|
provider,
|
||||||
env_vars,
|
env_vars,
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,13 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
||||||
// Uses the OpenAi provider kind because DashScope speaks the OpenAI REST
|
// Uses the OpenAi provider kind because DashScope speaks the OpenAI REST
|
||||||
// shape — only the base URL and auth env var differ.
|
// shape — only the base URL and auth env var differ.
|
||||||
// Allow ali- prefix as well for generic DashScope usage.
|
// Allow ali- prefix as well for generic DashScope usage.
|
||||||
if canonical.starts_with("qwen/") || canonical.starts_with("qwen-") || canonical.starts_with("ali/") || canonical.starts_with("ali-") || canonical.starts_with("glm/") || canonical.starts_with("glm-") {
|
if canonical.starts_with("qwen/")
|
||||||
|
|| canonical.starts_with("qwen-")
|
||||||
|
|| canonical.starts_with("ali/")
|
||||||
|
|| canonical.starts_with("ali-")
|
||||||
|
|| canonical.starts_with("glm/")
|
||||||
|
|| canonical.starts_with("glm-")
|
||||||
|
{
|
||||||
return Some(ProviderMetadata {
|
return Some(ProviderMetadata {
|
||||||
provider: ProviderKind::OpenAi,
|
provider: ProviderKind::OpenAi,
|
||||||
auth_env: "DASHSCOPE_API_KEY",
|
auth_env: "DASHSCOPE_API_KEY",
|
||||||
|
|
@ -463,10 +469,8 @@ pub(crate) fn dotenv_value(key: &str) -> Option<String> {
|
||||||
.and_then(|exe| exe.parent().map(std::path::PathBuf::from));
|
.and_then(|exe| exe.parent().map(std::path::PathBuf::from));
|
||||||
|
|
||||||
// Search order: cwd → exe directory → home directory
|
// Search order: cwd → exe directory → home directory
|
||||||
let search_paths: Vec<std::path::PathBuf> = [cwd, exe_dir, home]
|
let search_paths: Vec<std::path::PathBuf> =
|
||||||
.into_iter()
|
[cwd, exe_dir, home].into_iter().flatten().collect();
|
||||||
.flatten()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for dir in &search_paths {
|
for dir in &search_paths {
|
||||||
let env_path = dir.join(".env");
|
let env_path = dir.join(".env");
|
||||||
|
|
@ -777,14 +781,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn returns_context_window_metadata_for_kimi_models() {
|
fn returns_context_window_metadata_for_kimi_models() {
|
||||||
// kimi-k2.5
|
// kimi-k2.5
|
||||||
let k25_limit = model_token_limit("kimi-k2.5")
|
let k25_limit =
|
||||||
.expect("kimi-k2.5 should have token limit metadata");
|
model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata");
|
||||||
assert_eq!(k25_limit.max_output_tokens, 16_384);
|
assert_eq!(k25_limit.max_output_tokens, 16_384);
|
||||||
assert_eq!(k25_limit.context_window_tokens, 256_000);
|
assert_eq!(k25_limit.context_window_tokens, 256_000);
|
||||||
|
|
||||||
// kimi-k1.5
|
// kimi-k1.5
|
||||||
let k15_limit = model_token_limit("kimi-k1.5")
|
let k15_limit =
|
||||||
.expect("kimi-k1.5 should have token limit metadata");
|
model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata");
|
||||||
assert_eq!(k15_limit.max_output_tokens, 16_384);
|
assert_eq!(k15_limit.max_output_tokens, 16_384);
|
||||||
assert_eq!(k15_limit.context_window_tokens, 256_000);
|
assert_eq!(k15_limit.context_window_tokens, 256_000);
|
||||||
}
|
}
|
||||||
|
|
@ -792,11 +796,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
|
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
|
||||||
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
|
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
|
||||||
let alias_limit = model_token_limit("kimi")
|
let alias_limit =
|
||||||
.expect("kimi alias should resolve to kimi-k2.5 limits");
|
model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits");
|
||||||
let direct_limit = model_token_limit("kimi-k2.5")
|
let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits");
|
||||||
.expect("kimi-k2.5 should have limits");
|
assert_eq!(
|
||||||
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
|
alias_limit.max_output_tokens,
|
||||||
|
direct_limit.max_output_tokens
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alias_limit.context_window_tokens,
|
alias_limit.context_window_tokens,
|
||||||
direct_limit.context_window_tokens
|
direct_limit.context_window_tokens
|
||||||
|
|
@ -1061,6 +1067,7 @@ NO_EQUALS_LINE
|
||||||
provider,
|
provider,
|
||||||
env_vars,
|
env_vars,
|
||||||
hint,
|
hint,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*provider, "Anthropic");
|
assert_eq!(*provider, "Anthropic");
|
||||||
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
||||||
|
|
@ -1095,6 +1102,7 @@ NO_EQUALS_LINE
|
||||||
provider,
|
provider,
|
||||||
env_vars,
|
env_vars,
|
||||||
hint,
|
hint,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*provider, "Anthropic");
|
assert_eq!(*provider, "Anthropic");
|
||||||
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
||||||
|
|
|
||||||
|
|
@ -801,7 +801,10 @@ fn strip_routing_prefix(model: &str) -> &str {
|
||||||
let prefix = &model[..pos];
|
let prefix = &model[..pos];
|
||||||
// Only strip if the prefix before "/" is a known routing prefix,
|
// Only strip if the prefix before "/" is a known routing prefix,
|
||||||
// not if "/" appears in the middle of the model name for other reasons.
|
// not if "/" appears in the middle of the model name for other reasons.
|
||||||
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi" | "ali" | "glm") {
|
if matches!(
|
||||||
|
prefix,
|
||||||
|
"openai" | "xai" | "grok" | "qwen" | "kimi" | "ali" | "glm"
|
||||||
|
) {
|
||||||
&model[pos + 1..]
|
&model[pos + 1..]
|
||||||
} else {
|
} else {
|
||||||
model
|
model
|
||||||
|
|
@ -2195,9 +2198,16 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_specific_size_limits_are_correct() {
|
fn provider_specific_size_limits_are_correct() {
|
||||||
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
|
assert_eq!(
|
||||||
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
|
OpenAiCompatConfig::dashscope().max_request_body_bytes,
|
||||||
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
|
6_291_456
|
||||||
|
); // 6MB
|
||||||
|
assert_eq!(
|
||||||
|
OpenAiCompatConfig::openai().max_request_body_bytes,
|
||||||
|
104_857_600
|
||||||
|
); // 100MB
|
||||||
|
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
|
||||||
|
// 50MB
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -2338,18 +2338,20 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn handle_mcp_slash_command(
|
pub fn handle_mcp_slash_command(
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
) -> Result<String, runtime::ConfigError> {
|
) -> String {
|
||||||
let loader = ConfigLoader::default_for(cwd);
|
let loader = ConfigLoader::default_for(cwd);
|
||||||
render_mcp_report_for(&loader, cwd, args)
|
render_mcp_report_for(&loader, cwd, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn handle_mcp_slash_command_json(
|
pub fn handle_mcp_slash_command_json(
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
) -> Result<Value, runtime::ConfigError> {
|
) -> Value {
|
||||||
let loader = ConfigLoader::default_for(cwd);
|
let loader = ConfigLoader::default_for(cwd);
|
||||||
render_mcp_report_json_for(&loader, cwd, args)
|
render_mcp_report_json_for(&loader, cwd, args)
|
||||||
}
|
}
|
||||||
|
|
@ -2541,14 +2543,14 @@ fn render_mcp_report_for(
|
||||||
loader: &ConfigLoader,
|
loader: &ConfigLoader,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
) -> Result<String, runtime::ConfigError> {
|
) -> String {
|
||||||
if let Some(args) = normalize_optional_args(args) {
|
if let Some(args) = normalize_optional_args(args) {
|
||||||
if let Some(help_path) = help_path_from_args(args) {
|
if let Some(help_path) = help_path_from_args(args) {
|
||||||
return Ok(match help_path.as_slice() {
|
return match help_path.as_slice() {
|
||||||
[] => render_mcp_usage(None),
|
[] => render_mcp_usage(None),
|
||||||
["show", ..] => render_mcp_usage(Some("show")),
|
["show", ..] => render_mcp_usage(Some("show")),
|
||||||
_ => render_mcp_usage(Some(&help_path.join(" "))),
|
_ => render_mcp_usage(Some(&help_path.join(" "))),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2558,45 +2560,45 @@ fn render_mcp_report_for(
|
||||||
// as #143 for `status`). Text mode prepends a "Config load error"
|
// as #143 for `status`). Text mode prepends a "Config load error"
|
||||||
// block before the MCP list; the list falls back to empty.
|
// block before the MCP list; the list falls back to empty.
|
||||||
match loader.load() {
|
match loader.load() {
|
||||||
Ok(runtime_config) => Ok(render_mcp_summary_report(
|
Ok(runtime_config) => render_mcp_summary_report(
|
||||||
cwd,
|
cwd,
|
||||||
runtime_config.mcp().servers(),
|
runtime_config.mcp().servers(),
|
||||||
)),
|
),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let empty = std::collections::BTreeMap::new();
|
let empty = std::collections::BTreeMap::new();
|
||||||
Ok(format!(
|
format!(
|
||||||
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
|
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
|
||||||
render_mcp_summary_report(cwd, &empty)
|
render_mcp_summary_report(cwd, &empty)
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
|
Some(args) if is_help_arg(args) => render_mcp_usage(None),
|
||||||
Some("show") => Ok(render_mcp_usage(Some("show"))),
|
Some("show") => render_mcp_usage(Some("show")),
|
||||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||||
let mut parts = args.split_whitespace();
|
let mut parts = args.split_whitespace();
|
||||||
let _ = parts.next();
|
let _ = parts.next();
|
||||||
let Some(server_name) = parts.next() else {
|
let Some(server_name) = parts.next() else {
|
||||||
return Ok(render_mcp_usage(Some("show")));
|
return render_mcp_usage(Some("show"));
|
||||||
};
|
};
|
||||||
if parts.next().is_some() {
|
if parts.next().is_some() {
|
||||||
return Ok(render_mcp_usage(Some(args)));
|
return render_mcp_usage(Some(args));
|
||||||
}
|
}
|
||||||
// #144: same degradation for `mcp show`; if config won't parse,
|
// #144: same degradation for `mcp show`; if config won't parse,
|
||||||
// the specific server lookup can't succeed, so report the parse
|
// the specific server lookup can't succeed, so report the parse
|
||||||
// error with context.
|
// error with context.
|
||||||
match loader.load() {
|
match loader.load() {
|
||||||
Ok(runtime_config) => Ok(render_mcp_server_report(
|
Ok(runtime_config) => render_mcp_server_report(
|
||||||
cwd,
|
cwd,
|
||||||
server_name,
|
server_name,
|
||||||
runtime_config.mcp().get(server_name),
|
runtime_config.mcp().get(server_name),
|
||||||
)),
|
),
|
||||||
Err(err) => Ok(format!(
|
Err(err) => format!(
|
||||||
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
||||||
)),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(args) => Ok(render_mcp_usage(Some(args))),
|
Some(args) => render_mcp_usage(Some(args)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2604,14 +2606,14 @@ fn render_mcp_report_json_for(
|
||||||
loader: &ConfigLoader,
|
loader: &ConfigLoader,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
) -> Result<Value, runtime::ConfigError> {
|
) -> Value {
|
||||||
if let Some(args) = normalize_optional_args(args) {
|
if let Some(args) = normalize_optional_args(args) {
|
||||||
if let Some(help_path) = help_path_from_args(args) {
|
if let Some(help_path) = help_path_from_args(args) {
|
||||||
return Ok(match help_path.as_slice() {
|
return match help_path.as_slice() {
|
||||||
[] => render_mcp_usage_json(None),
|
[] => render_mcp_usage_json(None),
|
||||||
["show", ..] => render_mcp_usage_json(Some("show")),
|
["show", ..] => render_mcp_usage_json(Some("show")),
|
||||||
_ => render_mcp_usage_json(Some(&help_path.join(" "))),
|
_ => render_mcp_usage_json(Some(&help_path.join(" "))),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2623,15 +2625,13 @@ fn render_mcp_report_json_for(
|
||||||
// runs, the existing serializer adds `status: "ok"` below.
|
// runs, the existing serializer adds `status: "ok"` below.
|
||||||
match loader.load() {
|
match loader.load() {
|
||||||
Ok(runtime_config) => {
|
Ok(runtime_config) => {
|
||||||
let mut value = render_mcp_summary_report_json(
|
let mut value =
|
||||||
cwd,
|
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
|
||||||
runtime_config.mcp().servers(),
|
|
||||||
);
|
|
||||||
if let Some(map) = value.as_object_mut() {
|
if let Some(map) = value.as_object_mut() {
|
||||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||||
map.insert("config_load_error".to_string(), Value::Null);
|
map.insert("config_load_error".to_string(), Value::Null);
|
||||||
}
|
}
|
||||||
Ok(value)
|
value
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let empty = std::collections::BTreeMap::new();
|
let empty = std::collections::BTreeMap::new();
|
||||||
|
|
@ -2643,20 +2643,20 @@ fn render_mcp_report_json_for(
|
||||||
Value::String(err.to_string()),
|
Value::String(err.to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(value)
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
|
Some(args) if is_help_arg(args) => render_mcp_usage_json(None),
|
||||||
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
|
Some("show") => render_mcp_usage_json(Some("show")),
|
||||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||||
let mut parts = args.split_whitespace();
|
let mut parts = args.split_whitespace();
|
||||||
let _ = parts.next();
|
let _ = parts.next();
|
||||||
let Some(server_name) = parts.next() else {
|
let Some(server_name) = parts.next() else {
|
||||||
return Ok(render_mcp_usage_json(Some("show")));
|
return render_mcp_usage_json(Some("show"));
|
||||||
};
|
};
|
||||||
if parts.next().is_some() {
|
if parts.next().is_some() {
|
||||||
return Ok(render_mcp_usage_json(Some(args)));
|
return render_mcp_usage_json(Some(args));
|
||||||
}
|
}
|
||||||
// #144: same degradation pattern for show action.
|
// #144: same degradation pattern for show action.
|
||||||
match loader.load() {
|
match loader.load() {
|
||||||
|
|
@ -2670,19 +2670,19 @@ fn render_mcp_report_json_for(
|
||||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||||
map.insert("config_load_error".to_string(), Value::Null);
|
map.insert("config_load_error".to_string(), Value::Null);
|
||||||
}
|
}
|
||||||
Ok(value)
|
value
|
||||||
}
|
}
|
||||||
Err(err) => Ok(serde_json::json!({
|
Err(err) => serde_json::json!({
|
||||||
"kind": "mcp",
|
"kind": "mcp",
|
||||||
"action": "show",
|
"action": "show",
|
||||||
"server": server_name,
|
"server": server_name,
|
||||||
"status": "degraded",
|
"status": "degraded",
|
||||||
"config_load_error": err.to_string(),
|
"config_load_error": err.to_string(),
|
||||||
"working_directory": cwd.display().to_string(),
|
"working_directory": cwd.display().to_string(),
|
||||||
})),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
Some(args) => render_mcp_usage_json(Some(args)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5358,21 +5358,21 @@ mod tests {
|
||||||
fn mcp_usage_supports_help_and_unexpected_args() {
|
fn mcp_usage_supports_help_and_unexpected_args() {
|
||||||
let cwd = temp_dir("mcp-usage");
|
let cwd = temp_dir("mcp-usage");
|
||||||
|
|
||||||
let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
|
let help = super::handle_mcp_slash_command(Some("help"), &cwd);
|
||||||
assert!(help.contains("Usage /mcp [list|show <server>|help]"));
|
assert!(help.contains("Usage /mcp [list|show <server>|help]"));
|
||||||
assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
|
assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
|
||||||
|
|
||||||
let unexpected =
|
let unexpected =
|
||||||
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
|
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd);
|
||||||
assert!(unexpected.contains("Unexpected show alpha beta"));
|
assert!(unexpected.contains("Unexpected show alpha beta"));
|
||||||
|
|
||||||
let nested_help =
|
let nested_help =
|
||||||
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
|
super::handle_mcp_slash_command(Some("show --help"), &cwd);
|
||||||
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
|
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
|
||||||
assert!(nested_help.contains("Unexpected show"));
|
assert!(nested_help.contains("Unexpected show"));
|
||||||
|
|
||||||
let unknown_help =
|
let unknown_help =
|
||||||
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
|
super::handle_mcp_slash_command(Some("inspect --help"), &cwd);
|
||||||
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
|
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
|
||||||
assert!(unknown_help.contains("Unexpected inspect"));
|
assert!(unknown_help.contains("Unexpected inspect"));
|
||||||
|
|
||||||
|
|
@ -5423,8 +5423,7 @@ mod tests {
|
||||||
.expect("write local settings");
|
.expect("write local settings");
|
||||||
|
|
||||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||||
let list = super::render_mcp_report_for(&loader, &workspace, None)
|
let list = super::render_mcp_report_for(&loader, &workspace, None);
|
||||||
.expect("mcp list report should render");
|
|
||||||
assert!(list.contains("Configured servers 2"));
|
assert!(list.contains("Configured servers 2"));
|
||||||
assert!(list.contains("alpha"));
|
assert!(list.contains("alpha"));
|
||||||
assert!(list.contains("stdio"));
|
assert!(list.contains("stdio"));
|
||||||
|
|
@ -5435,21 +5434,18 @@ mod tests {
|
||||||
assert!(list.contains("local"));
|
assert!(list.contains("local"));
|
||||||
assert!(list.contains("wss://remote.example/mcp"));
|
assert!(list.contains("wss://remote.example/mcp"));
|
||||||
|
|
||||||
let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
|
let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"));
|
||||||
.expect("mcp show report should render");
|
|
||||||
assert!(show.contains("Name alpha"));
|
assert!(show.contains("Name alpha"));
|
||||||
assert!(show.contains("Command uvx"));
|
assert!(show.contains("Command uvx"));
|
||||||
assert!(show.contains("Args alpha-server"));
|
assert!(show.contains("Args alpha-server"));
|
||||||
assert!(show.contains("Env keys ALPHA_TOKEN"));
|
assert!(show.contains("Env keys ALPHA_TOKEN"));
|
||||||
assert!(show.contains("Tool timeout 1200 ms"));
|
assert!(show.contains("Tool timeout 1200 ms"));
|
||||||
|
|
||||||
let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
|
let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"));
|
||||||
.expect("mcp show remote report should render");
|
|
||||||
assert!(remote.contains("Transport ws"));
|
assert!(remote.contains("Transport ws"));
|
||||||
assert!(remote.contains("URL wss://remote.example/mcp"));
|
assert!(remote.contains("URL wss://remote.example/mcp"));
|
||||||
|
|
||||||
let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
|
let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"));
|
||||||
.expect("missing report should render");
|
|
||||||
assert!(missing.contains("server `missing` is not configured"));
|
assert!(missing.contains("server `missing` is not configured"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
|
@ -5501,7 +5497,7 @@ mod tests {
|
||||||
|
|
||||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||||
let list =
|
let list =
|
||||||
render_mcp_report_json_for(&loader, &workspace, None).expect("mcp list json render");
|
render_mcp_report_json_for(&loader, &workspace, None);
|
||||||
assert_eq!(list["kind"], "mcp");
|
assert_eq!(list["kind"], "mcp");
|
||||||
assert_eq!(list["action"], "list");
|
assert_eq!(list["action"], "list");
|
||||||
assert_eq!(list["configured_servers"], 2);
|
assert_eq!(list["configured_servers"], 2);
|
||||||
|
|
@ -5516,21 +5512,19 @@ mod tests {
|
||||||
"wss://remote.example/mcp"
|
"wss://remote.example/mcp"
|
||||||
);
|
);
|
||||||
|
|
||||||
let show = render_mcp_report_json_for(&loader, &workspace, Some("show alpha"))
|
let show = render_mcp_report_json_for(&loader, &workspace, Some("show alpha"));
|
||||||
.expect("mcp show json render");
|
|
||||||
assert_eq!(show["action"], "show");
|
assert_eq!(show["action"], "show");
|
||||||
assert_eq!(show["found"], true);
|
assert_eq!(show["found"], true);
|
||||||
assert_eq!(show["server"]["name"], "alpha");
|
assert_eq!(show["server"]["name"], "alpha");
|
||||||
assert_eq!(show["server"]["details"]["env_keys"][0], "ALPHA_TOKEN");
|
assert_eq!(show["server"]["details"]["env_keys"][0], "ALPHA_TOKEN");
|
||||||
assert_eq!(show["server"]["details"]["tool_call_timeout_ms"], 1200);
|
assert_eq!(show["server"]["details"]["tool_call_timeout_ms"], 1200);
|
||||||
|
|
||||||
let missing = render_mcp_report_json_for(&loader, &workspace, Some("show missing"))
|
let missing = render_mcp_report_json_for(&loader, &workspace, Some("show missing"));
|
||||||
.expect("mcp missing json render");
|
|
||||||
assert_eq!(missing["found"], false);
|
assert_eq!(missing["found"], false);
|
||||||
assert_eq!(missing["server_name"], "missing");
|
assert_eq!(missing["server_name"], "missing");
|
||||||
|
|
||||||
let help =
|
let help =
|
||||||
render_mcp_report_json_for(&loader, &workspace, Some("help")).expect("mcp help json");
|
render_mcp_report_json_for(&loader, &workspace, Some("help"));
|
||||||
assert_eq!(help["action"], "help");
|
assert_eq!(help["action"], "help");
|
||||||
assert_eq!(help["usage"]["sources"][0], ".claw/settings.json");
|
assert_eq!(help["usage"]["sources"][0], ".claw/settings.json");
|
||||||
|
|
||||||
|
|
@ -5565,8 +5559,7 @@ mod tests {
|
||||||
|
|
||||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||||
// list action: must return Ok (not Err) with degraded envelope.
|
// list action: must return Ok (not Err) with degraded envelope.
|
||||||
let list = render_mcp_report_json_for(&loader, &workspace, None)
|
let list = render_mcp_report_json_for(&loader, &workspace, None);
|
||||||
.expect("mcp list should not hard-fail on config parse errors (#144)");
|
|
||||||
assert_eq!(list["kind"], "mcp");
|
assert_eq!(list["kind"], "mcp");
|
||||||
assert_eq!(list["action"], "list");
|
assert_eq!(list["action"], "list");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -5585,8 +5578,7 @@ mod tests {
|
||||||
assert!(list["servers"].as_array().unwrap().is_empty());
|
assert!(list["servers"].as_array().unwrap().is_empty());
|
||||||
|
|
||||||
// show action: should also degrade (not hard-fail).
|
// show action: should also degrade (not hard-fail).
|
||||||
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
|
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"));
|
||||||
.expect("mcp show should not hard-fail on config parse errors (#144)");
|
|
||||||
assert_eq!(show["kind"], "mcp");
|
assert_eq!(show["kind"], "mcp");
|
||||||
assert_eq!(show["action"], "show");
|
assert_eq!(show["action"], "show");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -5600,8 +5592,7 @@ mod tests {
|
||||||
let clean_ws = temp_dir("mcp-degrades-144-clean");
|
let clean_ws = temp_dir("mcp-degrades-144-clean");
|
||||||
fs::create_dir_all(&clean_ws).expect("clean ws");
|
fs::create_dir_all(&clean_ws).expect("clean ws");
|
||||||
let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
|
let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
|
||||||
let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None)
|
let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None);
|
||||||
.expect("clean mcp list should succeed");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
clean_list["status"].as_str(),
|
clean_list["status"].as_str(),
|
||||||
Some("ok"),
|
Some("ok"),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use lsp_types::{
|
use lsp_types::{
|
||||||
Diagnostic, GotoDefinitionResponse, Location, LocationLink, Position, PublishDiagnosticsParams,
|
Diagnostic, GotoDefinitionResponse, Location, LocationLink, Position, PublishDiagnosticsParams,
|
||||||
|
|
@ -15,11 +15,13 @@ use tokio::sync::{oneshot, Mutex};
|
||||||
use crate::error::LspError;
|
use crate::error::LspError;
|
||||||
use crate::types::{LspServerConfig, SymbolLocation};
|
use crate::types::{LspServerConfig, SymbolLocation};
|
||||||
|
|
||||||
|
type PendingRequests = Arc<Mutex<BTreeMap<i64, oneshot::Sender<Result<Value, LspError>>>>>;
|
||||||
|
|
||||||
pub(crate) struct LspClient {
|
pub(crate) struct LspClient {
|
||||||
config: LspServerConfig,
|
config: LspServerConfig,
|
||||||
writer: Mutex<BufWriter<ChildStdin>>,
|
writer: Mutex<BufWriter<ChildStdin>>,
|
||||||
child: Mutex<Child>,
|
child: Mutex<Child>,
|
||||||
pending_requests: Arc<Mutex<BTreeMap<i64, oneshot::Sender<Result<Value, LspError>>>>>,
|
pending_requests: PendingRequests,
|
||||||
diagnostics: Arc<Mutex<BTreeMap<String, Vec<Diagnostic>>>>,
|
diagnostics: Arc<Mutex<BTreeMap<String, Vec<Diagnostic>>>>,
|
||||||
open_documents: Mutex<BTreeMap<PathBuf, i32>>,
|
open_documents: Mutex<BTreeMap<PathBuf, i32>>,
|
||||||
next_request_id: AtomicI64,
|
next_request_id: AtomicI64,
|
||||||
|
|
@ -59,7 +61,7 @@ impl LspClient {
|
||||||
|
|
||||||
client.spawn_reader(stdout);
|
client.spawn_reader(stdout);
|
||||||
if let Some(stderr) = stderr {
|
if let Some(stderr) = stderr {
|
||||||
client.spawn_stderr_drain(stderr);
|
Self::spawn_stderr_drain(stderr);
|
||||||
}
|
}
|
||||||
client.initialize().await?;
|
client.initialize().await?;
|
||||||
Ok(client)
|
Ok(client)
|
||||||
|
|
@ -190,7 +192,9 @@ impl LspClient {
|
||||||
Some(GotoDefinitionResponse::Scalar(location)) => {
|
Some(GotoDefinitionResponse::Scalar(location)) => {
|
||||||
location_to_symbol_locations(vec![location])
|
location_to_symbol_locations(vec![location])
|
||||||
}
|
}
|
||||||
Some(GotoDefinitionResponse::Array(locations)) => location_to_symbol_locations(locations),
|
Some(GotoDefinitionResponse::Array(locations)) => {
|
||||||
|
location_to_symbol_locations(locations)
|
||||||
|
}
|
||||||
Some(GotoDefinitionResponse::Link(links)) => location_links_to_symbol_locations(links),
|
Some(GotoDefinitionResponse::Link(links)) => location_links_to_symbol_locations(links),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
})
|
})
|
||||||
|
|
@ -272,7 +276,8 @@ impl LspClient {
|
||||||
if notification.diagnostics.is_empty() {
|
if notification.diagnostics.is_empty() {
|
||||||
diagnostics_map.remove(¬ification.uri.to_string());
|
diagnostics_map.remove(¬ification.uri.to_string());
|
||||||
} else {
|
} else {
|
||||||
diagnostics_map.insert(notification.uri.to_string(), notification.diagnostics);
|
diagnostics_map
|
||||||
|
.insert(notification.uri.to_string(), notification.diagnostics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok::<(), LspError>(())
|
Ok::<(), LspError>(())
|
||||||
|
|
@ -281,10 +286,7 @@ impl LspClient {
|
||||||
|
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
let mut pending = pending_requests.lock().await;
|
let mut pending = pending_requests.lock().await;
|
||||||
let drained = pending
|
let drained = pending.keys().copied().collect::<Vec<_>>();
|
||||||
.iter()
|
|
||||||
.map(|(id, _)| *id)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
for id in drained {
|
for id in drained {
|
||||||
if let Some(sender) = pending.remove(&id) {
|
if let Some(sender) = pending.remove(&id) {
|
||||||
let _ = sender.send(Err(LspError::Protocol(error.to_string())));
|
let _ = sender.send(Err(LspError::Protocol(error.to_string())));
|
||||||
|
|
@ -294,7 +296,7 @@ impl LspClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_stderr_drain<R>(&self, stderr: R)
|
fn spawn_stderr_drain<R>(stderr: R)
|
||||||
where
|
where
|
||||||
R: AsyncRead + Unpin + Send + 'static,
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
|
|
@ -439,7 +441,7 @@ fn location_to_symbol_locations(locations: Vec<Location>) -> Vec<SymbolLocation>
|
||||||
locations
|
locations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|location| {
|
.filter_map(|location| {
|
||||||
uri_to_path(&location.uri.to_string()).map(|path| SymbolLocation {
|
uri_to_path(location.uri.as_str()).map(|path| SymbolLocation {
|
||||||
path,
|
path,
|
||||||
range: location.range,
|
range: location.range,
|
||||||
})
|
})
|
||||||
|
|
@ -448,9 +450,10 @@ fn location_to_symbol_locations(locations: Vec<Location>) -> Vec<SymbolLocation>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn location_links_to_symbol_locations(links: Vec<LocationLink>) -> Vec<SymbolLocation> {
|
fn location_links_to_symbol_locations(links: Vec<LocationLink>) -> Vec<SymbolLocation> {
|
||||||
links.into_iter()
|
links
|
||||||
|
.into_iter()
|
||||||
.filter_map(|link| {
|
.filter_map(|link| {
|
||||||
uri_to_path(&link.target_uri.to_string()).map(|path| SymbolLocation {
|
uri_to_path(link.target_uri.as_str()).map(|path| SymbolLocation {
|
||||||
path,
|
path,
|
||||||
range: link.target_selection_range,
|
range: link.target_selection_range,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,8 @@ while True:
|
||||||
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
||||||
let script_path = write_mock_server_script(&root);
|
let script_path = write_mock_server_script(&root);
|
||||||
let source_path = root.join("src").join("main.rs");
|
let source_path = root.join("src").join("main.rs");
|
||||||
fs::write(&source_path, "fn main() {}\nlet value = 1;\n").expect("source file should exist");
|
fs::write(&source_path, "fn main() {}\nlet value = 1;\n")
|
||||||
|
.expect("source file should exist");
|
||||||
let manager = LspManager::new(vec![LspServerConfig {
|
let manager = LspManager::new(vec![LspServerConfig {
|
||||||
name: "rust-analyzer".to_string(),
|
name: "rust-analyzer".to_string(),
|
||||||
command: python,
|
command: python,
|
||||||
|
|
@ -204,7 +205,10 @@ while True:
|
||||||
}])
|
}])
|
||||||
.expect("manager should build");
|
.expect("manager should build");
|
||||||
manager
|
manager
|
||||||
.open_document(&source_path, &fs::read_to_string(&source_path).expect("source read should succeed"))
|
.open_document(
|
||||||
|
&source_path,
|
||||||
|
&fs::read_to_string(&source_path).expect("source read should succeed"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("document should open");
|
.expect("document should open");
|
||||||
wait_for_diagnostics(&manager).await;
|
wait_for_diagnostics(&manager).await;
|
||||||
|
|
@ -226,7 +230,10 @@ while True:
|
||||||
// then
|
// then
|
||||||
assert_eq!(diagnostics.files.len(), 1);
|
assert_eq!(diagnostics.files.len(), 1);
|
||||||
assert_eq!(diagnostics.total_diagnostics(), 1);
|
assert_eq!(diagnostics.total_diagnostics(), 1);
|
||||||
assert_eq!(diagnostics.files[0].diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
|
assert_eq!(
|
||||||
|
diagnostics.files[0].diagnostics[0].severity,
|
||||||
|
Some(DiagnosticSeverity::ERROR)
|
||||||
|
);
|
||||||
assert_eq!(definitions.len(), 1);
|
assert_eq!(definitions.len(), 1);
|
||||||
assert_eq!(definitions[0].start_line(), 1);
|
assert_eq!(definitions[0].start_line(), 1);
|
||||||
assert_eq!(references.len(), 2);
|
assert_eq!(references.len(), 2);
|
||||||
|
|
@ -246,7 +253,8 @@ while True:
|
||||||
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
fs::create_dir_all(root.join("src")).expect("workspace root should exist");
|
||||||
let script_path = write_mock_server_script(&root);
|
let script_path = write_mock_server_script(&root);
|
||||||
let source_path = root.join("src").join("lib.rs");
|
let source_path = root.join("src").join("lib.rs");
|
||||||
fs::write(&source_path, "pub fn answer() -> i32 { 42 }\n").expect("source file should exist");
|
fs::write(&source_path, "pub fn answer() -> i32 { 42 }\n")
|
||||||
|
.expect("source file should exist");
|
||||||
let manager = LspManager::new(vec![LspServerConfig {
|
let manager = LspManager::new(vec![LspServerConfig {
|
||||||
name: "rust-analyzer".to_string(),
|
name: "rust-analyzer".to_string(),
|
||||||
command: python,
|
command: python,
|
||||||
|
|
@ -258,7 +266,10 @@ while True:
|
||||||
}])
|
}])
|
||||||
.expect("manager should build");
|
.expect("manager should build");
|
||||||
manager
|
manager
|
||||||
.open_document(&source_path, &fs::read_to_string(&source_path).expect("source read should succeed"))
|
.open_document(
|
||||||
|
&source_path,
|
||||||
|
&fs::read_to_string(&source_path).expect("source read should succeed"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("document should open");
|
.expect("document should open");
|
||||||
wait_for_diagnostics(&manager).await;
|
wait_for_diagnostics(&manager).await;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ impl LspManager {
|
||||||
for config in server_configs {
|
for config in server_configs {
|
||||||
for extension in config.extension_to_language.keys() {
|
for extension in config.extension_to_language.keys() {
|
||||||
let normalized = normalize_extension(extension);
|
let normalized = normalize_extension(extension);
|
||||||
if let Some(existing_server) = extension_map.insert(normalized.clone(), config.name.clone()) {
|
if let Some(existing_server) =
|
||||||
|
extension_map.insert(normalized.clone(), config.name.clone())
|
||||||
|
{
|
||||||
return Err(LspError::DuplicateExtension {
|
return Err(LspError::DuplicateExtension {
|
||||||
extension: normalized,
|
extension: normalized,
|
||||||
existing_server,
|
existing_server,
|
||||||
|
|
@ -53,7 +55,10 @@ impl LspManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
pub async fn open_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||||
self.client_for_path(path).await?.open_document(path, text).await
|
self.client_for_path(path)
|
||||||
|
.await?
|
||||||
|
.open_document(path, text)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_document_from_disk(&self, path: &Path) -> Result<(), LspError> {
|
pub async fn sync_document_from_disk(&self, path: &Path) -> Result<(), LspError> {
|
||||||
|
|
@ -63,7 +68,10 @@ impl LspManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn change_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
pub async fn change_document(&self, path: &Path, text: &str) -> Result<(), LspError> {
|
||||||
self.client_for_path(path).await?.change_document(path, text).await
|
self.client_for_path(path)
|
||||||
|
.await?
|
||||||
|
.change_document(path, text)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_document(&self, path: &Path) -> Result<(), LspError> {
|
pub async fn save_document(&self, path: &Path) -> Result<(), LspError> {
|
||||||
|
|
@ -79,7 +87,11 @@ impl LspManager {
|
||||||
path: &Path,
|
path: &Path,
|
||||||
position: Position,
|
position: Position,
|
||||||
) -> Result<Vec<SymbolLocation>, LspError> {
|
) -> Result<Vec<SymbolLocation>, LspError> {
|
||||||
let mut locations = self.client_for_path(path).await?.go_to_definition(path, position).await?;
|
let mut locations = self
|
||||||
|
.client_for_path(path)
|
||||||
|
.await?
|
||||||
|
.go_to_definition(path, position)
|
||||||
|
.await?;
|
||||||
dedupe_locations(&mut locations);
|
dedupe_locations(&mut locations);
|
||||||
Ok(locations)
|
Ok(locations)
|
||||||
}
|
}
|
||||||
|
|
@ -100,14 +112,21 @@ impl LspManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn collect_workspace_diagnostics(&self) -> Result<WorkspaceDiagnostics, LspError> {
|
pub async fn collect_workspace_diagnostics(&self) -> Result<WorkspaceDiagnostics, LspError> {
|
||||||
let clients = self.clients.lock().await.values().cloned().collect::<Vec<_>>();
|
let clients = self
|
||||||
|
.clients
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
||||||
for client in clients {
|
for client in clients {
|
||||||
for (uri, diagnostics) in client.diagnostics_snapshot().await {
|
for (uri, diagnostics) in client.diagnostics_snapshot().await {
|
||||||
let Ok(path) = url::Url::parse(&uri)
|
let Ok(path) = url::Url::parse(&uri).and_then(|url| {
|
||||||
.and_then(|url| url.to_file_path().map_err(|()| url::ParseError::RelativeUrlWithoutBase))
|
url.to_file_path()
|
||||||
else {
|
.map_err(|()| url::ParseError::RelativeUrlWithoutBase)
|
||||||
|
}) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if diagnostics.is_empty() {
|
if diagnostics.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
|
||||||
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
||||||
pr_number: None,
|
pr_number: None,
|
||||||
};
|
};
|
||||||
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
|
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
|
||||||
// Log to stderr as interim routing before event stream integration
|
// Log to stderr as interim routing before event stream integration
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,9 @@ where
|
||||||
|
|
||||||
let error_msg = format!("API request failed with error: {error}\nI have automatically truncated the earlier conversation history to reduce context length. Please analyze the error, review your task, and continue.");
|
let error_msg = format!("API request failed with error: {error}\nI have automatically truncated the earlier conversation history to reduce context length. Please analyze the error, review your task, and continue.");
|
||||||
if let Err(e) = self.session.push_user_text(error_msg) {
|
if let Err(e) = self.session.push_user_text(error_msg) {
|
||||||
return Err(RuntimeError::new(format!("Failed to push error message: {e}")));
|
return Err(RuntimeError::new(format!(
|
||||||
|
"Failed to push error message: {e}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -382,9 +384,12 @@ where
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.record_turn_failed(iterations, &error);
|
self.record_turn_failed(iterations, &error);
|
||||||
|
|
||||||
let error_msg = format!("Failed to parse API response: {error}\nPlease try again.");
|
let error_msg =
|
||||||
|
format!("Failed to parse API response: {error}\nPlease try again.");
|
||||||
if let Err(e) = self.session.push_user_text(error_msg) {
|
if let Err(e) = self.session.push_user_text(error_msg) {
|
||||||
return Err(RuntimeError::new(format!("Failed to push error message: {e}")));
|
return Err(RuntimeError::new(format!(
|
||||||
|
"Failed to push error message: {e}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -405,7 +405,10 @@ pub enum BlockedSubphase {
|
||||||
#[serde(rename = "blocked.branch_freshness")]
|
#[serde(rename = "blocked.branch_freshness")]
|
||||||
BranchFreshness { behind_main: u32 },
|
BranchFreshness { behind_main: u32 },
|
||||||
#[serde(rename = "blocked.test_hang")]
|
#[serde(rename = "blocked.test_hang")]
|
||||||
TestHang { elapsed_secs: u32, test_name: Option<String> },
|
TestHang {
|
||||||
|
elapsed_secs: u32,
|
||||||
|
test_name: Option<String>,
|
||||||
|
},
|
||||||
#[serde(rename = "blocked.report_pending")]
|
#[serde(rename = "blocked.report_pending")]
|
||||||
ReportPending { since_secs: u32 },
|
ReportPending { since_secs: u32 },
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +546,8 @@ impl LaneEvent {
|
||||||
.with_failure_class(blocker.failure_class)
|
.with_failure_class(blocker.failure_class)
|
||||||
.with_detail(blocker.detail.clone());
|
.with_detail(blocker.detail.clone());
|
||||||
if let Some(ref subphase) = blocker.subphase {
|
if let Some(ref subphase) = blocker.subphase {
|
||||||
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
event =
|
||||||
|
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||||
}
|
}
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
@ -554,7 +558,8 @@ impl LaneEvent {
|
||||||
.with_failure_class(blocker.failure_class)
|
.with_failure_class(blocker.failure_class)
|
||||||
.with_detail(blocker.detail.clone());
|
.with_detail(blocker.detail.clone());
|
||||||
if let Some(ref subphase) = blocker.subphase {
|
if let Some(ref subphase) = blocker.subphase {
|
||||||
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
event =
|
||||||
|
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||||
}
|
}
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
@ -562,8 +567,12 @@ impl LaneEvent {
|
||||||
/// Ship prepared — §4.44.5
|
/// Ship prepared — §4.44.5
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||||
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
|
Self::new(
|
||||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
LaneEventName::ShipPrepared,
|
||||||
|
LaneEventStatus::Ready,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship commits selected — §4.44.5
|
/// Ship commits selected — §4.44.5
|
||||||
|
|
@ -573,22 +582,34 @@ impl LaneEvent {
|
||||||
commit_count: u32,
|
commit_count: u32,
|
||||||
commit_range: impl Into<String>,
|
commit_range: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
|
Self::new(
|
||||||
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
LaneEventName::ShipCommitsSelected,
|
||||||
|
LaneEventStatus::Ready,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship merged — §4.44.5
|
/// Ship merged — §4.44.5
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||||
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
|
Self::new(
|
||||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
LaneEventName::ShipMerged,
|
||||||
|
LaneEventStatus::Completed,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship pushed to main — §4.44.5
|
/// Ship pushed to main — §4.44.5
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||||
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
|
Self::new(
|
||||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
LaneEventName::ShipPushedMain,
|
||||||
|
LaneEventStatus::Completed,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ impl SessionStore {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
// #151: canonicalize workspace_root for consistent fingerprinting
|
// #151: canonicalize workspace_root for consistent fingerprinting
|
||||||
// across equivalent path representations.
|
// across equivalent path representations.
|
||||||
let canonical_workspace = fs::canonicalize(workspace_root)
|
let canonical_workspace =
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||||
let sessions_root = data_dir
|
let sessions_root = data_dir
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.join("sessions")
|
.join("sessions")
|
||||||
|
|
@ -158,10 +158,9 @@ impl SessionStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
self.list_sessions()?
|
self.list_sessions()?.into_iter().next().ok_or_else(|| {
|
||||||
.into_iter()
|
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
|
||||||
.next()
|
})
|
||||||
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_session(
|
pub fn load_session(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
unused_variables,
|
unused_variables,
|
||||||
clippy::unneeded_struct_pattern,
|
clippy::unneeded_struct_pattern,
|
||||||
clippy::unnecessary_wraps,
|
clippy::unnecessary_wraps,
|
||||||
clippy::unused_self
|
clippy::unused_self,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::doc_markdown,
|
||||||
|
clippy::result_large_err
|
||||||
)]
|
)]
|
||||||
mod init;
|
mod init;
|
||||||
mod input;
|
mod input;
|
||||||
|
|
@ -200,9 +203,12 @@ type RuntimePluginStateBuildOutput = (
|
||||||
);
|
);
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
||||||
if let Err(error) = run() {
|
if let Err(error) = run() {
|
||||||
let message = format!("{:?}\n\nStack trace:\n{}", error, std::backtrace::Backtrace::force_capture());
|
let message = format!(
|
||||||
|
"{:?}\n\nStack trace:\n{}",
|
||||||
|
error,
|
||||||
|
std::backtrace::Backtrace::force_capture()
|
||||||
|
);
|
||||||
// When --output-format json is active, emit errors as JSON so downstream
|
// When --output-format json is active, emit errors as JSON so downstream
|
||||||
// tools can parse failures the same way they parse successes (ROADMAP #42).
|
// tools can parse failures the same way they parse successes (ROADMAP #42).
|
||||||
let argv: Vec<String> = std::env::args().collect();
|
let argv: Vec<String> = std::env::args().collect();
|
||||||
|
|
@ -230,8 +236,10 @@ fn main() {
|
||||||
// don't need to regex-scrape the prose.
|
// don't need to regex-scrape the prose.
|
||||||
let kind = classify_error_kind(&message);
|
let kind = classify_error_kind(&message);
|
||||||
if message.contains("`claw --help`") {
|
if message.contains("`claw --help`") {
|
||||||
eprintln!("[error-kind: {kind}]
|
eprintln!(
|
||||||
error: {message}");
|
"[error-kind: {kind}]
|
||||||
|
error: {message}"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[error-kind: {kind}]
|
"[error-kind: {kind}]
|
||||||
|
|
@ -375,7 +383,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
model_flag_raw,
|
model_flag_raw,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
output_format,
|
output_format,
|
||||||
} => print_status_snapshot(&model, model_flag_raw.as_deref(), permission_mode, output_format)?,
|
} => print_status_snapshot(
|
||||||
|
&model,
|
||||||
|
model_flag_raw.as_deref(),
|
||||||
|
permission_mode,
|
||||||
|
output_format,
|
||||||
|
)?,
|
||||||
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
|
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt,
|
prompt,
|
||||||
|
|
@ -416,19 +429,17 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
CliAction::Config {
|
CliAction::Config {
|
||||||
section,
|
section,
|
||||||
output_format,
|
output_format,
|
||||||
} => {
|
} => match output_format {
|
||||||
match output_format {
|
CliOutputFormat::Text => {
|
||||||
CliOutputFormat::Text => {
|
println!("{}", render_config_report(section.as_deref())?);
|
||||||
println!("{}", render_config_report(section.as_deref())?);
|
|
||||||
}
|
|
||||||
CliOutputFormat::Json => {
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
CliOutputFormat::Json => {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
CliAction::Diff { output_format } => match output_format {
|
CliAction::Diff { output_format } => match output_format {
|
||||||
CliOutputFormat::Text => {
|
CliOutputFormat::Text => {
|
||||||
println!("{}", render_diff_report()?);
|
println!("{}", render_diff_report()?);
|
||||||
|
|
@ -632,13 +643,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
}
|
}
|
||||||
"--help" | "-h"
|
"--help" | "-h"
|
||||||
if !rest.is_empty()
|
if !rest.is_empty()
|
||||||
&& matches!(
|
&& matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") =>
|
||||||
rest[0].as_str(),
|
|
||||||
"prompt"
|
|
||||||
| "commit"
|
|
||||||
| "pr"
|
|
||||||
| "issue"
|
|
||||||
) =>
|
|
||||||
{
|
{
|
||||||
// `--help` following a subcommand that would otherwise forward
|
// `--help` following a subcommand that would otherwise forward
|
||||||
// the arg to the API (e.g. `claw prompt --help`) should show
|
// the arg to the API (e.g. `claw prompt --help`) should show
|
||||||
|
|
@ -849,9 +854,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
if let Some(action) = parse_local_help_action(&rest) {
|
if let Some(action) = parse_local_help_action(&rest) {
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
if let Some(action) =
|
if let Some(action) = parse_single_word_command_alias(
|
||||||
parse_single_word_command_alias(&rest, &model, model_flag_raw.as_deref(), permission_mode_override, output_format)
|
&rest,
|
||||||
{
|
&model,
|
||||||
|
model_flag_raw.as_deref(),
|
||||||
|
permission_mode_override,
|
||||||
|
output_format,
|
||||||
|
) {
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1317,7 +1326,6 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'
|
||||||
ranked_suggestions(input, candidates).into_iter().next()
|
ranked_suggestions(input, candidates).into_iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
||||||
const KNOWN_SUBCOMMANDS: &[&str] = &[
|
const KNOWN_SUBCOMMANDS: &[&str] = &[
|
||||||
"help",
|
"help",
|
||||||
|
|
@ -1347,8 +1355,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
||||||
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
|
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
|
||||||
let substring_match = normalized_candidate.contains(&normalized_input)
|
let substring_match = normalized_candidate.contains(&normalized_input)
|
||||||
|| normalized_input.contains(&normalized_candidate);
|
|| normalized_input.contains(&normalized_candidate);
|
||||||
((distance <= 2) || prefix_match || substring_match)
|
((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate))
|
||||||
.then_some((distance, *candidate))
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
|
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
|
||||||
|
|
@ -1368,7 +1375,6 @@ fn common_prefix_len(left: &str, right: &str) -> usize {
|
||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn looks_like_subcommand_typo(input: &str) -> bool {
|
fn looks_like_subcommand_typo(input: &str) -> bool {
|
||||||
!input.is_empty()
|
!input.is_empty()
|
||||||
&& input
|
&& input
|
||||||
|
|
@ -1455,19 +1461,23 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
// Known aliases are always valid
|
// Known aliases are always valid
|
||||||
match trimmed {
|
match trimmed {
|
||||||
"opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini" | "grok-3-mini" | "kimi" | "glm-5" => return Ok(()),
|
"opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini"
|
||||||
|
| "grok-3-mini" | "kimi" | "glm-5" => return Ok(()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashscope and xAI models that don't use provider/model syntax
|
// Dashscope and xAI models that don't use provider/model syntax
|
||||||
if trimmed.starts_with("qwen-") || trimmed.starts_with("ali-") || trimmed.starts_with("glm-") || trimmed.starts_with("kimi-") {
|
if trimmed.starts_with("qwen-")
|
||||||
|
|| trimmed.starts_with("ali-")
|
||||||
|
|| trimmed.starts_with("glm-")
|
||||||
|
|| trimmed.starts_with("kimi-")
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Check for spaces (malformed)
|
// Check for spaces (malformed)
|
||||||
if trimmed.contains(' ') {
|
if trimmed.contains(' ') {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"invalid model syntax: '{}' contains spaces. Use provider/model format or known alias",
|
"invalid model syntax: '{trimmed}' contains spaces. Use provider/model format or known alias"
|
||||||
trimmed
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Check provider/model format: provider_id/model_id
|
// Check provider/model format: provider_id/model_id
|
||||||
|
|
@ -1475,20 +1485,17 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||||
// #154: hint if the model looks like it belongs to a different provider
|
// #154: hint if the model looks like it belongs to a different provider
|
||||||
let mut err_msg = format!(
|
let mut err_msg = format!(
|
||||||
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)",
|
"invalid model syntax: '{trimmed}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)"
|
||||||
trimmed
|
|
||||||
);
|
);
|
||||||
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
|
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
|
||||||
err_msg.push_str("\nDid you mean `openai/");
|
err_msg.push_str("\nDid you mean `openai/");
|
||||||
err_msg.push_str(trimmed);
|
err_msg.push_str(trimmed);
|
||||||
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
|
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
|
||||||
}
|
} else if trimmed.starts_with("qwen") {
|
||||||
else if trimmed.starts_with("qwen") {
|
|
||||||
err_msg.push_str("\nDid you mean `qwen/");
|
err_msg.push_str("\nDid you mean `qwen/");
|
||||||
err_msg.push_str(trimmed);
|
err_msg.push_str(trimmed);
|
||||||
err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)");
|
err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)");
|
||||||
}
|
} else if trimmed.starts_with("grok") {
|
||||||
else if trimmed.starts_with("grok") {
|
|
||||||
err_msg.push_str("\nDid you mean `xai/");
|
err_msg.push_str("\nDid you mean `xai/");
|
||||||
err_msg.push_str(trimmed);
|
err_msg.push_str(trimmed);
|
||||||
err_msg.push_str("`? (Requires XAI_API_KEY env var)");
|
err_msg.push_str("`? (Requires XAI_API_KEY env var)");
|
||||||
|
|
@ -3284,8 +3291,8 @@ fn run_resume_command(
|
||||||
};
|
};
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
|
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)),
|
||||||
json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?),
|
json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||||
|
|
@ -4332,7 +4339,6 @@ impl LiveCli {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
|
|
@ -4868,10 +4874,10 @@ impl LiveCli {
|
||||||
}
|
}
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)?),
|
CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd)?)?
|
serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))?
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -5450,7 +5456,13 @@ fn print_status_snapshot(
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!(
|
CliOutputFormat::Text => println!(
|
||||||
"{}",
|
"{}",
|
||||||
format_status_report(&provenance.resolved, usage, permission_mode.as_str(), &context, Some(&provenance))
|
format_status_report(
|
||||||
|
&provenance.resolved,
|
||||||
|
usage,
|
||||||
|
permission_mode.as_str(),
|
||||||
|
&context,
|
||||||
|
Some(&provenance)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => println!(
|
||||||
"{}",
|
"{}",
|
||||||
|
|
@ -5618,8 +5630,7 @@ fn format_status_report(
|
||||||
Some(raw) if raw != model => {
|
Some(raw) if raw != model => {
|
||||||
format!("\n Model source {} (raw: {raw})", p.source.as_str())
|
format!("\n Model source {} (raw: {raw})", p.source.as_str())
|
||||||
}
|
}
|
||||||
Some(_) => format!("\n Model source {}", p.source.as_str()),
|
_ => format!("\n Model source {}", p.source.as_str()),
|
||||||
None => format!("\n Model source {}", p.source.as_str()),
|
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
blocks.extend([
|
blocks.extend([
|
||||||
|
|
@ -9016,26 +9027,24 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
||||||
collect_session_prompt_history, create_managed_session_handle, describe_tool_progress,
|
classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
|
||||||
filter_tool_specs, format_bughunter_report, format_commit_preflight_report,
|
describe_tool_progress, filter_tool_specs, format_bughunter_report,
|
||||||
format_commit_skipped_report, format_compact_report, format_connected_line,
|
format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
|
||||||
format_cost_report, format_history_timestamp, format_internal_prompt_progress_line,
|
format_connected_line, format_cost_report, format_history_timestamp,
|
||||||
format_issue_report, format_model_report, format_model_switch_report,
|
format_internal_prompt_progress_line, format_issue_report, format_model_report,
|
||||||
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
|
||||||
format_ultraplan_report, format_unknown_slash_command,
|
format_tool_result, format_ultraplan_report, format_unknown_slash_command,
|
||||||
format_unknown_slash_command_message, format_user_visible_api_error,
|
format_unknown_slash_command_message, format_user_visible_api_error,
|
||||||
classify_error_kind,
|
|
||||||
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args,
|
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args,
|
||||||
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
||||||
parse_history_count, permission_policy, print_help_to, push_output_block,
|
parse_history_count, permission_policy, print_help_to, push_output_block,
|
||||||
render_config_report, render_diff_report, render_diff_report_for, render_memory_report,
|
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
|
||||||
split_error_hint,
|
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
|
||||||
render_help_topic, render_prompt_history_report, render_repl_help, render_resume_usage,
|
|
||||||
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
|
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
|
||||||
resolve_repl_model, resolve_session_reference, response_to_events,
|
resolve_repl_model, resolve_session_reference, response_to_events,
|
||||||
resume_supported_slash_commands, run_resume_command, short_tool_id,
|
resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||||
slash_command_completion_candidates_with_sessions, status_context,
|
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
|
||||||
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
|
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
|
||||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||||
|
|
@ -10016,8 +10025,8 @@ mod tests {
|
||||||
// with a specific error instead of falling through to the prompt
|
// with a specific error instead of falling through to the prompt
|
||||||
// path (where they surface a misleading "missing Anthropic
|
// path (where they surface a misleading "missing Anthropic
|
||||||
// credentials" error or burn API tokens on an empty prompt).
|
// credentials" error or burn API tokens on an empty prompt).
|
||||||
let empty_err = parse_args(&["".to_string()])
|
let empty_err =
|
||||||
.expect_err("empty positional arg should be rejected");
|
parse_args(&["".to_string()]).expect_err("empty positional arg should be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
empty_err.starts_with("empty prompt:"),
|
empty_err.starts_with("empty prompt:"),
|
||||||
"empty-arg error should be specific, got: {empty_err}"
|
"empty-arg error should be specific, got: {empty_err}"
|
||||||
|
|
@ -10234,7 +10243,8 @@ mod tests {
|
||||||
.expect("write malformed .claw.json");
|
.expect("write malformed .claw.json");
|
||||||
|
|
||||||
let context = with_current_dir(&cwd, || {
|
let context = with_current_dir(&cwd, || {
|
||||||
super::status_context(None).expect("status_context should not hard-fail on config parse errors (#143)")
|
super::status_context(None)
|
||||||
|
.expect("status_context should not hard-fail on config parse errors (#143)")
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 1 contract: config_load_error is populated with the parse error.
|
// Phase 1 contract: config_load_error is populated with the parse error.
|
||||||
|
|
@ -10271,7 +10281,8 @@ mod tests {
|
||||||
cumulative: runtime::TokenUsage::default(),
|
cumulative: runtime::TokenUsage::default(),
|
||||||
estimated_tokens: 0,
|
estimated_tokens: 0,
|
||||||
};
|
};
|
||||||
let json = super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
|
let json =
|
||||||
|
super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json.get("status").and_then(|v| v.as_str()),
|
json.get("status").and_then(|v| v.as_str()),
|
||||||
Some("degraded"),
|
Some("degraded"),
|
||||||
|
|
@ -10288,8 +10299,14 @@ mod tests {
|
||||||
json.get("model").and_then(|v| v.as_str()),
|
json.get("model").and_then(|v| v.as_str()),
|
||||||
Some("test-model")
|
Some("test-model")
|
||||||
);
|
);
|
||||||
assert!(json.get("workspace").is_some(), "workspace field still reported");
|
assert!(
|
||||||
assert!(json.get("sandbox").is_some(), "sandbox field still reported");
|
json.get("workspace").is_some(),
|
||||||
|
"workspace field still reported"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.get("sandbox").is_some(),
|
||||||
|
"sandbox field still reported"
|
||||||
|
);
|
||||||
|
|
||||||
// Clean path: no config error → status: "ok", config_load_error: null.
|
// Clean path: no config error → status: "ok", config_load_error: null.
|
||||||
let clean_cwd = root.join("project-with-clean-config");
|
let clean_cwd = root.join("project-with-clean-config");
|
||||||
|
|
@ -10298,8 +10315,13 @@ mod tests {
|
||||||
super::status_context(None).expect("clean status_context should succeed")
|
super::status_context(None).expect("clean status_context should succeed")
|
||||||
});
|
});
|
||||||
assert!(clean_context.config_load_error.is_none());
|
assert!(clean_context.config_load_error.is_none());
|
||||||
let clean_json =
|
let clean_json = super::status_json_value(
|
||||||
super::status_json_value(Some("test-model"), usage, "workspace-write", &clean_context, None);
|
Some("test-model"),
|
||||||
|
usage,
|
||||||
|
"workspace-write",
|
||||||
|
&clean_context,
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
clean_json.get("status").and_then(|v| v.as_str()),
|
clean_json.get("status").and_then(|v| v.as_str()),
|
||||||
Some("ok"),
|
Some("ok"),
|
||||||
|
|
@ -10398,11 +10420,18 @@ mod tests {
|
||||||
// Other unrecognized args should NOT trigger the --json hint.
|
// Other unrecognized args should NOT trigger the --json hint.
|
||||||
let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()])
|
let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()])
|
||||||
.expect_err("`doctor garbage` should fail without --json hint");
|
.expect_err("`doctor garbage` should fail without --json hint");
|
||||||
assert!(!err_other.contains("--output-format json"),
|
assert!(
|
||||||
"unrelated args should not trigger --json hint: {err_other}");
|
!err_other.contains("--output-format json"),
|
||||||
|
"unrelated args should not trigger --json hint: {err_other}"
|
||||||
|
);
|
||||||
// #154: model syntax error should hint at provider prefix when applicable
|
// #154: model syntax error should hint at provider prefix when applicable
|
||||||
let err_gpt = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "gpt-4".to_string()])
|
let err_gpt = parse_args(&[
|
||||||
.expect_err("`--model gpt-4` should fail with OpenAI hint");
|
"prompt".to_string(),
|
||||||
|
"test".to_string(),
|
||||||
|
"--model".to_string(),
|
||||||
|
"gpt-4".to_string(),
|
||||||
|
])
|
||||||
|
.expect_err("`--model gpt-4` should fail with OpenAI hint");
|
||||||
assert!(
|
assert!(
|
||||||
err_gpt.contains("Did you mean `openai/gpt-4`?"),
|
err_gpt.contains("Did you mean `openai/gpt-4`?"),
|
||||||
"GPT model error should hint openai/ prefix: {err_gpt}"
|
"GPT model error should hint openai/ prefix: {err_gpt}"
|
||||||
|
|
@ -10411,8 +10440,13 @@ mod tests {
|
||||||
err_gpt.contains("OPENAI_API_KEY"),
|
err_gpt.contains("OPENAI_API_KEY"),
|
||||||
"GPT model error should mention env var: {err_gpt}"
|
"GPT model error should mention env var: {err_gpt}"
|
||||||
);
|
);
|
||||||
let err_qwen = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "qwen-plus".to_string()])
|
let err_qwen = parse_args(&[
|
||||||
.expect_err("`--model qwen-plus` should fail with DashScope hint");
|
"prompt".to_string(),
|
||||||
|
"test".to_string(),
|
||||||
|
"--model".to_string(),
|
||||||
|
"qwen-plus".to_string(),
|
||||||
|
])
|
||||||
|
.expect_err("`--model qwen-plus` should fail with DashScope hint");
|
||||||
assert!(
|
assert!(
|
||||||
err_qwen.contains("Did you mean `qwen/qwen-plus`?"),
|
err_qwen.contains("Did you mean `qwen/qwen-plus`?"),
|
||||||
"Qwen model error should hint qwen/ prefix: {err_qwen}"
|
"Qwen model error should hint qwen/ prefix: {err_qwen}"
|
||||||
|
|
@ -10422,8 +10456,13 @@ mod tests {
|
||||||
"Qwen model error should mention env var: {err_qwen}"
|
"Qwen model error should mention env var: {err_qwen}"
|
||||||
);
|
);
|
||||||
// Unrelated invalid model should NOT get a hint
|
// Unrelated invalid model should NOT get a hint
|
||||||
let err_garbage = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "asdfgh".to_string()])
|
let err_garbage = parse_args(&[
|
||||||
.expect_err("`--model asdfgh` should fail");
|
"prompt".to_string(),
|
||||||
|
"test".to_string(),
|
||||||
|
"--model".to_string(),
|
||||||
|
"asdfgh".to_string(),
|
||||||
|
])
|
||||||
|
.expect_err("`--model asdfgh` should fail");
|
||||||
assert!(
|
assert!(
|
||||||
!err_garbage.contains("Did you mean"),
|
!err_garbage.contains("Did you mean"),
|
||||||
"Unrelated model errors should not get a hint: {err_garbage}"
|
"Unrelated model errors should not get a hint: {err_garbage}"
|
||||||
|
|
@ -10433,15 +10472,42 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_error_kind_returns_correct_discriminants() {
|
fn classify_error_kind_returns_correct_discriminants() {
|
||||||
// #77: error kind classification for JSON error payloads
|
// #77: error kind classification for JSON error payloads
|
||||||
assert_eq!(classify_error_kind("missing Anthropic credentials; export ..."), "missing_credentials");
|
assert_eq!(
|
||||||
assert_eq!(classify_error_kind("no worker state file found at /tmp/..."), "missing_worker_state");
|
classify_error_kind("missing Anthropic credentials; export ..."),
|
||||||
assert_eq!(classify_error_kind("session not found: abc123"), "session_not_found");
|
"missing_credentials"
|
||||||
assert_eq!(classify_error_kind("failed to restore session: no managed sessions found"), "session_load_failed");
|
);
|
||||||
assert_eq!(classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse");
|
assert_eq!(
|
||||||
assert_eq!(classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), "invalid_model_syntax");
|
classify_error_kind("no worker state file found at /tmp/..."),
|
||||||
assert_eq!(classify_error_kind("unsupported resumed command: /blargh"), "unsupported_resumed_command");
|
"missing_worker_state"
|
||||||
assert_eq!(classify_error_kind("api failed after 3 attempts: ..."), "api_http_error");
|
);
|
||||||
assert_eq!(classify_error_kind("something completely unknown"), "unknown");
|
assert_eq!(
|
||||||
|
classify_error_kind("session not found: abc123"),
|
||||||
|
"session_not_found"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_error_kind("failed to restore session: no managed sessions found"),
|
||||||
|
"session_load_failed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
|
||||||
|
"cli_parse"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."),
|
||||||
|
"invalid_model_syntax"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_error_kind("unsupported resumed command: /blargh"),
|
||||||
|
"unsupported_resumed_command"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_error_kind("api failed after 3 attempts: ..."),
|
||||||
|
"api_http_error"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_error_kind("something completely unknown"),
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -10912,7 +10978,6 @@ mod tests {
|
||||||
assert!(report.contains("Use /help"));
|
assert!(report.contains("Use /help"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typoed_doctor_subcommand_returns_did_you_mean_error() {
|
fn typoed_doctor_subcommand_returns_did_you_mean_error() {
|
||||||
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
|
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
|
||||||
|
|
@ -10995,7 +11060,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
|
fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
|
||||||
// #140: Guard against test pollution — isolate cwd + env so this test
|
// #140: Guard against test pollution — isolate cwd + env so this test
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,10 @@ stderr:
|
||||||
);
|
);
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
|
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
|
||||||
assert_eq!(parsed["message"], "Mock streaming says hello from the parity harness.");
|
assert_eq!(
|
||||||
|
parsed["message"],
|
||||||
|
"Mock streaming says hello from the parity harness."
|
||||||
|
);
|
||||||
assert_eq!(parsed["compact"], true);
|
assert_eq!(parsed["compact"], true);
|
||||||
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
||||||
assert!(parsed["usage"].is_object());
|
assert!(parsed["usage"].is_object());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue