hey 0.1.0

Minimal terminal AI coding agent: kernel loop + MCP/Skills self-evolution
Documentation
//! 真实模型端到端冒烟测试(需网络 + 真实 provider;默认 `#[ignore]`)。
//!
//! 这是 DESIGN.md「测试策略」承诺的端到端冒烟层:MockServer/FakeProvider 只验证
//! wire 与循环逻辑,这里用真实模型验证协议适配器对真实行为的正确性——
//! reasoning_content / tool_calls / 流式 delta 的真实形态与 mock 假设可能不同。
//!
//! 运行:`cargo test --test e2e_live -- --ignored --nocapture`
//! 连接配置(优先级从高到低):
//!   1. 环境变量 HEY_E2E_BASE_URL / HEY_E2E_API_KEY / HEY_E2E_MODEL
//!   2. `~/.config/hey/config.toml` 的 `[default]` provider(用户主配置)
//!
//! 不设置任何配置时测试直接跳过(本地无 key 不发请求,避免误报红)。

use hey::llm::ir::{ChatRequest, EffortLevel, Message, Role, ToolCall};
use hey::llm::{Delta, Provider, build_provider};
use std::sync::Arc;

/// 解析 E2E 连接配置(优先级从高到低):
///   1. 环境变量 HEY_E2E_BASE_URL / HEY_E2E_API_KEY / HEY_E2E_MODEL
///   2. pi 的 kilo 网关(~/.pi/agent/{auth,models}.json,用户日常用的稳定网关,hy3)
///   3. 用户主配置 ~/.config/hey/config.toml 的 default provider(走 resolve 尊重 [default].model)
///
/// 拿不到有效配置返回 None(测试跳过,不发网络请求)。
fn live_provider() -> Option<(Arc<dyn Provider>, String)> {
    if let (Ok(b), Ok(k), Ok(m)) = (
        std::env::var("HEY_E2E_BASE_URL"),
        std::env::var("HEY_E2E_API_KEY"),
        std::env::var("HEY_E2E_MODEL"),
    ) {
        let pcfg = hey::config::ProviderConfig {
            protocol: Some("openai".into()),
            base_url: b,
            api_key: Some(k),
            models: vec![m.clone()],
            effort: Some(EffortLevel::Low),
            ..Default::default()
        };
        return build(pcfg, m);
    }
    // 2. kilo:从 pi 的 auth/models 配置读取(key 不落盘到 hey 配置)
    if let Some((pcfg, m)) = kilo_from_pi() {
        return build(pcfg, m);
    }
    // 3. 用户主配置 default provider
    let home = std::env::var("HOME").ok()?;
    let cfg_path = std::path::Path::new(&home).join(".config/hey/config.toml");
    let cfg = hey::config::Config::from_files(&[cfg_path]).ok()?;
    let (pname, model, mut pcfg) = cfg.resolve(None, None, None, None).ok()?;
    let _ = pname;
    pcfg.effort = Some(EffortLevel::Low);
    eprintln!(
        "[e2e] using provider config: model={model} base={}",
        pcfg.base_url
    );
    build(pcfg, model)
}

/// 从 pi 的 kilo 网关配置构建 provider:读 ~/.pi/agent/{auth,models}.json,
/// 自动选首个 hy3 模型(如 tencent/hy3:free)。
fn kilo_from_pi() -> Option<(hey::config::ProviderConfig, String)> {
    let home = std::env::var("HOME").ok()?;
    let root = std::path::Path::new(&home).join(".pi/agent");
    let auth: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(root.join("auth.json")).ok()?).ok()?;
    let models: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(root.join("models.json")).ok()?).ok()?;
    let kilo = models.get("providers")?.get("kilo")?;
    let base = kilo.get("baseUrl")?.as_str()?;
    let key = auth.get("kilo")?.get("key")?.as_str()?;
    // 选首个 hy3 模型(kilo 网关的腾讯 hy3,稳定),没有则取第一个
    let model = kilo
        .get("models")?
        .as_array()?
        .iter()
        .filter_map(|m| m.get("id").and_then(|v| v.as_str()))
        .find(|id| id.contains("hy3"))
        .or_else(|| {
            kilo.get("models")?
                .as_array()?
                .iter()
                .find_map(|m| m.get("id").and_then(|v| v.as_str()))
        })?;
    eprintln!("[e2e] kilo: model={model} base={base}");
    Some((
        hey::config::ProviderConfig {
            protocol: Some("openai".into()),
            base_url: base.to_string(),
            api_key: Some(key.to_string()),
            models: vec![model.to_string()],
            effort: Some(EffortLevel::Low),
            ..Default::default()
        },
        model.to_string(),
    ))
}

fn build(pcfg: hey::config::ProviderConfig, model: String) -> Option<(Arc<dyn Provider>, String)> {
    let retry = hey::config::RetryConfig {
        enabled: false,
        ..Default::default()
    };
    match build_provider(&pcfg, reqwest::Client::new(), &retry) {
        Ok(p) => Some((p, model)),
        Err(e) => {
            eprintln!("[e2e] build_provider 失败: {e}");
            None
        }
    }
}

