hey 0.1.0

Minimal terminal AI coding agent: kernel loop + MCP/Skills self-evolution
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
//! 配置:单文件 TOML + env(`HEY_*`)+ CLI 覆盖(见 docs/DESIGN.md §8)。
//!
//! 优先级:CLI 参数 > `HEY_*` 环境变量 > `[default]` > 首个 provider。

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::Deserialize;

/// 顶层配置。
/// 大多数段以 `Option<T>` 持有:merge() 只在显式设置时覆盖(保护用户配置不被后加载的默认值吞掉)。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
    pub default: DefaultSelection,
    pub provider: BTreeMap<String, ProviderConfig>,
    pub mcp: BTreeMap<String, McpConfig>,
    pub proxy: ProxyConfig,
    pub agent: AgentConfig,
    pub retry: Option<RetryConfig>,
    pub compaction: Option<CompactionConfig>,
    pub permission: PermissionConfig,
    /// `[tools]` 段(bash 输出过滤管线)。
    pub tools: Option<ToolsConfig>,
    /// `[failover]` 段:跨 provider 故障切换。
    pub failover: Option<FailoverConfig>,
    /// `quiet_startup`:压平 REPL 启动期的加载汇总(provider/skills/MCP)——错误仍照常报。
    /// 默认 `false`(pi 约定:首次用能看见加载了啥)。
    pub quiet_startup: Option<bool>,
}

/// `[failover]`:主 provider 请求失败(可重试类错误)时切换到备用 provider。
/// 备用 provider 在配置的 `[provider.*]` 表中按名查找,多备用按序尝试。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FailoverConfig {
    pub enabled: bool,
    /// 备用 provider 名(对应 `[provider.<name>]`),按顺序尝试。
    pub providers: Vec<String>,
}

/// `[tools]`:bash 输出过滤可选外部命令(DESIGN §13)。
/// `bash_filter`: 外部过滤命令(shell 字符串,stdin 收原始输出,stdout 返回过滤结果);
/// 缺省/"/builtin" 用内置启发式。raw=true 时仍走内置截断(外部过滤器对 raw 无意义)。
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ToolsConfig {
    pub bash_filter: Option<String>,
    /// shell 覆盖(Windows 默认找 sh 否则 PowerShell;None 平台默认)
    pub shell: Option<String>,
    /// bash 工具默认超时(秒);0 = 禁用(不包 timeout,无限等待)。模型参数 timeout_secs 覆盖它。
    pub timeout_secs: u64,
}

impl Default for ToolsConfig {
    fn default() -> Self {
        Self {
            bash_filter: None,
            shell: None,
            timeout_secs: 120,
        }
    }
}

/// 默认选择(CLI/env 覆盖它,再回落首个 provider)。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct DefaultSelection {
    pub provider: Option<String>,
    pub model: Option<String>,
}

/// MCP stdio server(docs/DESIGN.md §9.2):启动子进程,走 JSON-RPC 2.0。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct McpConfig {
    pub command: String,
    pub args: Vec<String>,
    /// 工具名前缀(缺省 `mcp.<name>`)。
    pub prefix: Option<String>,
    /// 远程 MCP 端点(HTTP/SSE)。存在时忽略 command/args,走 HTTP 传输。
    pub url: Option<String>,
    /// server 崩溃/管道断裂时自动重启子进程(stdio 传输)并重试一次。
    /// 默认 false = 明确报错 + REPL /reload 手动恢复(失败暴露给用户)。
    pub respawn: bool,
}
/// 单个 provider 配置。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ProviderConfig {
    /// 协议:`openai`(缺省)/ `anthropic`(M2) / `google`(v2)。
    pub protocol: Option<String>,
    pub base_url: String,
    pub api_key: Option<String>,
    /// 可用模型列表(`--model` 校验来源)。
    pub models: Vec<String>,
    /// 默认推理强度(CLI --effort 覆盖)。None = 模型默认行为。
    pub effort: Option<crate::llm::ir::EffortLevel>,
    /// Anthropic 专用:false(默认)= 新 API adaptive thinking + output_config.effort 直通;
    /// true = 老 API `enabled + budget_tokens` 映射(旧模型/兼容网关用)。
    pub legacy_thinking: bool,
    /// 模型上下文窗口(token),未配时按内置已知模型表推断。
    pub context_window: Option<u64>,
    /// Anthropic max_tokens 覆盖(默认按 effort 档位自动给足,见 max_tokens_for)。
    pub max_tokens: Option<u64>,
    /// 自定义请求头(如网关需要 X-API-Key / OpenAI-Organization 等)。
    /// 协议必需头(Content-Type / anthropic-version 等)与 api_key 生成的
    /// Authorization 优先,不能被这里覆盖。
    pub headers: std::collections::BTreeMap<String, String>,
}

