fix: cover purge command in Vietnamese locale
This commit is contained in:
+16
-12
@@ -286,25 +286,29 @@ max_subagents = 10 # optional (1-20)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Web Search Provider
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Choose which backend `web_search` uses. Default is Bing HTML scraping — no
|
||||
# API key needed. DuckDuckGo remains selectable and still falls back to Bing
|
||||
# when its HTML endpoint returns a bot challenge or no parseable results.
|
||||
# Switch to Tavily or Bocha for reliable search in mainland China.
|
||||
# Choose which backend `web_search` uses. Default is DuckDuckGo HTML scraping
|
||||
# with Bing fallback — no API key needed. Bing remains selectable for users who
|
||||
# explicitly prefer it. Switch to Tavily, Bocha, Metaso, or Baidu for
|
||||
# API-backed search.
|
||||
#
|
||||
# [search]
|
||||
# provider = "bing" # bing | duckduckgo | tavily | bocha | metaso
|
||||
# provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso | baidu
|
||||
# # duckduckgo: HTML scrape with Bing fallback
|
||||
# # tavily: https://tavily.com — AI search, needs api_key
|
||||
# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key
|
||||
# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费
|
||||
# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度
|
||||
# api_key = "tvly-YOUR_KEY" # required for tavily, bocha, and metaso (optional for metaso)
|
||||
# # WARNING: treat config.toml like a secret file when
|
||||
# # storing API keys. Use env vars or `auth set` instead.
|
||||
# # bing: HTML scrape, no API key
|
||||
# # tavily: https://tavily.com — AI search, needs api_key
|
||||
# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key
|
||||
# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费
|
||||
# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度
|
||||
# # baidu: 百度 AI Search via qianfan.baidubce.com,需 api_key
|
||||
# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, and baidu; optional for metaso
|
||||
# # WARNING: treat config.toml like a secret file when
|
||||
# # storing API keys. Prefer env vars for local smoke tests.
|
||||
#
|
||||
# Env-var overrides:
|
||||
# DEEPSEEK_SEARCH_PROVIDER → search.provider
|
||||
# DEEPSEEK_SEARCH_API_KEY → search.api_key
|
||||
# METASO_API_KEY → metaso key fallback
|
||||
# BAIDU_SEARCH_API_KEY → baidu key fallback
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Network Policy (#135)
|
||||
|
||||
@@ -744,6 +744,47 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// `/slop [query|export]` — inspect or export the slop ledger (#2127).
|
||||
/// With no arguments, prints a summary. `query` shows filtered results;
|
||||
/// `export` outputs the full ledger as Markdown.
|
||||
pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let arg = arg.map(str::trim).unwrap_or("");
|
||||
let ledger = match crate::slop_ledger::SlopLedger::load() {
|
||||
Ok(l) => l,
|
||||
Err(e) => return CommandResult::error(format!("Failed to load slop ledger: {e}")),
|
||||
};
|
||||
|
||||
match arg {
|
||||
"" => CommandResult::message(ledger.summary()),
|
||||
"query" | "q" => {
|
||||
if ledger.is_empty() {
|
||||
return CommandResult::message("Slop ledger is empty.");
|
||||
}
|
||||
let mut out = String::new();
|
||||
for entry in &ledger.query(&Default::default()) {
|
||||
use std::fmt::Write;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"[{}] {} ({:?} | {:?}) — {}",
|
||||
crate::slop_ledger::short_id(&entry.id),
|
||||
entry.bucket.as_str(),
|
||||
entry.severity,
|
||||
entry.status,
|
||||
entry.title
|
||||
);
|
||||
}
|
||||
CommandResult::message(out)
|
||||
}
|
||||
"export" | "e" => {
|
||||
let md = ledger.export_markdown(None, None);
|
||||
CommandResult::message(md)
|
||||
}
|
||||
_ => CommandResult::error(format!(
|
||||
"Unknown /slop action '{arg}'. Use /slop, /slop query, or /slop export."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Manage workspace-level trust and the per-path allowlist.
|
||||
///
|
||||
/// Subcommands:
|
||||
|
||||
@@ -552,6 +552,13 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
usage: "/cache [count|inspect|stats|warmup]",
|
||||
description_id: MessageId::CmdCacheDescription,
|
||||
},
|
||||
// Slop Ledger (#2127)
|
||||
CommandInfo {
|
||||
name: "slop",
|
||||
aliases: &["canzha"],
|
||||
usage: "/slop [query|export]",
|
||||
description_id: MessageId::CmdSlopDescription,
|
||||
},
|
||||
];
|
||||
|
||||
/// Execute a slash command
|
||||
@@ -628,6 +635,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"balance" => balance::balance(app),
|
||||
"cache" => debug::cache(app, arg),
|
||||
|
||||
// Slop ledger (#2127)
|
||||
"slop" | "canzha" => config::slop(app, arg),
|
||||
|
||||
// ChangeLog command
|
||||
"change" => change::change(app, arg),
|
||||
"system" | "xitong" => debug::system_prompt(app),
|
||||
|
||||
@@ -684,6 +684,14 @@ pub enum SearchProvider {
|
||||
/// or `METASO_API_KEY` env var; configurable via `[search] api_key`.
|
||||
#[serde(alias = "metaso")]
|
||||
Metaso,
|
||||
/// Baidu AI Search API (<https://qianfan.baidubce.com>). Requires api_key.
|
||||
#[serde(
|
||||
alias = "baidu-search",
|
||||
alias = "baidu_ai_search",
|
||||
alias = "baidu_search",
|
||||
alias = "baidu-ai-search"
|
||||
)]
|
||||
Baidu,
|
||||
}
|
||||
|
||||
impl SearchProvider {
|
||||
@@ -694,6 +702,10 @@ impl SearchProvider {
|
||||
"duckduckgo" | "duck-duck-go" | "duck_duck_go" | "ddg" => Some(Self::DuckDuckGo),
|
||||
"tavily" => Some(Self::Tavily),
|
||||
"bocha" => Some(Self::Bocha),
|
||||
"metaso" => Some(Self::Metaso),
|
||||
"baidu" | "baidu-search" | "baidu_search" | "baidu-ai-search" | "baidu_ai_search" => {
|
||||
Some(Self::Baidu)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -706,6 +718,7 @@ impl SearchProvider {
|
||||
Self::Tavily => "tavily",
|
||||
Self::Bocha => "bocha",
|
||||
Self::Metaso => "metaso",
|
||||
Self::Baidu => "baidu",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -737,11 +750,12 @@ pub struct SearchProviderResolution {
|
||||
/// Web search provider configuration (`[search]` table in config.toml).
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct SearchConfig {
|
||||
/// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso`. Default: `duckduckgo`.
|
||||
/// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso` | `baidu`. Default: `duckduckgo`.
|
||||
#[serde(default)]
|
||||
pub provider: Option<SearchProvider>,
|
||||
/// API key for Tavily, Bocha, or Metaso. Not required for Bing or DuckDuckGo.
|
||||
/// API key for Tavily, Bocha, Metaso, or Baidu. Not required for Bing or DuckDuckGo.
|
||||
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in default.
|
||||
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var.
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
@@ -2912,6 +2926,14 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
|
||||
config.managed_config_path = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SEARCH_API_KEY")
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
config
|
||||
.search
|
||||
.get_or_insert_with(SearchConfig::default)
|
||||
.api_key = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") {
|
||||
config.requirements_path = Some(value);
|
||||
}
|
||||
@@ -4363,6 +4385,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_baidu_search_provider_is_preserved() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[search]
|
||||
provider = "baidu"
|
||||
"#,
|
||||
)
|
||||
.expect("search config");
|
||||
|
||||
assert_eq!(
|
||||
config.search.and_then(|search| search.provider),
|
||||
Some(SearchProvider::Baidu)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baidu_search_provider_aliases_parse() {
|
||||
assert_eq!(SearchProvider::parse("baidu"), Some(SearchProvider::Baidu));
|
||||
assert_eq!(
|
||||
SearchProvider::parse("baidu-search"),
|
||||
Some(SearchProvider::Baidu)
|
||||
);
|
||||
assert_eq!(
|
||||
SearchProvider::parse("baidu_ai_search"),
|
||||
Some(SearchProvider::Baidu)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_provider_resolution_reports_default_source() {
|
||||
let _guard = lock_test_env();
|
||||
@@ -4416,6 +4467,42 @@ mod tests {
|
||||
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_provider_env_override_accepts_baidu() {
|
||||
let _guard = lock_test_env();
|
||||
let prev = env::var_os("DEEPSEEK_SEARCH_PROVIDER");
|
||||
unsafe { env::set_var("DEEPSEEK_SEARCH_PROVIDER", "baidu") };
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[search]
|
||||
provider = "duckduckgo"
|
||||
"#,
|
||||
)
|
||||
.expect("search config");
|
||||
|
||||
let resolution = config.search_provider_resolution();
|
||||
|
||||
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) };
|
||||
assert_eq!(resolution.provider, SearchProvider::Baidu);
|
||||
assert_eq!(resolution.source, SearchProviderSource::EnvOverride);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_sets_search_api_key() {
|
||||
let _guard = lock_test_env();
|
||||
let prev = env::var_os("DEEPSEEK_SEARCH_API_KEY");
|
||||
unsafe { env::set_var("DEEPSEEK_SEARCH_API_KEY", "search-env-key") };
|
||||
let mut config = Config::default();
|
||||
|
||||
apply_env_overrides(&mut config);
|
||||
|
||||
unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_API_KEY", prev) };
|
||||
assert_eq!(
|
||||
config.search.and_then(|search| search.api_key),
|
||||
Some("search-env-key".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_provider_resolution_ignores_invalid_env_override() {
|
||||
let _guard = lock_test_env();
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::StreamExt;
|
||||
@@ -178,8 +178,9 @@ pub struct EngineConfig {
|
||||
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
|
||||
/// Which search backend `web_search` should use. Default: DuckDuckGo.
|
||||
pub search_provider: crate::config::SearchProvider,
|
||||
/// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo.
|
||||
/// API key for Tavily, Bocha, Metaso, or Baidu. `None` for Bing or DuckDuckGo.
|
||||
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key.
|
||||
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY`.
|
||||
pub search_api_key: Option<String>,
|
||||
/// Per-step DeepSeek API timeout for sub-agent `create_message` requests.
|
||||
/// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800)
|
||||
@@ -358,6 +359,10 @@ pub struct Engine {
|
||||
/// Diagnostics collected during the current step's tool calls. Drained
|
||||
/// and forwarded as a synthetic user message before the next API call.
|
||||
pending_lsp_blocks: Vec<crate::lsp::DiagnosticBlock>,
|
||||
/// Cached SlopLedger gate block keyed by the ledger file's modified time.
|
||||
/// This keeps prompt refreshes cheap while still noticing append/update
|
||||
/// writes from slop ledger tools during the same session.
|
||||
slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>,
|
||||
}
|
||||
|
||||
// === Internal tool helpers ===
|
||||
@@ -599,6 +604,7 @@ impl Engine {
|
||||
turn_counter: 0,
|
||||
lsp_manager,
|
||||
pending_lsp_blocks: Vec::new(),
|
||||
slop_ledger_gate_cache: None,
|
||||
workshop_vars,
|
||||
sandbox_backend,
|
||||
};
|
||||
@@ -1987,8 +1993,20 @@ impl Engine {
|
||||
},
|
||||
self.session.approval_mode,
|
||||
);
|
||||
let stable_prompt =
|
||||
let mut stable_prompt =
|
||||
merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone());
|
||||
|
||||
// SlopLedger completion-gate: inject unresolved slop entries into the
|
||||
// system prompt so the agent can autonomously review them before
|
||||
// claiming the task is done (#2127).
|
||||
let gate_block = self.slop_ledger_gate_block();
|
||||
if let Some(ref block) = gate_block {
|
||||
if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt {
|
||||
prompt_text.push_str("\n\n");
|
||||
prompt_text.push_str(block);
|
||||
}
|
||||
}
|
||||
|
||||
let stable_hash = system_prompt_hash(stable_prompt.as_ref());
|
||||
if self.session.system_prompt_override {
|
||||
self.session.last_system_prompt_hash = Some(stable_hash);
|
||||
@@ -2000,6 +2018,31 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
fn slop_ledger_gate_block(&mut self) -> Option<String> {
|
||||
let modified = crate::slop_ledger::SlopLedger::default_path()
|
||||
.ok()
|
||||
.and_then(|path| std::fs::metadata(path).ok())
|
||||
.and_then(|metadata| metadata.modified().ok());
|
||||
|
||||
if let Some((cached_modified, cached_block)) = &self.slop_ledger_gate_cache
|
||||
&& *cached_modified == modified
|
||||
{
|
||||
return cached_block.clone();
|
||||
}
|
||||
|
||||
let loaded = crate::slop_ledger::SlopLedger::load()
|
||||
.ok()
|
||||
.and_then(|ledger| {
|
||||
if ledger.has_open_entries() {
|
||||
ledger.completion_gate_summary()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.slop_ledger_gate_cache = Some((modified, loaded.clone()));
|
||||
loaded
|
||||
}
|
||||
|
||||
fn merge_compaction_summary(&mut self, summary_prompt: Option<SystemPrompt>) {
|
||||
if summary_prompt.is_none() {
|
||||
return;
|
||||
|
||||
@@ -67,6 +67,14 @@ impl Engine {
|
||||
.with_parallel_tool()
|
||||
.with_recall_archive_tool();
|
||||
|
||||
// SlopLedger: plan mode only gets read-only query + export,
|
||||
// agent/yolo get the full set including append + update.
|
||||
builder = if mode == AppMode::Plan {
|
||||
builder.with_slop_ledger_read_only_tools()
|
||||
} else {
|
||||
builder.with_slop_ledger_tools()
|
||||
};
|
||||
|
||||
if mode != AppMode::Plan {
|
||||
builder = builder
|
||||
.with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone())
|
||||
|
||||
@@ -304,6 +304,7 @@ pub enum MessageId {
|
||||
CmdSettingsDescription,
|
||||
CmdSkillDescription,
|
||||
CmdSkillsDescription,
|
||||
CmdSlopDescription,
|
||||
CmdStashDescription,
|
||||
CmdStatusDescription,
|
||||
CmdStatuslineDescription,
|
||||
@@ -565,6 +566,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdSettingsDescription,
|
||||
MessageId::CmdSkillDescription,
|
||||
MessageId::CmdSkillsDescription,
|
||||
MessageId::CmdSlopDescription,
|
||||
MessageId::CmdStashDescription,
|
||||
MessageId::CmdStatusDescription,
|
||||
MessageId::CmdStatuslineDescription,
|
||||
@@ -1051,6 +1053,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"List local skills (filter by `/skills <prefix>`; --remote browses the curated registry)"
|
||||
}
|
||||
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
|
||||
MessageId::CmdStashDescription => {
|
||||
"Park or restore a composer draft (Ctrl+S to push, /stash list/pop)"
|
||||
}
|
||||
@@ -1388,6 +1391,9 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Kích hoạt nén ngữ cảnh để giải phóng không gian (cũ; v0.6.6 ưu tiên khởi động lại chu kỳ)"
|
||||
}
|
||||
MessageId::CmdPurgeDescription => {
|
||||
"Cho agent cắt gọn lịch sử trò chuyện để giải phóng ngữ cảnh"
|
||||
}
|
||||
MessageId::CmdConfigDescription => "Mở trình chỉnh sửa cấu hình tương tác",
|
||||
MessageId::CmdContextDescription => "Mở trình kiểm tra ngữ cảnh phiên thu gọn",
|
||||
MessageId::CmdCostDescription => "Hiển thị chi tiết chi phí của phiên làm việc",
|
||||
@@ -1462,6 +1468,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"Liệt kê các kỹ năng cục bộ (lọc bằng `/skills <tiền_tố>`; --remote để duyệt kho lưu trữ được kiểm duyệt)"
|
||||
}
|
||||
MessageId::CmdSlopDescription => "Kiểm tra hoặc xuất SlopLedger",
|
||||
MessageId::CmdStashDescription => {
|
||||
"Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)"
|
||||
}
|
||||
@@ -1888,6 +1895,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"ローカルスキルを一覧表示(`/skills <prefix>` で絞り込み、--remote で精選レジストリを参照)"
|
||||
}
|
||||
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
|
||||
MessageId::CmdStashDescription => {
|
||||
"コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)"
|
||||
}
|
||||
@@ -2257,6 +2265,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"列出本地技能(用 `/skills <prefix>` 按名称前缀过滤,--remote 浏览精选注册表)"
|
||||
}
|
||||
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
|
||||
MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)",
|
||||
MessageId::CmdStatusDescription => "显示当前运行状态",
|
||||
MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目",
|
||||
@@ -2624,6 +2633,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"Listar skills locais (filtre com `/skills <prefixo>`; --remote navega pelo registro curado)"
|
||||
}
|
||||
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
|
||||
MessageId::CmdStashDescription => {
|
||||
"Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)"
|
||||
}
|
||||
@@ -3045,6 +3055,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"Listar skills locales (filtra con `/skills <prefijo>`; --remote navega el registro curado)"
|
||||
}
|
||||
MessageId::CmdSlopDescription => "Inspect or export the SlopLedger",
|
||||
MessageId::CmdStashDescription => {
|
||||
"Estacionar o restaurar borrador del compositor (Ctrl+S estaciona, /stash list|pop)"
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ mod settings;
|
||||
mod shell_dispatcher;
|
||||
mod skill_state;
|
||||
mod skills;
|
||||
mod slop_ledger;
|
||||
mod snapshot;
|
||||
mod task_manager;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
use std::fs;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -40,7 +40,6 @@ use crate::runtime_threads::{
|
||||
};
|
||||
use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir};
|
||||
use crate::skill_state::SkillStateStore;
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::task_manager::{
|
||||
NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary,
|
||||
};
|
||||
@@ -261,11 +260,13 @@ struct SkillEntry {
|
||||
description: String,
|
||||
path: PathBuf,
|
||||
enabled: bool,
|
||||
is_bundled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillsResponse {
|
||||
directory: PathBuf,
|
||||
directories: Vec<PathBuf>,
|
||||
warnings: Vec<String>,
|
||||
skills: Vec<SkillEntry>,
|
||||
}
|
||||
@@ -960,7 +961,7 @@ async fn list_skills(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<SkillsResponse>, ApiError> {
|
||||
let skills_dir = resolve_skills_dir(&state.config, &state.workspace);
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir);
|
||||
let skill_state = state.skill_state.lock().await;
|
||||
let skills = registry
|
||||
.list()
|
||||
@@ -968,12 +969,14 @@ async fn list_skills(
|
||||
.map(|skill| SkillEntry {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skills_dir.join(&skill.name).join("SKILL.md"),
|
||||
path: skill.path.clone(),
|
||||
enabled: skill_state.is_enabled(&skill.name),
|
||||
is_bundled: skill_entry_is_bundled(skill, &skills_dir),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(SkillsResponse {
|
||||
directory: skills_dir,
|
||||
directories,
|
||||
warnings: registry.warnings().to_vec(),
|
||||
skills,
|
||||
}))
|
||||
@@ -985,12 +988,12 @@ async fn set_skill_enabled(
|
||||
Json(req): Json<SetSkillEnabledRequest>,
|
||||
) -> Result<Json<SetSkillEnabledResponse>, ApiError> {
|
||||
let skills_dir = resolve_skills_dir(&state.config, &state.workspace);
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir);
|
||||
let exists = registry.list().iter().any(|skill| skill.name == name);
|
||||
if !exists {
|
||||
return Err(ApiError::not_found(format!(
|
||||
"skill '{name}' not found under {}",
|
||||
skills_dir.display()
|
||||
"skill '{name}' not found in searched directories: {}",
|
||||
format_skill_search_paths(&directories)
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1766,6 +1769,50 @@ fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
|
||||
config.skills_dir()
|
||||
}
|
||||
|
||||
fn skills_search_directories(workspace: &FsPath, skills_dir: &FsPath) -> Vec<PathBuf> {
|
||||
let mut directories = crate::skills::skills_directories(workspace);
|
||||
if skills_dir.is_dir() && !directories.iter().any(|path| path == skills_dir) {
|
||||
directories.push(skills_dir.to_path_buf());
|
||||
}
|
||||
directories
|
||||
}
|
||||
|
||||
fn discover_skills_for_runtime_api(
|
||||
workspace: &FsPath,
|
||||
skills_dir: &FsPath,
|
||||
) -> (crate::skills::SkillRegistry, Vec<PathBuf>) {
|
||||
let directories = skills_search_directories(workspace, skills_dir);
|
||||
let registry = crate::skills::discover_from_directories(directories.clone());
|
||||
(registry, directories)
|
||||
}
|
||||
|
||||
fn skill_entry_is_bundled(skill: &crate::skills::Skill, skills_dir: &FsPath) -> bool {
|
||||
if !crate::skills::is_bundled_skill_name(&skill.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_path = skills_dir.join(&skill.name).join("SKILL.md");
|
||||
paths_refer_to_same_file(&skill.path, &expected_path)
|
||||
}
|
||||
|
||||
fn paths_refer_to_same_file(left: &FsPath, right: &FsPath) -> bool {
|
||||
match (fs::canonicalize(left), fs::canonicalize(right)) {
|
||||
(Ok(left), Ok(right)) => left == right,
|
||||
_ => left == right,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_skill_search_paths(directories: &[PathBuf]) -> String {
|
||||
if directories.is_empty() {
|
||||
return "<none>".to_string();
|
||||
}
|
||||
directories
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn load_mcp_config_or_default(path: &std::path::Path) -> Result<McpConfig, ApiError> {
|
||||
crate::mcp::load_config(path)
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}")))
|
||||
@@ -3885,6 +3932,71 @@ mod tests {
|
||||
assert_eq!(resolved, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_search_directories_includes_custom_skills_dir() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let custom_skills = tmp.path().join("custom-skills");
|
||||
fs::create_dir_all(&workspace).expect("create workspace");
|
||||
fs::create_dir_all(&custom_skills).expect("create custom skills");
|
||||
|
||||
let directories = skills_search_directories(&workspace, &custom_skills);
|
||||
|
||||
assert!(
|
||||
directories.iter().any(|dir| dir == &custom_skills),
|
||||
"custom skills_dir must be reported when discovery searches it"
|
||||
);
|
||||
let message = format_skill_search_paths(&directories);
|
||||
assert!(message.contains("custom-skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_entry_is_bundled_requires_configured_bundle_path() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let bundled_skills_dir = tmp.path().join("bundled-skills");
|
||||
let bundled_skill_path = bundled_skills_dir.join("delegate").join("SKILL.md");
|
||||
let override_skill_path = tmp
|
||||
.path()
|
||||
.join("workspace")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("delegate")
|
||||
.join("SKILL.md");
|
||||
fs::create_dir_all(bundled_skill_path.parent().expect("bundled parent"))
|
||||
.expect("create bundled skill dir");
|
||||
fs::create_dir_all(override_skill_path.parent().expect("override parent"))
|
||||
.expect("create override skill dir");
|
||||
fs::write(
|
||||
&bundled_skill_path,
|
||||
"---\nname: delegate\ndescription: bundled\n---\n",
|
||||
)
|
||||
.expect("write bundled skill");
|
||||
fs::write(
|
||||
&override_skill_path,
|
||||
"---\nname: delegate\ndescription: override\n---\n",
|
||||
)
|
||||
.expect("write override skill");
|
||||
|
||||
let bundled_skill = crate::skills::Skill {
|
||||
name: "delegate".to_string(),
|
||||
description: String::new(),
|
||||
body: String::new(),
|
||||
path: bundled_skill_path,
|
||||
};
|
||||
let override_skill = crate::skills::Skill {
|
||||
name: "delegate".to_string(),
|
||||
description: String::new(),
|
||||
body: String::new(),
|
||||
path: override_skill_path,
|
||||
};
|
||||
|
||||
assert!(skill_entry_is_bundled(&bundled_skill, &bundled_skills_dir));
|
||||
assert!(!skill_entry_is_bundled(
|
||||
&override_skill,
|
||||
&bundled_skills_dir
|
||||
));
|
||||
}
|
||||
|
||||
/// A `skills` symlink that points outside the workspace must NOT be
|
||||
/// returned as the resolved skills directory. Containment check ensures
|
||||
/// the canonicalized candidate stays under the canonicalized workspace
|
||||
|
||||
@@ -580,6 +580,10 @@ fn discover_for_workspace_dirs_and_dir(mut dirs: Vec<PathBuf>, skills_dir: &Path
|
||||
dirs.push(skills_dir.to_path_buf());
|
||||
}
|
||||
|
||||
discover_from_directories(dirs)
|
||||
}
|
||||
|
||||
pub(crate) fn discover_from_directories(dirs: impl IntoIterator<Item = PathBuf>) -> SkillRegistry {
|
||||
let mut merged = SkillRegistry::default();
|
||||
for dir in dirs {
|
||||
let registry = SkillRegistry::discover(&dir);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -733,6 +733,30 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(RememberTool))
|
||||
}
|
||||
|
||||
/// Include the slop ledger tools (#2127) — durable tracking of
|
||||
/// unresolved architectural residue: append, query, update, export.
|
||||
/// Registered unconditionally; the ledger JSON file is auto-created
|
||||
/// on first append.
|
||||
#[must_use]
|
||||
pub fn with_slop_ledger_tools(self) -> Self {
|
||||
use crate::slop_ledger::{
|
||||
SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, SlopLedgerUpdateTool,
|
||||
};
|
||||
self.with_tool(Arc::new(SlopLedgerAppendTool))
|
||||
.with_tool(Arc::new(SlopLedgerQueryTool))
|
||||
.with_tool(Arc::new(SlopLedgerUpdateTool))
|
||||
.with_tool(Arc::new(SlopLedgerExportTool))
|
||||
}
|
||||
|
||||
/// Read-only subset of slop ledger tools (#2127) for plan mode:
|
||||
/// only query and export — no append or update.
|
||||
#[must_use]
|
||||
pub fn with_slop_ledger_read_only_tools(self) -> Self {
|
||||
use crate::slop_ledger::{SlopLedgerExportTool, SlopLedgerQueryTool};
|
||||
self.with_tool(Arc::new(SlopLedgerQueryTool))
|
||||
.with_tool(Arc::new(SlopLedgerExportTool))
|
||||
}
|
||||
|
||||
/// Include the `notify` tool — model-callable desktop notification
|
||||
/// (#1322). Routes through the existing `tui::notifications` OSC 9 /
|
||||
/// BEL pipeline so the user's `[notifications].method` config is
|
||||
|
||||
@@ -165,8 +165,9 @@ pub struct ToolContext {
|
||||
/// Which search backend `web_search` should use. Default: DuckDuckGo. Set via
|
||||
/// `[search] provider` in config.toml.
|
||||
pub search_provider: crate::config::SearchProvider,
|
||||
/// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo.
|
||||
/// API key for Tavily, Bocha, Metaso, or Baidu. `None` for Bing or DuckDuckGo.
|
||||
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key.
|
||||
/// Baidu also falls back to `BAIDU_SEARCH_API_KEY`.
|
||||
pub search_api_key: Option<String>,
|
||||
|
||||
/// Per-session workshop variable store (#548). Holds the raw content of
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Web search tool backed by multiple providers: Bing HTML scrape, DuckDuckGo
|
||||
//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API, and
|
||||
//! Metaso API (<https://metaso.cn>).
|
||||
//! (HTML scrape with Bing fallback), Tavily API, Bocha (博查) API,
|
||||
//! Metaso API (<https://metaso.cn>), and Baidu AI Search.
|
||||
//!
|
||||
//! This is the primary web search surface for agents. For browsing workflows
|
||||
//! (page open, click, screenshot) use a direct URL approach instead.
|
||||
//!
|
||||
//! Set `[search]` in config.toml to switch providers:
|
||||
//! provider = "duckduckgo" # or tavily/bocha/metaso
|
||||
//! provider = "duckduckgo" # or tavily/bocha/metaso/baidu
|
||||
//! api_key = "tvly-..."
|
||||
|
||||
use super::spec::{
|
||||
@@ -27,6 +27,7 @@ const BING_HOST: &str = "www.bing.com";
|
||||
const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search";
|
||||
const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/ai/search";
|
||||
const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1";
|
||||
const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_search";
|
||||
/// Intentionally public default key provided by Metaso for open-source/community use.
|
||||
/// Last-resort fallback after config and env var. Rate-limited to ~100 searches/day.
|
||||
const METASO_DEFAULT_API_KEY: &str = "mk-E384C1DD5E8501BB7EFE27C949AFDE5B";
|
||||
@@ -57,6 +58,7 @@ static TAG_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static BEARER_TOKEN_RE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
fn get_title_re() -> &'static Regex {
|
||||
TITLE_RE.get_or_init(|| {
|
||||
@@ -99,6 +101,13 @@ fn get_bing_snippet_re() -> &'static Regex {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_bearer_token_re() -> &'static Regex {
|
||||
BEARER_TOKEN_RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
|
||||
.expect("bearer token regex pattern is valid")
|
||||
})
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESULTS: usize = 5;
|
||||
const MAX_RESULTS: usize = 10;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
|
||||
@@ -129,7 +138,7 @@ impl ToolSpec for WebSearchTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly."
|
||||
"Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"baidu\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -210,6 +219,13 @@ impl ToolSpec for WebSearchTool {
|
||||
.run_metaso_search(&query, max_results, timeout_ms, context)
|
||||
.await;
|
||||
}
|
||||
SearchProvider::Baidu => {
|
||||
let decider = context.network_policy.as_ref();
|
||||
check_policy(decider, "qianfan.baidubce.com")?;
|
||||
return self
|
||||
.run_baidu_search(&query, max_results, timeout_ms, context)
|
||||
.await;
|
||||
}
|
||||
SearchProvider::Bing | SearchProvider::DuckDuckGo => {}
|
||||
}
|
||||
|
||||
@@ -645,6 +661,73 @@ impl WebSearchTool {
|
||||
|
||||
search_tool_result(query.to_string(), "metaso", results, None)
|
||||
}
|
||||
|
||||
/// Search via Baidu AI Search API (<https://qianfan.baidubce.com>).
|
||||
async fn run_baidu_search(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
timeout_ms: u64,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let env_key = std::env::var("BAIDU_SEARCH_API_KEY").ok();
|
||||
let api_key = context
|
||||
.search_api_key
|
||||
.as_deref()
|
||||
.or(env_key.as_deref())
|
||||
.ok_or_else(|| {
|
||||
ToolError::execution_failed(
|
||||
"Baidu search requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
|
||||
let payload = baidu_search_payload(query, max_results);
|
||||
|
||||
let resp = client
|
||||
.post(BAIDU_ENDPOINT)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Baidu search request failed: {e}"))
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to read Baidu response: {e}"))
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = match status.as_u16() {
|
||||
401 | 403 => "Baidu search API key rejected — check BAIDU_SEARCH_API_KEY or `[search] api_key` in config.toml".to_string(),
|
||||
429 => "Baidu search rate-limited — wait and retry, or check your Baidu AI Search quota".to_string(),
|
||||
_ => {
|
||||
let truncated = truncate_error_body(&body);
|
||||
format!("Baidu search failed: HTTP {} — {truncated}", status.as_u16())
|
||||
}
|
||||
};
|
||||
return Err(ToolError::execution_failed(msg));
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to parse Baidu response: {e}"))
|
||||
})?;
|
||||
|
||||
if let Some(error) = baidu_error_message(&parsed) {
|
||||
return Err(ToolError::execution_failed(error));
|
||||
}
|
||||
|
||||
let results = parse_baidu_results(&parsed, max_results);
|
||||
search_tool_result(query.to_string(), "baidu", results, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_error_body(body: &str) -> String {
|
||||
@@ -662,12 +745,87 @@ fn truncate_error_body(body: &str) -> String {
|
||||
|
||||
fn sanitize_error_body(body: &str) -> String {
|
||||
let stripped = strip_html_tags(body);
|
||||
stripped
|
||||
let visible: String = stripped
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || c.is_ascii_whitespace())
|
||||
.collect();
|
||||
get_bearer_token_re()
|
||||
.replace_all(&visible, "Bearer [REDACTED]")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_baidu_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
|
||||
parsed
|
||||
.get("references")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flat_map(|arr| arr.iter())
|
||||
.filter_map(|item| {
|
||||
let title = item
|
||||
.get("title")
|
||||
.or_else(|| item.get("name"))
|
||||
.and_then(|s| s.as_str())?
|
||||
.trim();
|
||||
let url = item
|
||||
.get("url")
|
||||
.or_else(|| item.get("link"))
|
||||
.and_then(|s| s.as_str())?
|
||||
.trim();
|
||||
if title.is_empty() || url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let snippet = item
|
||||
.get("content")
|
||||
.or_else(|| item.get("snippet"))
|
||||
.or_else(|| item.get("summary"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string);
|
||||
Some(WebSearchEntry {
|
||||
title: title.to_string(),
|
||||
url: url.to_string(),
|
||||
snippet,
|
||||
})
|
||||
})
|
||||
.take(max_results)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn baidu_error_message(parsed: &Value) -> Option<String> {
|
||||
let code = parsed
|
||||
.get("error_code")
|
||||
.or_else(|| parsed.get("code"))
|
||||
.and_then(|v| v.as_i64())?;
|
||||
if code == 0 {
|
||||
return None;
|
||||
}
|
||||
let message = parsed
|
||||
.get("error_msg")
|
||||
.or_else(|| parsed.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
Some(format!("Baidu search API error (code {code}: {message})"))
|
||||
}
|
||||
|
||||
fn baidu_search_payload(query: &str, max_results: usize) -> Value {
|
||||
json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": query,
|
||||
}
|
||||
],
|
||||
"search_source": "baidu_search_v2",
|
||||
"resource_type_filter": [
|
||||
{
|
||||
"type": "web",
|
||||
"top_k": max_results,
|
||||
}
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_search_query(input: &Value) -> Result<String, ToolError> {
|
||||
for key in ["query", "q"] {
|
||||
if let Some(value) = input.get(key) {
|
||||
@@ -1034,9 +1192,10 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, decode_html_entities,
|
||||
extract_search_query, is_likely_spam_results, normalize_bing_url,
|
||||
optional_search_max_results, root_domain, sanitize_error_body, truncate_error_body,
|
||||
ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, baidu_search_payload,
|
||||
decode_html_entities, extract_search_query, is_likely_spam_results, normalize_bing_url,
|
||||
optional_search_max_results, parse_baidu_results, root_domain, sanitize_error_body,
|
||||
truncate_error_body,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -1314,6 +1473,96 @@ mod tests {
|
||||
assert_eq!(sanitized, "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_error_body_redacts_bearer_tokens() {
|
||||
let body = r#"{"error":"bad token","authorization":"Bearer test-token/with+chars="}"#;
|
||||
|
||||
let sanitized = sanitize_error_body(body);
|
||||
|
||||
assert!(!sanitized.contains("test-token/with+chars="));
|
||||
assert!(sanitized.contains("Bearer [REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_baidu_references_extracts_ranked_results() {
|
||||
let body = json!({
|
||||
"references": [
|
||||
{
|
||||
"title": "Rust 官方文档",
|
||||
"url": "https://www.rust-lang.org/",
|
||||
"content": "Rust 是一门注重性能和可靠性的语言。"
|
||||
},
|
||||
{
|
||||
"title": "Cargo Book",
|
||||
"url": "https://doc.rust-lang.org/cargo/",
|
||||
"snippet": "Cargo is Rust's package manager."
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let results = parse_baidu_results(&body, 10);
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].title, "Rust 官方文档");
|
||||
assert_eq!(results[0].url, "https://www.rust-lang.org/");
|
||||
assert_eq!(
|
||||
results[0].snippet.as_deref(),
|
||||
Some("Rust 是一门注重性能和可靠性的语言。")
|
||||
);
|
||||
assert_eq!(results[1].title, "Cargo Book");
|
||||
assert_eq!(results[1].url, "https://doc.rust-lang.org/cargo/");
|
||||
assert_eq!(
|
||||
results[1].snippet.as_deref(),
|
||||
Some("Cargo is Rust's package manager.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_baidu_references_skips_incomplete_entries() {
|
||||
let body = json!({
|
||||
"references": [
|
||||
{"title": "No URL", "content": "missing url"},
|
||||
{"url": "https://example.com/no-title", "content": "missing title"},
|
||||
{"title": "Valid", "url": "https://example.com/valid"}
|
||||
]
|
||||
});
|
||||
|
||||
let results = parse_baidu_results(&body, 10);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title, "Valid");
|
||||
assert_eq!(results[0].url, "https://example.com/valid");
|
||||
assert_eq!(results[0].snippet, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baidu_search_payload_uses_official_search_source() {
|
||||
let payload = baidu_search_payload("Rust cargo workspace", 3);
|
||||
|
||||
assert_eq!(
|
||||
payload.get("search_source").and_then(|v| v.as_str()),
|
||||
Some("baidu_search_v2")
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|messages| messages.first())
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("Rust cargo workspace")
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("resource_type_filter")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|filters| filters.first())
|
||||
.and_then(|filter| filter.get("top_k"))
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tavily_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
|
||||
// Trust-boundary pin: if a user has opted into Tavily but
|
||||
@@ -1360,6 +1609,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn baidu_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
|
||||
use crate::config::SearchProvider;
|
||||
use crate::tools::spec::{ToolContext, ToolSpec};
|
||||
|
||||
let prev = std::env::var_os("BAIDU_SEARCH_API_KEY");
|
||||
unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") };
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
ctx.search_provider = SearchProvider::Baidu;
|
||||
ctx.search_api_key = None;
|
||||
let err = WebSearchTool
|
||||
.execute(json!({"query": "anything"}), &ctx)
|
||||
.await
|
||||
.expect_err("missing api_key must surface as ToolError");
|
||||
|
||||
match prev {
|
||||
Some(value) => unsafe { std::env::set_var("BAIDU_SEARCH_API_KEY", value) },
|
||||
None => unsafe { std::env::remove_var("BAIDU_SEARCH_API_KEY") },
|
||||
}
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Baidu") && msg.contains("API key"),
|
||||
"error must name the provider and missing key; got `{msg}`"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metaso_provider_uses_built_in_key_when_no_config_key_set() {
|
||||
// Unlike Tavily/Bocha, Metaso falls back to a built-in default, so
|
||||
|
||||
@@ -71,6 +71,7 @@ mod ui_text;
|
||||
pub mod user_input;
|
||||
pub mod views;
|
||||
pub mod vim_mode;
|
||||
pub mod whale_routes;
|
||||
pub mod widgets;
|
||||
pub mod workspace_context;
|
||||
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier
|
||||
//! and apply both at once (#39).
|
||||
//! `/model` picker modal: pick a model and thinking-effort tier (#39, #2026).
|
||||
//!
|
||||
//! Two side-by-side panes — Models on the left, Thinking effort on the
|
||||
//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies
|
||||
//! both and closes the modal. Esc applies the last-highlighted choice and
|
||||
//! closes.
|
||||
//! For DeepSeek providers the picker shows whale-sized routes — model + effort
|
||||
//! combinations sorted largest → fastest with friendly whale-species labels
|
||||
//! (Blue Whale, Fin Whale, …, Beluga). A single ↑/↓ selection sets both
|
||||
//! model and effort at once. The "auto" option is always available; custom
|
||||
//! (unrecognised) model ids appear as a separate row.
|
||||
//!
|
||||
//! The effort pane intentionally only exposes `Off / High / Max`. Per
|
||||
//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
|
||||
//! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is
|
||||
//! mapped to `max`, so surfacing them as separate choices would be misleading.
|
||||
//! The legacy variants remain valid in `~/.deepseek/settings.toml` for
|
||||
//! back-compat — the picker just doesn't offer them.
|
||||
//! For pass-through providers the picker falls back to the classic two-column
|
||||
//! layout (Models | Thinking), with no whale labelling.
|
||||
//!
|
||||
//! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
|
||||
//! model id and effort tier; the UI handler updates `App` state, persists
|
||||
//! the choice via `Settings`, and forwards `Op::SetModel` so the running
|
||||
//! engine picks up the change without a restart.
|
||||
//! model id and effort tier.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
@@ -30,6 +24,7 @@ use ratatui::{
|
||||
use crate::palette;
|
||||
use crate::tui::app::{App, ReasoningEffort};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
use crate::tui::whale_routes::WHALE_ROUTES;
|
||||
|
||||
/// Models the picker exposes by default. Kept short on purpose — power
|
||||
/// users can still type `/model <id>` for anything else.
|
||||
@@ -69,12 +64,21 @@ pub struct ModelPickerView {
|
||||
/// When true, hide DeepSeek-specific model rows (pass-through providers
|
||||
/// like openai don't support them).
|
||||
hide_deepseek_models: bool,
|
||||
/// When true, show whale-sized routes instead of two-column model/effort.
|
||||
show_whale_routes: bool,
|
||||
/// Selected whale-route index (when show_whale_routes is true).
|
||||
selected_route_idx: usize,
|
||||
}
|
||||
|
||||
impl ModelPickerView {
|
||||
#[must_use]
|
||||
pub fn new(app: &App) -> Self {
|
||||
let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider);
|
||||
// Whale routes are DeepSeek-specific — only official providers get them.
|
||||
let show_whale_routes = matches!(
|
||||
app.api_provider,
|
||||
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
|
||||
);
|
||||
let initial_model = if app.auto_model {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
@@ -104,6 +108,34 @@ impl ModelPickerView {
|
||||
.position(|e| *e == normalized)
|
||||
.unwrap_or(2); // default to High if somehow unknown
|
||||
|
||||
// When showing whale routes, find the matching route by position in the array
|
||||
// (not by sort_order, which happens to match today but is semantically wrong).
|
||||
let (selected_route_idx, show_custom_model_row) = if show_whale_routes {
|
||||
let idx = WHALE_ROUTES
|
||||
.iter()
|
||||
.position(|r| {
|
||||
r.model.eq_ignore_ascii_case(&initial_model) && r.effort == normalized
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
// No matching whale route — key the fallback on whether the
|
||||
// current model is actually "auto", not on show_custom_model_row.
|
||||
// Otherwise a known DeepSeek model (e.g. v4-pro) paired with
|
||||
// ReasoningEffort::Auto silently falls through to the "auto" row
|
||||
// and replaces the explicit model on apply.
|
||||
if initial_model.eq_ignore_ascii_case("auto") {
|
||||
WHALE_ROUTES.len() // "auto" row
|
||||
} else {
|
||||
WHALE_ROUTES.len() + 1 // custom model row
|
||||
}
|
||||
});
|
||||
// When the whale-route fallback selected the custom row, ensure it is
|
||||
// visible so the user can see their current model in the picker.
|
||||
let show_custom = show_custom_model_row || idx == WHALE_ROUTES.len() + 1;
|
||||
(idx, show_custom)
|
||||
} else {
|
||||
(0, show_custom_model_row)
|
||||
};
|
||||
|
||||
Self {
|
||||
initial_model,
|
||||
initial_effort,
|
||||
@@ -113,6 +145,8 @@ impl ModelPickerView {
|
||||
selection_touched: false,
|
||||
show_custom_model_row,
|
||||
hide_deepseek_models,
|
||||
show_whale_routes,
|
||||
selected_route_idx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +162,11 @@ impl ModelPickerView {
|
||||
self.visible_model_ids().len() + if self.show_custom_model_row { 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// Resolve the currently highlighted model row to a model id. If the
|
||||
/// custom row is selected we return the original model from the App so
|
||||
/// "Apply" doesn't blow away an unrecognised id.
|
||||
/// Resolve the currently highlighted row to a model id.
|
||||
fn resolved_model(&self) -> String {
|
||||
if self.show_whale_routes {
|
||||
return self.resolved_whale_model();
|
||||
}
|
||||
let visible = self.visible_model_ids();
|
||||
if self.show_custom_model_row && self.selected_model_idx == visible.len() {
|
||||
self.initial_model.clone()
|
||||
@@ -143,13 +178,59 @@ impl ModelPickerView {
|
||||
}
|
||||
|
||||
fn resolved_effort(&self) -> ReasoningEffort {
|
||||
if self.show_whale_routes {
|
||||
return self.resolved_whale_effort();
|
||||
}
|
||||
if self.resolved_model().trim().eq_ignore_ascii_case("auto") {
|
||||
return ReasoningEffort::Auto;
|
||||
}
|
||||
PICKER_EFFORTS[self.selected_effort_idx]
|
||||
}
|
||||
|
||||
/// Resolve model from the whale-route list.
|
||||
fn resolved_whale_model(&self) -> String {
|
||||
if self.selected_route_idx < WHALE_ROUTES.len() {
|
||||
WHALE_ROUTES[self.selected_route_idx].model.to_string()
|
||||
} else if self.selected_route_idx == WHALE_ROUTES.len() {
|
||||
// First fallback row: always "auto".
|
||||
"auto".to_string()
|
||||
} else {
|
||||
// Second fallback row: custom model.
|
||||
self.initial_model.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve effort from the whale-route list.
|
||||
fn resolved_whale_effort(&self) -> ReasoningEffort {
|
||||
if self.selected_route_idx < WHALE_ROUTES.len() {
|
||||
WHALE_ROUTES[self.selected_route_idx].effort
|
||||
} else if self.selected_route_idx == WHALE_ROUTES.len() {
|
||||
// First fallback row: "auto".
|
||||
ReasoningEffort::Auto
|
||||
} else {
|
||||
// Second fallback row: custom model — keep the initial effort.
|
||||
self.initial_effort
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of rows in the whale-route list.
|
||||
fn whale_route_row_count(&self) -> usize {
|
||||
let base = WHALE_ROUTES.len() + 1; // routes + auto
|
||||
if self.show_custom_model_row {
|
||||
base + 1
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn move_up(&mut self) -> bool {
|
||||
if self.show_whale_routes {
|
||||
if self.selected_route_idx > 0 {
|
||||
self.selected_route_idx -= 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
match self.focus {
|
||||
Pane::Model => {
|
||||
if self.selected_model_idx > 0 {
|
||||
@@ -168,6 +249,14 @@ impl ModelPickerView {
|
||||
}
|
||||
|
||||
fn move_down(&mut self) -> bool {
|
||||
if self.show_whale_routes {
|
||||
let max = self.whale_route_row_count().saturating_sub(1);
|
||||
if self.selected_route_idx < max {
|
||||
self.selected_route_idx += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
match self.focus {
|
||||
Pane::Model => {
|
||||
let max = self.model_row_count().saturating_sub(1);
|
||||
@@ -285,7 +374,9 @@ impl ModalView for ModelPickerView {
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.toggle_focus();
|
||||
if !self.show_whale_routes {
|
||||
self.toggle_focus();
|
||||
}
|
||||
ViewAction::None
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
@@ -293,6 +384,88 @@ impl ModalView for ModelPickerView {
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if self.show_whale_routes {
|
||||
self.render_whale_routes(area, buf);
|
||||
} else {
|
||||
self.render_classic(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelPickerView {
|
||||
/// Single-column whale-route list for DeepSeek providers.
|
||||
fn render_whale_routes(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 62.min(area.width.saturating_sub(4)).max(44);
|
||||
let row_count = self.whale_route_row_count();
|
||||
let popup_height = (row_count as u16 + 4)
|
||||
.min(area.height.saturating_sub(4))
|
||||
.max(8);
|
||||
let popup_area = Rect {
|
||||
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
|
||||
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
|
||||
width: popup_width,
|
||||
height: popup_height,
|
||||
};
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
let outer = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
" Whale Routes ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.title_bottom(Line::from(vec![
|
||||
Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("choose "),
|
||||
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("apply "),
|
||||
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("apply "),
|
||||
]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default());
|
||||
let inner = outer.inner(popup_area);
|
||||
outer.render(popup_area, buf);
|
||||
|
||||
let mut rows: Vec<(String, String)> = WHALE_ROUTES
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
format!("{} — {}", r.label, r.hint),
|
||||
r.description.to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fallback row 1: always "auto".
|
||||
rows.push((
|
||||
"auto — select per turn".to_string(),
|
||||
"Let CodeWhale pick the best model each turn".to_string(),
|
||||
));
|
||||
|
||||
// Fallback row 2: custom model when the current model isn't recognized.
|
||||
if self.show_custom_model_row {
|
||||
rows.push((
|
||||
format!("{} — custom", self.initial_model),
|
||||
"Current model (not a standard route)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.render_pane(
|
||||
inner,
|
||||
buf,
|
||||
"Model & thinking",
|
||||
rows,
|
||||
self.selected_route_idx,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Classic two-column layout for pass-through providers.
|
||||
fn render_classic(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
|
||||
let popup_height = 14.min(area.height.saturating_sub(4)).max(10);
|
||||
let popup_area = Rect {
|
||||
@@ -455,12 +628,7 @@ mod tests {
|
||||
app.auto_model = true;
|
||||
app.reasoning_effort = ReasoningEffort::Off;
|
||||
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.selected_model_idx = 0;
|
||||
view.selected_effort_idx = PICKER_EFFORTS
|
||||
.iter()
|
||||
.position(|effort| *effort == ReasoningEffort::Max)
|
||||
.expect("max effort row");
|
||||
let view = ModelPickerView::new(&app);
|
||||
|
||||
assert_eq!(view.resolved_model(), "auto");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
|
||||
@@ -505,53 +673,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_within_focused_pane() {
|
||||
fn arrow_keys_move_within_whale_routes() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
// Default focus is Model; move down then up.
|
||||
let initial = view.selected_model_idx;
|
||||
assert!(view.show_whale_routes);
|
||||
let initial = view.selected_route_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial + 1);
|
||||
assert_eq!(view.selected_route_idx, initial + 1);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Up,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial);
|
||||
assert_eq!(view.selected_route_idx, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switches_focus_and_arrow_now_moves_effort() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
// Default is Max; pin to Off so the Down arrow has
|
||||
// somewhere to go.
|
||||
app.reasoning_effort = ReasoningEffort::Off;
|
||||
fn tab_is_noop_in_whale_route_mode() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
let initial_effort_idx = view.selected_effort_idx;
|
||||
assert!(view.show_whale_routes);
|
||||
let before = view.selected_route_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.focus, Pane::Effort);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert!(view.selected_effort_idx > initial_effort_idx);
|
||||
assert_eq!(view.selected_route_idx, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_emits_apply_event_with_selection() {
|
||||
fn enter_with_whale_routes_emits_apply_event() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
app.reasoning_effort = ReasoningEffort::High;
|
||||
app.model = "deepseek-v4-pro".to_string();
|
||||
app.auto_model = false;
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
// Initial route: Fin Whale (Pro + High, sort_order=1)
|
||||
assert_eq!(view.selected_route_idx, 1);
|
||||
// Move down to Sperm Whale (Pro + Off, sort_order=2)
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
@@ -568,13 +729,91 @@ mod tests {
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(model, "deepseek-v4-pro");
|
||||
assert_eq!(effort, ReasoningEffort::Max);
|
||||
assert_eq!(effort, ReasoningEffort::Off);
|
||||
assert_eq!(previous_effort, ReasoningEffort::High);
|
||||
}
|
||||
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_initial_selection_matches_app_state() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
app.model = "deepseek-v4-flash".to_string();
|
||||
app.auto_model = false;
|
||||
app.reasoning_effort = ReasoningEffort::Max;
|
||||
let view = ModelPickerView::new(&app);
|
||||
// Humpback = Flash + Max, sort_order = 3
|
||||
assert_eq!(view.selected_route_idx, 3);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_known_model_auto_effort_does_not_fall_to_auto() {
|
||||
// Regression: a known DeepSeek model paired with ReasoningEffort::Auto
|
||||
// must NOT fall through to the "auto" row — that would silently replace
|
||||
// the explicit model with "auto" on apply.
|
||||
let (mut app, _lock) = create_test_app();
|
||||
app.model = "deepseek-v4-pro".to_string();
|
||||
app.auto_model = false;
|
||||
app.reasoning_effort = ReasoningEffort::Auto;
|
||||
let view = ModelPickerView::new(&app);
|
||||
// Should fall to custom row (WHALE_ROUTES.len() + 1), not auto row.
|
||||
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-pro");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
|
||||
// The custom row must be visible so the user sees their current model.
|
||||
assert!(view.show_custom_model_row);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_auto_effort_maps_to_fallback_row() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
app.model = "auto".to_string();
|
||||
app.auto_model = true;
|
||||
app.reasoning_effort = ReasoningEffort::Auto;
|
||||
let view = ModelPickerView::new(&app);
|
||||
// "auto" doesn't match any whale route, falls to fallback row
|
||||
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len());
|
||||
assert_eq!(view.resolved_model(), "auto");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_custom_model_falls_back() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
|
||||
app.auto_model = false;
|
||||
app.reasoning_effort = ReasoningEffort::High;
|
||||
let view = ModelPickerView::new(&app);
|
||||
// Custom model → second fallback row (after "auto")
|
||||
assert_eq!(view.selected_route_idx, WHALE_ROUTES.len() + 1);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::High);
|
||||
// Row count includes routes + auto + custom
|
||||
assert_eq!(view.whale_route_row_count(), WHALE_ROUTES.len() + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_down_from_last_is_noop() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
// Navigate to the last row
|
||||
view.selected_route_idx = view.whale_route_row_count() - 1;
|
||||
let result = view.move_down();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_routes_up_from_first_is_noop() {
|
||||
let (app, _lock) = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.selected_route_idx = 0;
|
||||
let result = view.move_up();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_esc_applies_current_selection() {
|
||||
let (app, _lock) = create_test_app();
|
||||
@@ -592,9 +831,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_after_selection_move_applies_highlighted_model() {
|
||||
let (app, _lock) = create_test_app();
|
||||
fn esc_after_selection_move_applies_highlighted_route() {
|
||||
let (mut app, _lock) = create_test_app();
|
||||
app.reasoning_effort = ReasoningEffort::High;
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
// Initial: Fin Whale (Pro+High), previous_effort=High
|
||||
// Down → Sperm Whale (Pro+Off)
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
@@ -608,13 +850,15 @@ mod tests {
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
|
||||
model,
|
||||
previous_model,
|
||||
effort,
|
||||
previous_effort,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(previous_model, "deepseek-v4-pro");
|
||||
assert_eq!(model, "deepseek-v4-flash");
|
||||
assert_eq!(model, "deepseek-v4-pro");
|
||||
assert_eq!(effort, ReasoningEffort::Off);
|
||||
assert_eq!(previous_effort, ReasoningEffort::High);
|
||||
}
|
||||
other => panic!("expected Esc to apply highlighted model, got {other:?}"),
|
||||
other => panic!("expected Esc to apply highlighted route, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1605,6 +1605,25 @@ async fn run_event_loop(
|
||||
// composer receipt), regardless of notification method
|
||||
// or platform.
|
||||
if status == crate::core::events::TurnOutcomeStatus::Completed {
|
||||
// SlopLedger completion-gate: after every completed
|
||||
// turn, check whether there are unresolved slop entries
|
||||
// the agent should address before claiming the task is
|
||||
// done (#2127). This runs autonomously — no tool call
|
||||
// required — so the agent can't forget to check.
|
||||
if let Ok(ledger) = crate::slop_ledger::SlopLedger::load()
|
||||
&& ledger.has_open_entries()
|
||||
{
|
||||
if let Some(gate_msg) = ledger.completion_gate_summary() {
|
||||
let short =
|
||||
gate_msg.lines().nth(4).unwrap_or("review before done");
|
||||
app.push_status_toast(
|
||||
format!("⚠️ SlopLedger: {short}"),
|
||||
crate::tui::app::StatusToastLevel::Warning,
|
||||
Some(12_000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tool_count = app.tool_evidence.len();
|
||||
let mut receipt = "✓ turn completed".to_string();
|
||||
if tool_count > 0 {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Whale-size route taxonomy for model + thinking-effort combinations (#2026).
|
||||
//!
|
||||
//! Maps each `(model, reasoning_effort)` pair to a friendly whale-species label,
|
||||
//! sorted from largest/deepest to smallest/fastest. The labels share the same
|
||||
//! species pool as sub-agent nicknames (#2016) but serve a different purpose:
|
||||
//! route/tier names help users understand depth/cost/speed at a glance.
|
||||
//!
|
||||
//! ## Route ordering (size → speed)
|
||||
//!
|
||||
//! 1. Blue Whale — Pro + max thinking (largest, deepest)
|
||||
//! 2. Fin Whale — Pro + high thinking
|
||||
//! 3. Sperm Whale — Pro + no thinking
|
||||
//! 4. Humpback — Flash + max thinking
|
||||
//! 5. Minke Whale — Flash + high thinking
|
||||
//! 6. Beluga — Flash + no thinking (smallest, fastest)
|
||||
//!
|
||||
//! Unknown or non-DeepSeek models fall back to the raw model id without
|
||||
//! fake whale labeling.
|
||||
|
||||
use crate::tui::app::ReasoningEffort;
|
||||
|
||||
/// One whale-sized route: a model + thinking-effort combination with
|
||||
/// a friendly label, sort order, and descriptive hint.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WhaleRoute {
|
||||
/// Whale-species label, e.g. "Blue Whale".
|
||||
pub label: &'static str,
|
||||
/// Model id, e.g. "deepseek-v4-pro".
|
||||
pub model: &'static str,
|
||||
/// Reasoning effort tier.
|
||||
pub effort: ReasoningEffort,
|
||||
/// Sort index (0 = largest / deepest).
|
||||
pub sort_order: usize,
|
||||
/// Short inline hint, e.g. "Pro + max thinking".
|
||||
pub hint: &'static str,
|
||||
/// Longer description for tooltips / route receipts.
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// Six canonical routes, sorted largest → smallest.
|
||||
pub const WHALE_ROUTES: &[WhaleRoute] = &[
|
||||
WhaleRoute {
|
||||
label: "Blue Whale",
|
||||
model: "deepseek-v4-pro",
|
||||
effort: ReasoningEffort::Max,
|
||||
sort_order: 0,
|
||||
hint: "Pro + max thinking",
|
||||
description: "Flagship reasoning at maximum depth — architecture, debugging, security reviews",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Fin Whale",
|
||||
model: "deepseek-v4-pro",
|
||||
effort: ReasoningEffort::High,
|
||||
sort_order: 1,
|
||||
hint: "Pro + high thinking",
|
||||
description: "Deep reasoning for complex tasks — multi-file refactors, careful planning",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Sperm Whale",
|
||||
model: "deepseek-v4-pro",
|
||||
effort: ReasoningEffort::Off,
|
||||
sort_order: 2,
|
||||
hint: "Pro + no thinking",
|
||||
description: "Full model power without reasoning overhead — straightforward code generation",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Humpback",
|
||||
model: "deepseek-v4-flash",
|
||||
effort: ReasoningEffort::Max,
|
||||
sort_order: 3,
|
||||
hint: "Flash + max thinking",
|
||||
description: "Fast model with reasoning depth — lightweight analysis, first-pass reviews",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Minke Whale",
|
||||
model: "deepseek-v4-flash",
|
||||
effort: ReasoningEffort::High,
|
||||
sort_order: 4,
|
||||
hint: "Flash + high thinking",
|
||||
description: "Fast model, moderate reasoning — tool execution, read-only scouting",
|
||||
},
|
||||
WhaleRoute {
|
||||
label: "Beluga",
|
||||
model: "deepseek-v4-flash",
|
||||
effort: ReasoningEffort::Off,
|
||||
sort_order: 5,
|
||||
hint: "Flash + no thinking",
|
||||
description: "Fastest and cheapest — lookups, searches, simple edits",
|
||||
},
|
||||
];
|
||||
|
||||
impl WhaleRoute {
|
||||
/// Look up the whale route for a given model id and reasoning effort.
|
||||
/// Returns `None` for non-DeepSeek models or unrecognized combinations.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn for_model_effort(model: &str, effort: ReasoningEffort) -> Option<&'static WhaleRoute> {
|
||||
WHALE_ROUTES
|
||||
.iter()
|
||||
.find(|r| r.model.eq_ignore_ascii_case(model) && r.effort == effort)
|
||||
}
|
||||
|
||||
/// Look up a whale route by its sort-order index.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn by_sort_order(index: usize) -> Option<&'static WhaleRoute> {
|
||||
WHALE_ROUTES.iter().find(|r| r.sort_order == index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes_are_sorted_by_size() {
|
||||
for window in WHALE_ROUTES.windows(2) {
|
||||
assert!(
|
||||
window[0].sort_order < window[1].sort_order,
|
||||
"{} should sort before {}",
|
||||
window[0].label,
|
||||
window[1].label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_blue_whale_for_pro_max() {
|
||||
let route = WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Max)
|
||||
.expect("blue whale route exists");
|
||||
assert_eq!(route.label, "Blue Whale");
|
||||
assert_eq!(route.model, "deepseek-v4-pro");
|
||||
assert_eq!(route.effort, ReasoningEffort::Max);
|
||||
assert_eq!(route.sort_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_beluga_for_flash_off() {
|
||||
let route = WhaleRoute::for_model_effort("deepseek-v4-flash", ReasoningEffort::Off)
|
||||
.expect("beluga route exists");
|
||||
assert_eq!(route.label, "Beluga");
|
||||
assert_eq!(route.sort_order, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_case_insensitive_model() {
|
||||
let route = WhaleRoute::for_model_effort("DeepSeek-V4-Pro", ReasoningEffort::High)
|
||||
.expect("case-insensitive match");
|
||||
assert_eq!(route.label, "Fin Whale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_model_returns_none() {
|
||||
assert!(WhaleRoute::for_model_effort("gpt-4o", ReasoningEffort::High).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_effort_with_valid_model_returns_none() {
|
||||
// ReasoningEffort::Auto is not in any whale route
|
||||
assert!(WhaleRoute::for_model_effort("deepseek-v4-pro", ReasoningEffort::Auto).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_sort_order_finds_correct_routes() {
|
||||
assert_eq!(WhaleRoute::by_sort_order(0).unwrap().label, "Blue Whale");
|
||||
assert_eq!(WhaleRoute::by_sort_order(5).unwrap().label, "Beluga");
|
||||
assert!(WhaleRoute::by_sort_order(99).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_route_has_unique_sort_order() {
|
||||
let orders: Vec<usize> = WHALE_ROUTES.iter().map(|r| r.sort_order).collect();
|
||||
let mut sorted = orders.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(orders.len(), sorted.len(), "duplicate sort orders detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_route_has_unique_label() {
|
||||
let labels: Vec<&str> = WHALE_ROUTES.iter().map(|r| r.label).collect();
|
||||
let mut sorted = labels.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(labels.len(), sorted.len(), "duplicate labels detected");
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -686,14 +686,21 @@ Use `codewhale-tui features list` to inspect known flags and their effective sta
|
||||
`web_search` uses DuckDuckGo by default and does not require an API key. The
|
||||
DuckDuckGo path keeps a Bing fallback when DDG returns a bot challenge or no
|
||||
parseable results. Bing remains selectable for users who explicitly want it,
|
||||
and Tavily or Bocha can be selected when an API-backed provider is preferred.
|
||||
**Metaso** ([metaso.cn](https://metaso.cn))
|
||||
100 searches/day free quota — set `METASO_API_KEY` or `[search] api_key` for a higher quota.
|
||||
and Tavily, Bocha, Metaso, or Baidu can be selected when an API-backed provider
|
||||
is preferred.
|
||||
|
||||
**Metaso** ([metaso.cn](https://metaso.cn)) has a 100 searches/day free quota;
|
||||
set `METASO_API_KEY` or `[search] api_key` for a higher quota.
|
||||
|
||||
**Baidu** uses Baidu AI Search at
|
||||
`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set
|
||||
`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend
|
||||
only; it does not add a Baidu model provider.
|
||||
|
||||
```toml
|
||||
[search]
|
||||
provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso
|
||||
# api_key = "YOUR_KEY" # required for tavily and bocha; optional for metaso (100 searches/day free quota)
|
||||
provider = "baidu" # duckduckgo | bing | tavily | bocha | metaso | baidu
|
||||
# api_key = "YOUR_KEY" # required for tavily, bocha, and baidu; optional for metaso
|
||||
```
|
||||
|
||||
## Local Media Attachments
|
||||
|
||||
@@ -35,7 +35,7 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts
|
||||
|---|---|
|
||||
| `grep_files` | Regex search file contents within the workspace; structured matches + context lines. Pure-Rust (`regex` crate), no `rg`/`grep` shell-out. |
|
||||
| `file_search` | Fuzzy-match filenames (not contents). Use when you know roughly the name. |
|
||||
| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, and Bocha are selectable in config. Ranked snippets + `ref_id` for citation. |
|
||||
| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, Bocha, Metaso, and Baidu are selectable in config. Ranked snippets + `ref_id` for citation. |
|
||||
| `fetch_url` | Direct HTTP GET on a known URL. Faster than `web_search` when the link is already known. HTML stripped to text by default. |
|
||||
|
||||
### Shell
|
||||
|
||||
Reference in New Issue
Block a user