Unlucky Launcher v0.2.0
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。
//!
//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。
//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。
mod support;
use std::sync::Arc;
use serde_json::json;
use support::{
block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION,
};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall};
use unlucky_agent::event::AgentEvent;
#[test]
fn single_turn_without_tools_returns_text() {
let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]);
let mut history = Vec::new();
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?");
assert_eq!(outcome.steps, 1);
assert_eq!(outcome.tool_calls, 0);
assert_eq!(outcome.stopped_reason, StopReason::Completed);
assert_eq!(history.len(), 2, "应只有 user + assistant");
assert!(harness.audit.is_empty(), "没调工具就不该有审计记录");
}
#[test]
fn system_prompt_leads_every_request_and_tools_are_projected() {
let harness = Harness::new(vec![ChatReply::text("好的")]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看配置"),
)
.unwrap();
let requests = harness.provider.requests();
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert!(
matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")),
"system prompt 必须在首位"
);
assert_eq!(request.model, "mock-model");
let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
vec![
"get_config",
"set_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
],
"注册表的双投影载荷必须原样交给 provider"
);
// 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。
assert_eq!(request.tools, harness.registry.definitions());
}
#[test]
fn tool_call_round_trip_feeds_result_back_and_finishes() {
let harness = Harness::new(vec![
tool_call_reply("call_1", "list_game_sessions", list_args()),
ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"),
]);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "最近崩了吗"),
)
.unwrap();
assert_eq!(outcome.tool_calls, 1);
assert_eq!(outcome.steps, 2);
assert_eq!(outcome.stopped_reason, StopReason::Completed);
let requests = harness.provider.requests();
assert_eq!(requests.len(), 2);
let tool_message = requests[1]
.messages
.iter()
.find_map(|m| match m {
ChatMessage::Tool {
call_id,
name,
content,
} => Some((call_id.clone(), name.clone(), content.clone())),
_ => None,
})
.expect("必须有工具结果消息");
assert_eq!(tool_message.0, "call_1");
assert_eq!(tool_message.1, "list_game_sessions");
assert!(
tool_message.2.contains(SESSION),
"工具结果应包含会话 id:{}",
tool_message.2
);
let audits = harness.audit.records();
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].tool, "list_game_sessions");
assert_eq!(audits[0].outcome, AuditOutcome::Ok);
assert!(!audits[0].yolo);
assert!(!audits[0].confirmed);
}
#[test]
fn multiple_tool_calls_in_one_step_all_execute() {
let reply = ChatReply::with_tool_calls(vec![
ToolCall {
id: "c1".to_string(),
name: "list_game_sessions".to_string(),
arguments: list_args(),
},
ToolCall {
id: "c2".to_string(),
name: "diagnose_crash".to_string(),
arguments: json!({ "session_id": SESSION }),
},
]);
let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "查一下"),
)
.unwrap();
assert_eq!(outcome.tool_calls, 2);
assert_eq!(harness.audit.len(), 2);
assert_eq!(
harness
.audit
.records()
.iter()
.map(|r| r.tool.as_str())
.collect::<Vec<_>>(),
vec!["list_game_sessions", "diagnose_crash"]
);
}
#[test]
fn step_limit_stops_runaway_loop() {
// 每一步都要求调工具,永远不会给最终回答。
let script: Vec<ChatReply> = (0..16)
.map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({})))
.collect();
let harness = Harness::new(script);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "死循环"),
)
.unwrap();
assert_eq!(outcome.stopped_reason, StopReason::StepLimit);
assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS);
assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS);
}
#[test]
fn streaming_deltas_are_forwarded_as_events() {
let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "什么错误"),
)
.unwrap();
assert_eq!(
harness.events.streamed_text(),
"日志里是 NoClassDefFoundError"
);
let events = harness.events.events();
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::TurnStarted { .. })));
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::AssistantText { .. })));
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::TurnFinished { .. })));
}
#[test]
fn disabled_provider_fails_fast() {
let harness = Harness::from_provider(Arc::new(MockProvider::disabled()));
let mut history = Vec::new();
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
assert_eq!(err, BrainError::Disabled);
assert!(err.to_string().contains("规则引擎"), "要提示降级能力");
assert!(
harness.provider.requests().is_empty(),
"禁用态不该发起任何请求"
);
assert!(history.is_empty(), "禁用态不该改动历史");
}
#[test]
fn provider_error_surfaces_as_brain_error_and_event() {
let harness = Harness::from_provider(Arc::new(MockProvider::failing(
"连接 http://127.0.0.1:8080 失败",
)));
let mut history = Vec::new();
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
assert_eq!(
err,
BrainError::Provider(unlucky_agent::brain::ProviderError::new(
"连接 http://127.0.0.1:8080 失败"
))
);
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080"))));
}
#[test]
fn empty_script_yields_empty_completion_turn() {
let harness = Harness::new(vec![]);
let mut history = Vec::new();
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
assert_eq!(outcome.text, "");
assert_eq!(outcome.stopped_reason, StopReason::Completed);
}
+132
View File
@@ -0,0 +1,132 @@
//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。
//!
//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层
//! 只做直投」,M39 补上 rmcp 侧:
//!
//! 1. `mcp::projection::tool_attrs(&registry)` 的 `(name, description, input_schema)`
//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多");
//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度;
//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提);
//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用);
//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。
use serde_json::{json, Value};
use unlucky_agent::mcp::projection::{tool_attr, tool_attrs};
use unlucky_agent::port::Ports;
use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry};
use unlucky_agent::testing::mock_ports;
use unlucky_agent::tools::build_v1_registry;
fn registry() -> ToolRegistry {
let (ports, _settings) = mock_ports();
build_v1_registry(&ports).expect("注册表应装配成功")
}
fn ports() -> Ports {
mock_ports().0
}
#[test]
fn mcp_projection_equals_registry_definition_item_by_item() {
let registry = registry();
let definitions = registry.definitions();
let attrs = tool_attrs(&registry);
assert_eq!(attrs.len(), definitions.len());
for (attr, definition) in attrs.iter().zip(definitions.iter()) {
assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等");
assert_eq!(
attr.description.as_deref(),
Some(definition.description.as_str()),
"{} 的 description 必须逐字相等",
definition.name
);
assert_eq!(
Value::Object((*attr.input_schema).clone()),
definition.input_schema,
"{} 的 JSON Schema 必须逐项相等",
definition.name
);
}
}
#[test]
fn projection_order_matches_registration_order() {
let registry = registry();
let projected: Vec<String> = tool_attrs(&registry)
.iter()
.map(|attr| attr.name.to_string())
.collect();
assert_eq!(projected, registry.names(), "两侧顺序必须一致");
}
#[test]
fn every_projected_schema_is_an_object_and_round_trips() {
for attr in tool_attrs(&registry()) {
let schema = Value::Object((*attr.input_schema).clone());
assert_eq!(
schema["type"],
json!("object"),
"{} 的 schema 根必须是 object",
attr.name
);
// rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。
let json = serde_json::to_string(&attr).expect("Tool 可序列化");
let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化");
assert_eq!(back, attr);
}
}
#[test]
fn read_only_hint_tracks_access() {
let registry = registry();
for (attr, spec) in tool_attrs(&registry).iter().zip(registry.specs()) {
let hint = attr
.annotations
.as_ref()
.and_then(|annotations| annotations.read_only_hint)
.expect("每个工具都应有 read_only_hint");
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
}
}
#[test]
fn read_only_subset_projects_without_the_writing_tool() {
let subset = registry().read_only_subset();
assert_eq!(subset.len(), 9);
let names: Vec<String> = tool_attrs(&subset)
.iter()
.map(|attr| attr.name.to_string())
.collect();
assert!(!names.iter().any(|name| name == "set_config"), "{names:?}");
assert!(subset.access_of("set_config").is_none());
}
#[test]
fn projected_attrs_are_pure_functions_of_the_definition() {
let definition = ToolDefinition {
name: "demo".to_string(),
description: "演示".to_string(),
input_schema: json!({ "type": "object", "properties": {}, "required": [] }),
};
let first = tool_attr(&definition, Access::ReadOnly);
let second = tool_attr(&definition, Access::ReadOnly);
assert_eq!(first, second);
// 副作用等级换一下,只有 annotations 变,name/description/schema 不变。
let mutating = tool_attr(&definition, Access::Mutating);
assert_eq!(first.name, mutating.name);
assert_eq!(first.input_schema, mutating.input_schema);
assert_ne!(first.annotations, mutating.annotations);
}
#[test]
fn mock_ports_cover_every_port_the_registry_needs() {
// 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。
let ports = ports();
let registry = build_v1_registry(&ports).expect("注册表应装配成功");
assert_eq!(registry.len(), 10);
assert!(unlucky_agent::testing::expected_v1_tool_names()
.iter()
.all(|name| registry.get(name).is_some()));
}
+336
View File
@@ -0,0 +1,336 @@
//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。
//!
//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature
//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」
//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。
//!
//! 覆盖的安全语义(02 §3.1 / §4b):
//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计;
//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`;
//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO;
//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。
use std::net::SocketAddr;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit};
use unlucky_agent::brain::exec::ToolExecutor;
use unlucky_agent::clock::{FixedClock, SharedClock};
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
use unlucky_agent::event::NoopSink;
use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer};
use unlucky_agent::testing::mock_ports;
use unlucky_agent::tools::build_v1_registry;
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#;
struct Harness {
addr: SocketAddr,
audit_dir: std::path::PathBuf,
}
fn audit_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn server(read_only: bool, yolo: bool, audit: Arc<JsonlAudit>) -> RegistryServer {
let (ports, _settings) = mock_ports();
let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"));
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let executor = Arc::new(
ToolExecutor::new(
exposed,
audit,
Arc::new(NoUiGate),
ConfirmPolicy { yolo },
clock,
Arc::new(NoopSink),
)
.with_guidance(DenyGuidance::External),
);
RegistryServer::new(registry, executor, "mcp-http", read_only)
}
async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness {
let dir = audit_dir(tag);
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = Arc::new(JsonlAudit::new(&dir, clock));
let server = server(read_only, yolo, audit);
let cfg = McpConfig {
port: 0,
..McpConfig::default()
};
let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口");
tokio::spawn(async move {
let _ = serve_http_on(server, cfg, addr, listener).await;
});
Harness {
addr,
audit_dir: dir,
}
}
/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。
async fn post(
addr: SocketAddr,
body: &str,
session: Option<&str>,
token: Option<&str>,
) -> (u16, Option<String>, String) {
let mut stream = TcpStream::connect(addr).await.expect("连接失败");
let mut head = format!(
"POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
Accept: application/json, text/event-stream\r\nConnection: close\r\n\
Content-Length: {}\r\n",
body.len()
);
if let Some(session) = session {
head.push_str(&format!("Mcp-Session-Id: {session}\r\n"));
}
if let Some(token) = token {
head.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
head.push_str("\r\n");
stream
.write_all(format!("{head}{body}").as_bytes())
.await
.expect("写请求失败");
stream.flush().await.expect("刷新失败");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("读响应失败");
let text = String::from_utf8_lossy(&raw).to_string();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), ""));
let status = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or(0);
let session = head.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("mcp-session-id")
.then(|| value.trim().to_string())
});
(status, session, body.to_string())
}
/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。
fn parse_rpc(body: &str) -> Value {
if let Ok(value) = serde_json::from_str::<Value>(body.trim()) {
return value;
}
for line in body.lines() {
let Some(data) = line.strip_prefix("data: ") else {
continue;
};
if let Ok(value) = serde_json::from_str::<Value>(data.trim()) {
return value;
}
}
panic!("响应体既不是 JSON 也不是 SSE:{body}");
}
async fn initialize(addr: SocketAddr) -> Option<String> {
let (status, session, body) = post(addr, INITIALIZE, None, None).await;
assert_eq!(status, 200, "initialize 应成功:{body}");
let response = parse_rpc(&body);
assert_eq!(
response["result"]["serverInfo"]["name"],
json!("unlucky-launcher")
);
// 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。
let _ = post(
addr,
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
session.as_deref(),
None,
)
.await;
session
}
async fn rpc(
addr: SocketAddr,
session: Option<&str>,
id: u32,
method: &str,
params: Value,
) -> Value {
let body =
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
let (status, _session, text) = post(addr, &body, session, None).await;
assert_eq!(status, 200, "{method} 应成功:{text}");
parse_rpc(&text)
}
fn audit_records(dir: &std::path::Path) -> Vec<AuditRecord> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
for line in text.lines() {
if let Ok(record) = serde_json::from_str::<AuditRecord>(line) {
out.push(record);
}
}
}
}
out
}
#[tokio::test(flavor = "multi_thread")]
async fn readonly_server_lists_and_serves_only_read_tools() {
let harness = spawn("readonly", true, false).await;
let session = initialize(harness.addr).await;
let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await;
let tools = list["result"]["tools"].as_array().expect("应有 tools 数组");
let names: Vec<&str> = tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert_eq!(names.len(), 9, "{names:?}");
assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具");
assert!(names.contains(&"get_config"));
// 真调一个只读工具:返回真实配置概要(mock 端口)。
let call = rpc(
harness.addr,
session.as_deref(),
3,
"tools/call",
json!({ "name": "get_config", "arguments": {} }),
)
.await;
assert_ne!(call["result"]["isError"], json!(true), "{call}");
let text = call["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("download.concurrency"), "{text}");
// 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。
let denied = rpc(
harness.addr,
session.as_deref(),
4,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
)
.await;
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let records = audit_records(&harness.audit_dir);
assert!(records.iter().any(|r| r.tool == "get_config"
&& r.outcome == AuditOutcome::Ok
&& r.agent_session_id == "mcp-http"));
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool));
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn write_server_enforces_sensitive_key_confirmation() {
let harness = spawn("write", false, false).await;
let session = initialize(harness.addr).await;
// 普通键:直接写。
let ok = rpc(
harness.addr,
session.as_deref(),
2,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
)
.await;
assert_ne!(ok["result"]["isError"], json!(true), "{ok}");
// 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。
let denied = rpc(
harness.addr,
session.as_deref(),
3,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
)
.await;
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let text = denied["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
text.contains("YOLO"),
"拒绝文案应引导到 YOLO/设置页:{text}"
);
assert!(text.contains("设置页"), "{text}");
let records = audit_records(&harness.audit_dir);
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation));
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo));
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn yolo_lets_the_same_write_through_and_audits_the_flag() {
let harness = spawn("yolo", false, true).await;
let session = initialize(harness.addr).await;
let written = rpc(
harness.addr,
session.as_deref(),
2,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
)
.await;
assert_ne!(written["result"]["isError"], json!(true), "{written}");
let records = audit_records(&harness.audit_dir);
let yolo_ok = records
.iter()
.find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok);
let record = yolo_ok.expect("应有一条 ok 审计");
assert!(record.yolo, "YOLO 放行必须标记 yolo=true");
assert!(!record.confirmed, "YOLO 自动放行不算用户确认");
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bearer_token_is_enforced_when_configured() {
let dir = audit_dir("token");
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = Arc::new(JsonlAudit::new(&dir, clock));
let server = server(true, false, audit);
let cfg = McpConfig {
port: 0,
token: Some("s3cret".to_string()),
..McpConfig::default()
};
let (addr, listener) = bind(&cfg).await.expect("应能绑定");
tokio::spawn(async move {
let _ = serve_http_on(server, cfg, addr, listener).await;
});
let (status, _, _) = post(addr, INITIALIZE, None, None).await;
assert_eq!(status, 401, "没带令牌必须 401");
let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await;
assert_eq!(status, 200, "带对令牌应通过:{body}");
let _ = std::fs::remove_dir_all(&dir);
}
+163
View File
@@ -0,0 +1,163 @@
//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。
//!
//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是
//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`,
//! 所以「两侧相等」在这里等价于三条可断言的性质:
//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等);
//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数;
//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。
//!
//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测:
//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与
//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投);
//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与
//! 注册表逐项相等(M39 补的那条)。
use serde_json::json;
use unlucky_agent::registry::validate::validate;
use unlucky_agent::registry::{Access, ToolDefinition};
use unlucky_agent::testing::mock_ports;
fn registry() -> unlucky_agent::registry::ToolRegistry {
let (ports, _settings) = mock_ports();
unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")
}
#[test]
fn ships_exactly_the_v1_tools() {
let registry = registry();
assert_eq!(
registry.names(),
vec![
"get_config",
"set_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
]
);
assert_eq!(registry.len(), 10);
}
#[test]
fn definitions_are_pure_projections() {
let registry = registry();
let first = registry.definitions();
let second = registry.definitions();
assert_eq!(first, second, "投影必须是纯函数");
for definition in &first {
// 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。
let json = serde_json::to_string(definition).expect("可序列化");
let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化");
assert_eq!(&round_trip, definition);
assert!(!definition.description.trim().is_empty());
assert_eq!(
definition.input_schema["type"],
json!("object"),
"{} 的 schema 根必须是 object",
definition.name
);
}
}
#[test]
fn only_set_config_is_mutating() {
let registry = registry();
for name in registry.names() {
let expected = if name == "set_config" {
Access::Mutating
} else {
Access::ReadOnly
};
assert_eq!(registry.access_of(name), Some(expected), "{name}");
}
let read_only: Vec<String> = registry
.read_only_definitions()
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(
read_only,
vec![
"get_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
],
"只读 MCP 模式只暴露这九个(写侧只有 set_config)"
);
}
#[test]
fn every_schema_rejects_unknown_arguments() {
for definition in registry().definitions() {
let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 }))
.expect_err(&format!("{} 应拒绝野参数", definition.name));
assert!(
err.iter().any(|e| e.contains("不是可接受的参数名")),
"{}: {err:?}",
definition.name
);
}
}
#[test]
fn required_fields_are_actually_enforced() {
for definition in registry().definitions() {
let required = definition
.input_schema
.get("required")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
if required.is_empty() {
continue;
}
let err = validate(&definition.input_schema, &json!({}))
.expect_err(&format!("{} 应有必填参数", definition.name));
for field in required {
let name = field.as_str().unwrap_or_default();
assert!(
err.iter().any(|e| e.contains(name)),
"{} 的必填 `{name}` 没被校验:{err:?}",
definition.name
);
}
}
}
#[test]
fn integer_bounds_are_declared_and_enforced() {
let registry = registry();
let read_log = registry.get("read_game_log").expect("工具存在");
let max_events = &read_log.input_schema["properties"]["max_events"];
assert_eq!(max_events["minimum"], json!(1));
assert_eq!(max_events["maximum"], json!(500));
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 0 })
)
.is_err());
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 501 })
)
.is_err());
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 500 })
)
.is_ok());
}
+225
View File
@@ -0,0 +1,225 @@
//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。
//!
//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。
//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。
#![allow(dead_code)]
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use serde_json::{json, Value};
use unlucky_agent::audit::{AuditSink, MemoryAudit};
use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps};
use unlucky_agent::brain::{
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError,
ProviderFuture, ToolCall,
};
use unlucky_agent::clock::{FixedClock, SharedClock};
use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate};
use unlucky_agent::event::{AgentEventSink, MemoryEventSink};
use unlucky_agent::registry::ToolRegistry;
use unlucky_agent::testing::{mock_ports, MockSettings};
/// 立刻完成的 future 直接 poll;挂起就是测试写错了。
pub fn block_on<F: Future>(future: F) -> F::Output {
let mut future: Pin<Box<F>> = Box::pin(future);
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => value,
Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"),
}
}
/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。
pub struct MockProvider {
config: ProviderConfig,
script: Mutex<VecDeque<ChatReply>>,
requests: Mutex<Vec<ChatRequest>>,
fail_with: Option<String>,
}
impl MockProvider {
/// 用默认模型名(本地 llama.cpp 形态)回放脚本。
#[must_use]
pub fn new(script: Vec<ChatReply>) -> Self {
Self {
config: ProviderConfig::llamacpp_local("mock-model"),
script: Mutex::new(script.into()),
requests: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// 禁用态(测 `BrainError::Disabled`)。
#[must_use]
pub fn disabled() -> Self {
Self {
config: ProviderConfig::disabled(),
script: Mutex::new(VecDeque::new()),
requests: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// 每次补全都失败(测 provider 错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail_with: Some(message.to_string()),
..Self::new(Vec::new())
}
}
/// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。
#[must_use]
pub fn requests(&self) -> Vec<ChatRequest> {
self.requests.lock().map(|r| r.clone()).unwrap_or_default()
}
}
impl ChatProvider for MockProvider {
fn config(&self) -> &ProviderConfig {
&self.config
}
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async move {
if let Ok(mut requests) = self.requests.lock() {
requests.push(request.clone());
}
if let Some(message) = &self.fail_with {
return Err(ProviderError::new(message.clone()));
}
let reply = self
.script
.lock()
.ok()
.and_then(|mut script| script.pop_front())
.unwrap_or_default();
// 按 4 字符切块吐增量,模拟流式。
if !reply.text.is_empty() {
for chunk in reply.text.chars().collect::<Vec<_>>().chunks(4) {
observer.on_delta(&chunk.iter().collect::<String>());
}
}
Ok(reply)
})
}
}
/// 装配好的测试环境。
pub struct Harness {
/// 对话循环。
pub brain: Brain,
/// 审计出口(内存)。
pub audit: Arc<MemoryAudit>,
/// 事件出口(内存)。
pub events: Arc<MemoryEventSink>,
/// mock provider(查请求)。
pub provider: Arc<MockProvider>,
/// mock 配置端口(查是否真的写入)。
pub settings: Arc<MockSettings>,
/// 工具注册表。
pub registry: Arc<ToolRegistry>,
}
impl Harness {
/// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。
/// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。
#[must_use]
pub fn new(script: Vec<ChatReply>) -> Self {
Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate))
}
/// YOLO:自动放行全部(除破坏性)。
#[must_use]
pub fn yolo(script: Vec<ChatReply>) -> Self {
Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| {
Arc::new(AutoApproveGate)
})
}
/// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。
#[must_use]
pub fn from_provider(provider: Arc<MockProvider>) -> Self {
Self::build(provider, ConfirmPolicy::default(), |_sink| {
Arc::new(NoUiGate)
})
}
/// 自定义闸门 + 策略。
#[must_use]
pub fn with_gate(
script: Vec<ChatReply>,
policy: ConfirmPolicy,
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
) -> Self {
Self::build(Arc::new(MockProvider::new(script)), policy, make_gate)
}
fn build(
provider: Arc<MockProvider>,
policy: ConfirmPolicy,
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
) -> Self {
let (ports, settings) = mock_ports();
let registry =
Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功"));
let audit = Arc::new(MemoryAudit::new());
let events = Arc::new(MemoryEventSink::new());
let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3));
let gate = make_gate(events.clone());
let deps = BrainDeps::new(
audit.clone() as Arc<dyn AuditSink>,
gate,
policy,
clock,
events.clone() as Arc<dyn AgentEventSink>,
);
let brain = Brain::new(
provider.clone(),
Arc::clone(&registry),
deps,
BrainConfig::default(),
);
Self {
brain,
audit,
events,
provider,
settings,
registry,
}
}
}
/// 构造一条工具调用回复。
#[must_use]
pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply {
ChatReply::with_tool_calls(vec![ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments,
}])
}
/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。
pub const SESSION: &str = "20261029T124501-9f3c2a";
/// 便捷:agent 对话会话 id。
pub const AGENT_SESSION: &str = "chat-1";
/// 便捷:`list_game_sessions` 的最小参数。
#[must_use]
pub fn list_args() -> Value {
json!({ "limit": 5 })
}
+365
View File
@@ -0,0 +1,365 @@
//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。
//!
//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8
//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本;
//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。
mod support;
use std::sync::Arc;
use serde_json::{json, Value};
use support::{block_on, tool_call_reply, Harness, AGENT_SESSION};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::ChatReply;
use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate};
use unlucky_agent::event::AgentEvent;
fn set_config_reply(path: &str, value: Value) -> ChatReply {
tool_call_reply("c1", "set_config", json!({ "path": path, "value": value }))
}
fn tool_results(harness: &Harness) -> Vec<String> {
harness
.provider
.requests()
.last()
.map(|request| {
request
.messages
.iter()
.filter_map(|m| match m {
unlucky_agent::brain::ChatMessage::Tool { content, .. } => {
Some(content.clone())
}
_ => None,
})
.collect()
})
.unwrap_or_default()
}
fn confirm_events(harness: &Harness) -> usize {
harness
.events
.events()
.iter()
.filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. }))
.count()
}
#[test]
fn low_key_writes_silently_without_confirmation() {
let harness = Harness::new(vec![
set_config_reply("appearance.theme_mode", json!("metro_light")),
ChatReply::text("已切换主题"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换成浅色"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(
harness.settings.value_of("appearance.theme_mode"),
Some(json!("metro_light"))
);
assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::Ok);
assert!(!records[0].confirmed, "静默生效不是「用户确认」");
assert!(!records[0].yolo);
}
#[test]
fn medium_key_also_writes_silently() {
// M12:Medium = 静默生效但审计标记(不弹窗)
let harness = Harness::new(vec![
set_config_reply("download.region", json!("cn")),
ChatReply::text("已切到国内源"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换下载源"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0);
assert_eq!(
harness.audit.records_for("set_config")[0].outcome,
AuditOutcome::Ok
);
}
#[test]
fn high_key_is_fail_closed_without_ui() {
let harness = Harness::new(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("需要你确认"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(
harness.settings.set_calls(),
0,
"P0 无 UI 时必须 fail-closed"
);
assert_eq!(harness.settings.value_of("java.selected_path"), None);
assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出");
let outcomes: Vec<AuditOutcome> = harness
.audit
.records_for("set_config")
.iter()
.map(|r| r.outcome)
.collect();
assert_eq!(
outcomes,
vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied],
"先记「AI 想改」,再记「被拒绝」"
);
assert!(tool_results(&harness)
.iter()
.any(|content| content.contains("denied")));
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::ToolDenied { .. })));
}
#[test]
fn user_approval_writes_and_marks_confirmed() {
let harness = Harness::with_gate(
vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
],
ConfirmPolicy::default(),
|_sink| {
Arc::new(ScriptedGate::new(
[ConfirmDecision::Approved],
ConfirmDecision::Denied,
))
},
);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次");
let records = harness.audit.records_for("set_config");
let last = records.last().expect("必须有审计记录");
assert_eq!(last.outcome, AuditOutcome::Ok);
assert!(last.confirmed, "用户点了允许");
assert!(!last.yolo);
}
#[test]
fn yolo_skips_confirm_but_audits_flag() {
let harness = Harness::yolo(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1, "YOLO 只留结局记录");
assert!(records[0].yolo, "审计必须标记 yolo");
assert!(!records[0].confirmed, "自动放行不是用户确认");
assert_eq!(records[0].outcome, AuditOutcome::Ok);
}
#[test]
fn whitelist_violation_is_rejected_and_audited() {
let harness = Harness::new(vec![
set_config_reply("java.home.mystery", json!("x")),
ChatReply::text("换个方案"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改个不存在的键"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("白名单")),
"模型需要知道为什么被拒:{results:?}"
);
}
#[test]
fn invalid_args_are_rejected_before_any_side_effect() {
// 缺 value(schema required)
let harness = Harness::new(vec![
tool_call_reply(
"c1",
"set_config",
json!({ "path": "appearance.theme_mode" }),
),
ChatReply::text("参数补上"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改主题"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
assert!(tool_results(&harness)
.iter()
.any(|c| c.contains("缺少必填参数")));
// 多余参数(additionalProperties: false)
let harness = Harness::new(vec![
tool_call_reply(
"c2",
"read_game_log",
json!({ "session_id": "x", "path": "evil" }),
),
ChatReply::text("好"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "读日志"),
)
.unwrap();
let records = harness.audit.records_for("read_game_log");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
}
#[test]
fn unknown_tool_lists_available_names() {
let harness = Harness::new(vec![
tool_call_reply("c1", "delete_everything", json!({})),
ChatReply::text("抱歉"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "删库跑路"),
)
.unwrap();
let records = harness.audit.records_for("delete_everything");
assert_eq!(records[0].outcome, AuditOutcome::UnknownTool);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("get_config")),
"{results:?}"
);
}
#[test]
fn locked_credential_key_is_refused_even_under_yolo() {
// M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围
let harness = Harness::yolo(vec![
set_config_reply("ai.api_key", json!("sk-super-secret-value")),
ChatReply::text("换不了"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 key"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let args = serde_json::to_string(&records[0].args).unwrap();
assert!(
!args.contains("sk-super-secret-value"),
"审计绝不能落明文密钥:{args}"
);
assert!(args.contains("***"), "{args}");
}
#[test]
fn get_config_refuses_credential_keys() {
// F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。
let harness = Harness::new(vec![
tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })),
ChatReply::text("读不到"),
]);
// 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读)
harness
.settings
.seed_value("ai.api_key", json!("sk-live-credential-value"));
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看我的 key"),
)
.unwrap();
// 1) 工具报错并被审计
let records = harness.audit.records_for("get_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
// 2) 模型拿到的是拒绝文案,不是值
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("凭据")),
"应给出可读拒绝:{results:?}"
);
assert!(
!results
.iter()
.any(|c| c.contains("sk-live-credential-value")),
"工具结果绝不能带凭据:{results:?}"
);
// 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化)
let leaked_in_events = harness
.events
.events()
.iter()
.any(|e| format!("{e:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_events, "事件流不能带凭据明文");
let leaked_in_history = history
.iter()
.any(|m| format!("{m:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_history, "对话历史不能带凭据明文");
}