impl ProviderConfig {
    /// 当前选中的模型:models 列表首个,否则 base_url 推测。
    pub fn default_model(&self) -> String {
        self.models
            .first()
            .cloned()
            .unwrap_or_else(|| "model".to_string())
    }

    /// 测试用最小配置。
    pub fn dummy() -> Self {
        Self {
            base_url: "http://localhost:9999/v1".into(),
            api_key: None,
            models: vec!["test-model".into()],
            ..Default::default()
        }
    }
}

/// 代理(缺省走环境变量)。
/// 超时分层(无副作用原则):
/// - connect_timeout_secs:建连(DNS/TCP/TLS)超时,默认 30s——正常建连秒级完成,
///   不会误伤任何正常使用(连接阶段无"正常的慢")
/// - read_timeout_secs:流式读取间隔超时,默认 0 = 禁用(保持无限等待)——
///   推理模型可能长时间静默思考,自动判断会误伤,故默认不启用,用户需要时显式开启
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ProxyConfig {
    pub url: Option<String>,
    /// 建连超时(秒);0 = 禁用。默认 30。
    pub connect_timeout_secs: u64,
    /// 流式读取间隔超时(秒);0 = 禁用(默认,保持现状)。
    pub read_timeout_secs: u64,
}

impl Default for ProxyConfig {
    fn default() -> Self {
        Self {
            url: None,
            connect_timeout_secs: 30,
            read_timeout_secs: 0,
        }
    }
}

/// agent 行为。字段为 Option 是为了让 merge() 只覆盖显式设置:
/// 全局 `max_turns = 50` + 项目配置不设 max_turns(默认 20)→ 加载后仍是 50。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct AgentConfig {
    pub max_turns: Option<u32>,
    pub budget_tokens: Option<u64>,
    /// 并行执行同 turn 的多个 tool_call(默认 true;关则串行保序)
    pub parallel_tools: Option<bool>,
}

/// 重试(免费模型友好)。
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct RetryConfig {
    pub enabled: bool,
    pub max_retries: u32,
    pub base_delay_ms: u64,
    pub max_delay_ms: u64,
    pub jitter: bool,
    pub respect_retry_after: bool,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            max_retries: 5,
            base_delay_ms: 2_000,
            max_delay_ms: 60_000,
            jitter: true,
            respect_retry_after: true,
        }
    }
}

/// 上下文压缩(M2 使用,M1 仅解析)。
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CompactionConfig {
    pub enabled: bool,
    pub dedup: bool,
    pub purge_errors_after: u32,
    pub nudge_threshold: f64,
    pub compress_tool: bool,
}

impl Default for CompactionConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            dedup: true,
            purge_errors_after: 4,
            nudge_threshold: 0.7,
            compress_tool: true,
        }
    }
}

/// 权限 allow 列表(M2 使用)。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PermissionConfig {
    pub allow: Vec<String>,
}

impl Config {
    /// 合并 TOML 文件(不存在的跳过),后读覆盖先读。
    pub fn from_files(paths: &[PathBuf]) -> anyhow::Result<Self> {
        let mut cfg = Self::default();
        for p in paths {
            if p.is_file() {
                let raw = std::fs::read_to_string(p)?;
                let parsed: Config = toml::from_str(&raw)
                    .map_err(|e| anyhow::anyhow!("bad config {}: {e}", p.display()))?;
                cfg.merge(parsed);
            }
        }
        cfg.expand_env_refs();
        Ok(cfg)
    }