fn req(messages: Vec<Message>) -> ChatRequest {
    ChatRequest {
        messages,
        tools: vec![],
        effort: EffortLevel::Low,
    }
}

/// 单轮问答:验证真实模型的正文能解析出来(非空)。
#[tokio::test]
#[ignore = "需真实 API key 与网络"]
async fn live_single_turn() {
    let Some((provider, model)) = live_provider() else {
        return; // 无配置则跳过
    };
    let mut text = String::new();
    let mut thinking = String::new();
    let c = provider
        .stream(
            &req(vec![Message::text(Role::User, "用中文回复两个字:连通")]),
            &mut |d| match d {
                Delta::Text(t) => text.push_str(&t),
                Delta::Thinking(t) => thinking.push_str(&t),
            },
        )
        .await
        .expect("真实请求应成功");
    assert!(
        !c.text.trim().is_empty(),
        "应有正文输出,got text={text:?} (streamed={text:?})"
    );
    eprintln!(
        "[e2e] single_turn model={model} text={} thinking_len={}",
        text.trim(),
        thinking.len()
    );
}

/// 流式增量分派:真实推理模型(如 hy3)应产出 Thinking + Text 两类 delta。
#[tokio::test]
#[ignore = "需真实 API key 与网络"]
async fn live_stream_thinking_and_text() {
    let Some((provider, _model)) = live_provider() else {
        return;
    };
    let mut saw_text = false;
    let mut saw_thinking = false;
    provider
        .stream(
            &req(vec![Message::text(
                Role::User,
                "一步一步推理:1+1 等于几?最后只输出答案。",
            )]),
            &mut |d| match d {
                Delta::Text(_) => saw_text = true,
                Delta::Thinking(_) => saw_thinking = true,
            },
        )
        .await
        .expect("真实请求应成功");
    assert!(saw_text, "应收到 Text delta");
    eprintln!("[e2e] stream deltas: text={saw_text} thinking={saw_thinking}");
    // thinking 不强制断言:部分模型/网关可能不返回 reasoning(如关闭推理时)
}

/// 工具调用闭环:注册一个 echo 工具,验证真实模型能:
/// 收到 schema → 发起 tool_call → 结果回填 → 收尾。
#[tokio::test]
#[ignore = "需真实 API key 与网络"]
async fn live_tool_loop() {
    let Some((provider, _model)) = live_provider() else {
        return;
    };
    // 注册一个最小 echo 工具(真实模型不依赖任何文件系统/网络)
    use hey::tools::{Tool, ToolCtx, ToolOutput};
    struct EchoTool;
    #[async_trait::async_trait]
    impl hey::tools::Tool for EchoTool {
        fn name(&self) -> &str {
            "echo"
        }
        fn description(&self) -> &str {
            "回显传入的 message 字段原样返回"
        }
        fn schema(&self) -> serde_json::Value {
            serde_json::json!({
                "type": "object",
                "properties": { "message": { "type": "string" } },
                "required": ["message"]
            })
        }
        async fn run(&self, _ctx: &ToolCtx, args: serde_json::Value) -> ToolOutput {
            let msg = args
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            ToolOutput::ok(msg)
        }
    }
    let tool = Arc::new(EchoTool);
    let mut messages = vec![Message::text(
        Role::User,
        "调用 echo 工具,参数 message 填 hi,然后把工具返回的内容原样输出。",
    )];
    let mut completed = false;
    for _round in 0..4 {
        let req = ChatRequest {
            messages: messages.clone(),
            tools: vec![tool.to_schema()],
            effort: EffortLevel::Low,
        };
        let c = provider
            .stream(&req, &mut |_| {})
            .await
            .expect("真实请求应成功");
        if let Some(tc) = c.tool_calls.first() {
            eprintln!(
                "[e2e] tool_call round: {} {} {:?}",
                tc.name, tc.id, tc.arguments
            );
            // 执行 echo(schema 已保证 message 存在)
            let args: serde_json::Value =
                serde_json::from_str(&tc.arguments).unwrap_or(serde_json::json!({}));
            let out = match tool
                .run(
                    &ToolCtx {
                        project_root: ".".into(),
                    },
                    args,
                )
                .await
            {
                ToolOutput { ok: true, text } => text,
                ToolOutput { ok: false, text } => text,
            };
            eprintln!("[e2e] tool result: {out:?}");
            messages.push(assistant_msg(tc.clone()));
            messages.push(Message::tool_result(&tc.id, out));
        } else {
            eprintln!("[e2e] final text: {}", c.text.trim());
            assert!(
                c.text.to_lowercase().contains("hi"),
                "最终应复述 echo 返回的 hi,got: {:?}",
                c.text
            );
            completed = true;
            break;
        }
    }
    assert!(completed, "工具闭环未在 4 轮内完成");
}

fn assistant_msg(tc: ToolCall) -> Message {
    Message {
        id: String::new(),
        role: Role::Assistant,
        content: vec![],
        tool_calls: vec![tc],
        tool_call_id: None,
    }
}