    /// 合并:非空字段覆盖。
    fn merge(&mut self, other: Self) {
        if other.default.provider.is_some() {
            self.default.provider = other.default.provider;
        }
        if other.default.model.is_some() {
            self.default.model = other.default.model;
        }
        for (k, v) in other.provider {
            self.provider.insert(k, v);
        }
        for (k, v) in other.mcp {
            self.mcp.insert(k, v);
        }
        if other.proxy.url.is_some() {
            self.proxy.url = other.proxy.url;
        }
        // Option 字段:仅在 other 显式设置时覆盖(保护前加载配置不被默认值吞掉)。
        // AgentConfig 内部字段也是 Option,递归保护。
        if other.agent.max_turns.is_some() {
            self.agent.max_turns = other.agent.max_turns;
        }
        if other.agent.budget_tokens.is_some() {
            self.agent.budget_tokens = other.agent.budget_tokens;
        }
        if other.agent.parallel_tools.is_some() {
            self.agent.parallel_tools = other.agent.parallel_tools;
        }
        if other.retry.is_some() {
            self.retry = other.retry;
        }
        if other.compaction.is_some() {
            self.compaction = other.compaction;
        }
        if !other.permission.allow.is_empty() {
            self.permission.allow = other.permission.allow;
        }
        if other.tools.is_some() {
            self.tools = other.tools;
        }
        if other.failover.is_some() {
            self.failover = other.failover;
        }
        if other.quiet_startup.is_some() {
            self.quiet_startup = other.quiet_startup;
        }
    }

    /// `${VAR}` / `${VAR:-default}` 环境变量引用展开。
    /// 解析失败:api_key 按无认证处理(返回 None),其它字段留空。
    fn expand_env_refs(&mut self) {
        for p in self.provider.values_mut() {
            if let Some(key) = &p.api_key {
                p.api_key = expand_env_ref(key);
            }
        }
    }

    /// 解析默认配置路径列表(不存在的路径自动跳过)。
    pub fn config_paths(cli_config: Option<&Path>) -> Vec<PathBuf> {
        let mut paths = Vec::new();
        // 全局:~/.config/hey/config.toml(Linux/macOS 统一用 home/.config)
        if let Some(home) = dirs::home_dir() {
            paths.push(home.join(".config").join("hey").join("config.toml"));
        }
        // 项目:.hey/config.toml(向上到 git 根或 cwd)
        if let Some(proj) = find_project_root(std::env::current_dir().unwrap_or_default()) {
            paths.push(proj.join(".hey").join("config.toml"));
        }
        // CLI 显式指定:最高优先
        if let Some(p) = cli_config {
            paths.push(p.to_path_buf());
        }
        paths
    }

    /// 解析选中的 provider/model 与 pcfg(CLI --base-url 临时 provider / 配置 / env / `[default]` 兜底)。
    /// `base_url` 存在 → 动态合成临时 provider(名 `cli` 或 --model 前缀,零配置进内存);
    /// 否则 → 配置路径:`--model` 必须带 `provider/model` 前缀,无则回落 env/`[default]`/首个 provider。
    /// 返回 (provider 名, model 名, pcfg)。
    pub fn resolve(
        &self,
        base_url: Option<&str>,
        protocol: Option<&str>,
        api_key: Option<&str>,
        cli_model: Option<&str>,
    ) -> Result<(String, String, ProviderConfig), String> {
        let env_provider = std::env::var("HEY_PROVIDER").ok().filter(|s| !s.is_empty());
        let env_model = std::env::var("HEY_MODEL").ok().filter(|s| !s.is_empty());

        if let Some(base_url) = base_url {
            // 临时 provider:--model/HEY_MODEL 取模型名;前缀可选(`cli/gpt-4o` 或 `gpt-4o`)
            let m = cli_model
                .or(env_model.as_deref())
                .ok_or_else(|| "--base-url requires --model <name>".to_string())?;
            let (pname, model) = m.split_once('/').unwrap_or(("cli", m));
            let protocol = protocol.unwrap_or("openai").to_string();
            let pcfg = ProviderConfig {
                protocol: Some(protocol),
                base_url: base_url.to_string(),
                api_key: api_key.map(str::to_string),
                models: vec![model.to_string()],
                ..Default::default()
            };
            Ok((pname.to_string(), model.to_string(), pcfg))
        } else {
            // 配置路径:--model/HEY_MODEL 带 `provider/model` 前缀,或回落 [default]/env/首个
            let m = cli_model.or(env_model.as_deref());
            let (pname, model) = if let Some(m) = m {
                let (p, rest) = m.split_once('/').ok_or_else(|| {
                    format!(
                        "model \"{m}\" must be provider/model (e.g. zen/deepseek-v4-flash) or use --base-url"
                    )
                })?;
                (p.to_string(), rest.to_string())
            } else {
                let p = env_provider
                    .or_else(|| self.default.provider.clone())
                    .or_else(|| self.provider.keys().next().cloned())
                    .ok_or_else(|| {
                        "no provider configured; add [provider.*] or use --base-url".to_string()
                    })?;
                let base = self
                    .provider
                    .get(&p)
                    .ok_or_else(|| format!("provider '{p}' not found in config"))?;
                let mm = env_model
                    .or_else(|| self.default.model.clone())
                    .unwrap_or_else(|| base.default_model());
                (p, mm)
            };
            let pcfg = self
                .provider
                .get(&pname)
                .cloned()
                .ok_or_else(|| format!("provider '{pname}' not found in config"))?;
            Ok((pname, model, pcfg))
        }
    }
}

/// 找 git 仓库根:从 dir 向上找 `.git`,找不到就返回 dir 自身。
pub fn find_project_root(dir: PathBuf) -> Option<PathBuf> {
    let mut cur = Some(dir.as_path());
    while let Some(d) = cur {
        if d.join(".git").exists() {
            return Some(d.to_path_buf());
        }
        cur = d.parent();
    }
    Some(dir)
}

/// 展开 `${VAR}` 与 `${VAR:-default}`。找不到返回 None(调用方按无认证/留空处理)。
pub fn expand_env_ref(s: &str) -> Option<String> {
    if let Some(rest) = s.strip_prefix("${").and_then(|r| r.strip_suffix('}')) {
        if let Some((var, default)) = rest.split_once(":-") {
            return std::env::var(var)
                .ok()
                .or_else(|| Some(default.to_string()));
        }
        std::env::var(rest).ok()
    } else {
        Some(s.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// env 是进程级共享状态:cargo test 默认多线程并行,set_var/remove_var
    /// 必须串行化——与 proxy.rs 的 ENV_LOCK 同源(可独立设、避免跨模块竞争)。
    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    #[test]
    fn expand_env_ref_handles_var_and_default() {
        let _env = ENV_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner); // 防 poison 连锁崩
        // Safety: set_var/remove_var 在 Rust 2024 是 unsafe(非线程安全)。
        // ENV_LOCK 保证与其它 config/proxy 测试串行;hey 本身只读 env(${VAR} 展开),
        // 不并发写 env。HEY_TEST_KEY 是专用测试变量,与生产路径无关。
        unsafe {
            std::env::set_var("HEY_TEST_KEY", "abc");
        }
        assert_eq!(expand_env_ref("${HEY_TEST_KEY}"), Some("abc".into()));
        assert_eq!(expand_env_ref("${NO_SUCH_VAR_XYZ}"), None);
        assert_eq!(
            expand_env_ref("${NO_SUCH_VAR_XYZ:-fallback}"),
            Some("fallback".into())
        );
        assert_eq!(expand_env_ref("plain"), Some("plain".into()));
        unsafe {
            std::env::remove_var("HEY_TEST_KEY");
        }
    }

    #[test]
    fn parse_mcp_http_url() {
        // M13:远程 MCP 支持 —— [mcp.*] 可声明 url(HTTP 传输),command/args 被忽略
        let raw = r#"
[mcp.remote]
url = "https://mcp.example.com/api"
[mcp.local]
command = "python3"
args = ["s.py"]
"#;
        let cfg: Config = toml::from_str(raw).unwrap();
        assert_eq!(
            cfg.mcp["remote"].url.as_deref(),
            Some("https://mcp.example.com/api")
        );
        assert_eq!(cfg.mcp["remote"].command, ""); // url 存在时 command 未被占用
        assert!(cfg.mcp["local"].url.is_none());
        assert_eq!(cfg.mcp["local"].command, "python3");
    }

    #[test]
    fn provider_headers_deserialize() {
        let raw = r#"
[provider.test]
base_url = "http://localhost"
api_key = "x"
models = ["m"]
headers = { "X-API-Key" = "abc", "OpenAI-Organization" = "org-123" }
"#;
        let cfg: Config = toml::from_str(raw).unwrap();
        let p = &cfg.provider["test"];
        assert_eq!(p.headers.get("X-API-Key").unwrap(), "abc");
        assert_eq!(p.headers.get("OpenAI-Organization").unwrap(), "org-123");
        assert_eq!(p.headers.len(), 2);
    }

    #[test]
    fn provider_headers_default_empty() {
        // 未配置 headers 时 = 空 map
        let raw = r#"
[provider.test]
base_url = "http://localhost"
api_key = "x"
models = ["m"]
"#;
        let cfg: Config = toml::from_str(raw).unwrap();
        assert!(cfg.provider["test"].headers.is_empty());
    }

    #[test]
    fn merge_keeps_mcp_and_tools() {
        // 回归(M7 实测抓到):merge() 曾漏 mcp/tools → [mcp.*] 与 [tools].bash_filter 从配置丢失
        let raw = r#"
[provider.zen]
base_url = "http://x/v1"
models = ["m"]
[mcp.fake]
command = "python3"
args = ["s.py"]
[tools]
bash_filter = "grep warn"
"#;
        let parsed: Config = toml::from_str(raw).unwrap();
        let mut cfg = Config::default();
        cfg.merge(parsed);
        assert!(cfg.mcp.contains_key("fake"), "mcp 段必须被合并");
        assert_eq!(cfg.mcp["fake"].command, "python3");
        assert_eq!(
            cfg.tools.as_ref().and_then(|t| t.bash_filter.as_deref()),
            Some("grep warn")
        );
    }

    #[test]
    fn merge_keeps_parallel_tools() {
        // M12 遗漏:merge() 曾漏 parallel_tools → [agent] parallel_tools=false 配置不生效
        let raw = r"
[agent]
parallel_tools = false
";
        let parsed: Config = toml::from_str(raw).unwrap();
        let mut cfg = Config::default();
        cfg.merge(parsed);
        assert!(
            !cfg.agent.parallel_tools.unwrap_or(true),
            "显式 false 必须保留"
        );
    }

    #[test]
    fn merge_does_not_clobber_with_defaults() {
        // Bug 回归:merge() 中 bool/数字字段直接赋值而非 Option 检查,
        // 导致后加载配置的默认值会覆盖前一次显式设置。
        // 场景:全局 `quiet_startup = true`,项目配置不设该项(默认 false),
        // 项目加载后 quiet_startup 被错误地变成 false。

        let user_global: Config = toml::from_str(
            "quiet_startup = true
",
        )
        .unwrap();
        let project_default: Config = toml::from_str("[agent]\nmax_turns = 30\n").unwrap();

        let mut cfg = Config::default();
        cfg.merge(user_global); // 用户的 quiet_startup=true
        assert!(
            cfg.quiet_startup.unwrap_or(false),
            "user 显式 true 必须保留"
        );

        cfg.merge(project_default); // 项目不设 quiet_startup
        assert!(
            cfg.quiet_startup.unwrap_or(false),
            "后加载的默认值不应覆盖用户显式设置(actual={:?})",
            cfg.quiet_startup
        );
        assert_eq!(cfg.agent.max_turns, Some(30));
    }

    #[test]
    fn tools_timeout_secs_parse_and_default() {
        // [tools].timeout_secs 解析;未写时默认 120(bash 默认超时)
        let cfg: Config = toml::from_str("[tools]\ntimeout_secs = 300\n").unwrap();
        assert_eq!(cfg.tools.as_ref().unwrap().timeout_secs, 300);
        assert_eq!(
            Config::default().tools.unwrap_or_default().timeout_secs,
            120
        );
        // merge 整体替换(tools 段整体覆盖)
        let mut merged = Config::default();
        merged.merge(toml::from_str("[tools]\ntimeout_secs = 0\n").unwrap());
        assert_eq!(
            merged.tools.unwrap().timeout_secs,
            0,
            "0 = 禁用(不包 timeout)"
        );
    }

    #[test]
    fn config_from_toml() {
        let raw = r#"
[default]
provider = "local"
model = "qwen3:14b"

[provider.local]
base_url = "http://localhost:11434/v1"
models = ["qwen3:14b"]

[agent]
max_turns = 5
"#;
        let cfg: Config = toml::from_str(raw).unwrap();
        assert_eq!(cfg.agent.max_turns, Some(5));
        assert_eq!(cfg.default.provider.as_deref(), Some("local"));
        assert_eq!(cfg.default.model.as_deref(), Some("qwen3:14b"));
    }

    #[test]
    fn resolve_base_url_creates_ad_hoc_provider() {
        // --base-url 零配置:合成临时 provider,名 = 前缀或 cli,protocol 默认 openai
        let cfg = Config::default();
        let (p, m, pcfg) = cfg
            .resolve(
                Some("http://localhost:4000/v1"),
                None,
                Some("k"),
                Some("gpt-4o"),
            )
            .unwrap();
        assert_eq!((p.as_str(), m.as_str()), ("cli", "gpt-4o"));
        assert_eq!(pcfg.base_url, "http://localhost:4000/v1");
        assert_eq!(pcfg.api_key.as_deref(), Some("k"));
        assert_eq!(pcfg.protocol.as_deref(), Some("openai"));
        // 前缀可选:`mybox/gpt-4o` → provider=mybox
        let (p, m, _) = cfg
            .resolve(
                Some("http://x/v1"),
                Some("anthropic"),
                None,
                Some("mybox/haiku"),
            )
            .unwrap();
        assert_eq!((p.as_str(), m.as_str()), ("mybox", "haiku"));
        // 无 --model → 报错
        assert!(cfg.resolve(Some("http://x/v1"), None, None, None).is_err());
    }

    #[test]
    fn resolve_config_requires_provider_prefix() {
        // 无 --base-url:--model 必须带 provider/model 前缀
        let mut cfg = Config::default();
        cfg.provider.insert(
            "zen".into(),
            ProviderConfig {
                base_url: "http://z/v1".into(),
                models: vec!["m1".into()],
                ..Default::default()
            },
        );
        // 前缀正确 → 命中配置
        let (p, m, pcfg) = cfg.resolve(None, None, None, Some("zen/m2")).unwrap();
        assert_eq!((p.as_str(), m.as_str()), ("zen", "m2"));
        assert_eq!(pcfg.base_url, "http://z/v1");
        // 无前缀 → 报错
        assert!(cfg.resolve(None, None, None, Some("m1")).is_err());
        // 无 --model → 回落首个 provider 默认模型
        let (p, m, _) = cfg.resolve(None, None, None, None).unwrap();
        assert_eq!((p.as_str(), m.as_str()), ("zen", "m1"));
    }

    #[test]
    fn parse_effort_and_legacy_thinking() {
        // effort 简单字符串;legacy_thinking 默认 false(新 API adaptive)、可显式 true
        let raw = r#"
[provider.a]
base_url = "http://a/v1"
effort = "high"

[provider.b]
base_url = "http://b/v1"
protocol = "anthropic"
legacy_thinking = true
"#;
        let cfg: Config = toml::from_str(raw).unwrap();
        assert_eq!(
            cfg.provider["a"].effort,
            Some(crate::llm::ir::EffortLevel::High)
        );
        assert!(!cfg.provider["a"].legacy_thinking); // 默认新 API
        assert_eq!(cfg.provider["b"].effort, None); // 未配 effort
        assert!(cfg.provider["b"].legacy_thinking);
    }
}