fix(m174): 复审 r1 五项 —— 失败带 target、密钥消息掩码、请求体断言、桥写前重读、结构化分类

P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体
改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条
「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增
`ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版
手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。

P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。
用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。

P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例
`the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出
一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自
`ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。

P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份
文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例:
`presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的
`ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。

P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化
报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure`
(`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable`
不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例
`factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。

顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` /
`rig/tests.rs`(纯搬移)。
This commit is contained in:
AskaEth committed 2026-11-08 16:20:34 +08:00
1 parent 9cb605f316
commit 93d02ab12b
18 files changed
+840 -412

No files matched your search

@@ -30,7 +30,6 @@ impl App {
&mut self,
message: pages::settings::ai_about::Message,
) -> Task<Message> {
use pages::settings::ai::Secret;
use pages::settings::ai_about::Message;
match message {
@@ -50,8 +49,8 @@ impl App {
self.settings.ai_about.model = value;
self.touch();
}
Message::EditKey(value) => {
self.settings.ai_about.key = Secret::new(value);
Message::EditKey(secret) => {
self.settings.ai_about.key = secret;
self.touch();
}
Message::Reveal(reveal) => {
@@ -313,7 +313,9 @@ fn saving_the_ai_form_writes_the_real_settings_file() {
)));
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
let _ = app.update(ai(Ai::EditKey("sk-live-app-path".to_string())));
let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
"sk-live-app-path",
))));
let _ = app.update(ai(Ai::Save));
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
@@ -116,45 +116,79 @@ impl ProbeResult {
}),
_ => None,
};
let (kind, base_url, model, detail) = match outcome {
ProbeOutcome::NotConfigured { .. } => (
ProbeKind::NotConfigured,
String::new(),
String::new(),
String::new(),
),
ProbeOutcome::Passed { base_url, model } => {
(ProbeKind::Passed, base_url, model, String::new())
// r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上
// 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。
let (kind, target, detail) = match outcome {
ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()),
ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()),
ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail),
ProbeOutcome::ModelNotFound { target, detail } => {
(ProbeKind::ModelNotFound, Some(target), detail)
}
ProbeOutcome::Auth { detail } => {
(ProbeKind::Auth, String::new(), String::new(), detail)
}
ProbeOutcome::ModelNotFound { detail } => (
ProbeKind::ModelNotFound,
String::new(),
String::new(),
detail,
),
ProbeOutcome::Unreachable { detail } => {
(ProbeKind::Unreachable, String::new(), String::new(), detail)
}
ProbeOutcome::Timeout { detail } => {
(ProbeKind::Timeout, String::new(), String::new(), detail)
}
ProbeOutcome::Failed { detail } => {
(ProbeKind::Failed, String::new(), String::new(), detail)
ProbeOutcome::Unreachable { target, detail } => {
(ProbeKind::Unreachable, Some(target), detail)
}
ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail),
ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail),
};
Self {
kind,
missing,
base_url,
model,
base_url: target
.as_ref()
.map_or_else(String::new, |t| t.base_url.clone()),
model: target.map_or_else(String::new, |t| t.model.clone()),
elapsed_ms,
detail,
}
}
/// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。
///
/// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态
/// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于
/// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。
#[must_use]
pub fn screenshot_seed(
kind: ProbeKind,
base_url: String,
model: String,
elapsed_ms: u128,
) -> Self {
use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target};
let target = Target { base_url, model };
let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}";
let outcome = match kind {
ProbeKind::Passed => ProbeOutcome::Passed { target },
ProbeKind::Auth => ProbeOutcome::Auth {
target,
detail: detail.to_string(),
},
ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound {
target,
detail: detail.to_string(),
},
ProbeKind::Unreachable => ProbeOutcome::Unreachable {
target,
detail: "Http client error: error sending request".to_string(),
},
ProbeKind::Timeout => ProbeOutcome::Timeout {
target,
detail: "8 s".to_string(),
},
ProbeKind::Failed => ProbeOutcome::Failed {
target,
detail: detail.to_string(),
},
// 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。
ProbeKind::NotConfigured => ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::Model,
},
};
Self::from_outcome(outcome, elapsed_ms)
}
/// 失败的三类之外的那几类(`NotConfigured` 没发请求)。
#[must_use]
pub fn sent_a_request(&self) -> bool {
+14 -13
View File
@@ -334,25 +334,26 @@ impl State {
state.key_chars = 32;
}
other => {
let (kind, missing) = match other {
"verified" => (ProbeKind::Passed, None),
"failed-auth" => (ProbeKind::Auth, None),
"failed-model" => (ProbeKind::ModelNotFound, None),
"failed-unreachable" => (ProbeKind::Unreachable, None),
"failed-timeout" => (ProbeKind::Timeout, None),
let kind = match other {
"verified" => ProbeKind::Passed,
"failed-auth" => ProbeKind::Auth,
"failed-model" => ProbeKind::ModelNotFound,
"failed-unreachable" => ProbeKind::Unreachable,
"failed-timeout" => ProbeKind::Timeout,
_ => return None,
};
state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = "deepseek-chat".to_string();
state.key_chars = 32;
state.probe = Probe::Done(ProbeResult {
// r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定,
// 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」
// 这类缺陷只在产品上出现、截图永远看不见。
state.probe = Probe::Done(ProbeResult::screenshot_seed(
kind,
missing,
base_url: state.resolved_base_url(),
model: state.model.clone(),
elapsed_ms: 412,
detail: String::new(),
});
state.resolved_base_url(),
state.model.clone(),
412,
));
}
}
Some(state)
@@ -1,4 +1,4 @@
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘。
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。
//!
//! | 用例 | 守什么 | 变异 |
//! |---|---|---|
@@ -7,6 +7,7 @@
//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 |
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
use std::path::PathBuf;
@@ -127,3 +128,36 @@ fn external_edit_is_read_back() {
assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **P2-4 的用户场景原样**(r1 复审要求的用例):设置页保存 AI 配置 → 同一秒内改主题 → 值还在。
///
/// 这一条与 `presenter/src/settings_bridge.rs` 的 `persist_keeps_keys_another_writer_just_saved`
/// 守的是同一个缺陷,但从**产品的两个写者**出发:`ai::store::save`(现读现写)与
/// `SettingsBridge::persist`(长生命 store + 写前 reload)。任一方少了「写前现读」,这条红。
#[test]
fn appearance_write_back_keeps_the_ai_keys() {
use unlucky_presenter::prefs::{AppearancePrefs, ThemeMode};
use unlucky_presenter::settings_bridge::SettingsBridge;
let path = temp_config("appearance-keeps-ai");
// 外观桥先加载过一份文件(它的 store 就是从这里开始的旧快照)。
let mut bridge = SettingsBridge::with_path(&path);
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
// 用户在「助手」页保存配置(真落盘、含密钥)。
let mut draft = draft();
draft.key = Secret::new("sk-must-survive");
save(&path, &draft).expect("保存 AI 配置");
// 紧接着用户去「外观」改主题(外观桥写回它那份整文档)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"密钥被外观写回盖掉了:{text}"
);
assert!(text.contains("claude-sonnet-4"), "模型名也被盖掉了:{text}");
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
+69 -10
View File
@@ -59,6 +59,35 @@ fn secret_debug_is_masked() {
assert!(Secret::new(" ").is_empty());
}
/// **P2-2**:`Message::EditKey` 的 `Debug` 不许把密钥打进日志。
///
/// 现场(r1 复审):载荷是裸 `String`,而 harness / 断言失败输出会把页面消息 `{:?}` 打出来 ⇒
/// 明文 key 进 stderr 与 CI 日志。载荷改成 [`Secret`] 之后由它自己的 `Debug` 兜住。
///
/// 抓得住:谁把 `EditKey(String)` 改回去(`Secret` 的 Debug 掩码见 [`secret_debug_is_masked`])。
#[test]
fn the_key_message_never_prints_the_key() {
use crate::pages::settings::ai_about::Message;
const SECRET: &str = "sk-live-MSG-LEAK-7c2f";
let message = Message::EditKey(Secret::new(SECRET));
let printed = format!("{message:?}");
assert!(
!printed.contains(SECRET),
"页面消息的 Debug 泄漏了密钥(它会进日志/CI 输出):{printed}"
);
assert!(printed.contains("***"), "应当看得见掩码标记:{printed}");
// 整条投递口(`pages::Message`)也一样:它才是真的被一层层 `{:?}` 的那个类型。
let page =
crate::pages::Message::Settings(crate::pages::settings::GroupMessage::AiAbout(message));
let printed = format!("{page:?}");
assert!(
!printed.contains(SECRET),
"页面消息漏斗泄漏了密钥:{printed}"
);
}
#[test]
fn validate_refuses_bad_endpoint_and_empty_model() {
let mut state = configured();
@@ -217,58 +246,79 @@ fn saving_a_new_target_drops_the_old_pass() {
assert_eq!(state.status(), Status::Configured);
}
/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。
///
/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷——
/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。
#[test]
fn not_configured_outcome_carries_no_target() {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome};
fn failures_carry_the_target_that_was_dialed() {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target};
let result = ProbeResult::from_outcome(
let target = || Target {
base_url: "https://api.deepseek.com/v1".to_string(),
model: "deepseek-chat".to_string(),
};
let not_configured = ProbeResult::from_outcome(
ProbeOutcome::NotConfigured {
missing: Missing::Model,
},
0,
);
assert_eq!(result.kind, ProbeKind::NotConfigured);
assert_eq!(result.missing, Some(MissingField::Model));
assert!(result.base_url.is_empty(), "没发请求就没有目标地址");
assert!(result.model.is_empty());
assert!(!result.sent_a_request());
assert_eq!(not_configured.kind, ProbeKind::NotConfigured);
assert_eq!(not_configured.missing, Some(MissingField::Model));
assert!(not_configured.base_url.is_empty(), "没发请求就没有目标地址");
assert!(not_configured.model.is_empty());
assert!(!not_configured.sent_a_request());
// 四类失败各自翻译过来,且都不带目标(它们没有「成功的目标」可说)。
for (outcome, expected) in [
(
ProbeOutcome::Auth {
target: target(),
detail: "401".to_string(),
},
ProbeKind::Auth,
),
(
ProbeOutcome::ModelNotFound {
target: target(),
detail: "404".to_string(),
},
ProbeKind::ModelNotFound,
),
(
ProbeOutcome::Unreachable {
target: target(),
detail: "refused".to_string(),
},
ProbeKind::Unreachable,
),
(
ProbeOutcome::Timeout {
target: target(),
detail: "8 s".to_string(),
},
ProbeKind::Timeout,
),
(
ProbeOutcome::Failed {
target: target(),
detail: "?".to_string(),
},
ProbeKind::Failed,
),
(ProbeOutcome::Passed { target: target() }, ProbeKind::Passed),
] {
let result = ProbeResult::from_outcome(outcome, 5);
assert_eq!(result.kind, expected);
assert!(result.sent_a_request());
assert_eq!(
result.base_url, "https://api.deepseek.com/v1",
"{expected:?}"
);
assert_eq!(result.model, "deepseek-chat", "{expected:?}");
if expected != ProbeKind::Passed {
assert!(result.sent_a_request());
}
}
}
@@ -288,6 +338,15 @@ fn screenshot_seed_covers_the_state_matrix() {
let failed = State::screenshot_seed("failed-auth").expect("种子");
assert_eq!(failed.status(), Status::Failed);
// P2-1:种子的**形状**要走产品路径(`ProbeResult::from_outcome`),所以失败态也带 target。
// 上一版种子手填字段、把 target 填满,于是「产品上失败态目标是空串」这个缺陷截图看不见。
if let Probe::Done(result) = &failed.probe {
assert_eq!(result.base_url, "https://api.deepseek.com/v1");
assert_eq!(result.model, "deepseek-chat");
assert!(!result.detail.is_empty(), "失败态应当带一句脱敏详情");
} else {
panic!("失败种子没带上探测结论");
}
assert!(
State::screenshot_seed("nonsense").is_none(),
"未知种子不伪造状态"
+20 -16
View File
@@ -27,7 +27,7 @@ use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::settings::ai::outcome::ProbeKind;
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, State, Status};
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status};
use crate::pages::settings::{ai_about, GroupMessage, States};
use crate::pages::Message;
use crate::theme::Ui;
@@ -64,20 +64,24 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
}
if let Probe::Done(result) = &state.probe {
stacked = stacked.push(probe_line(ui, catalog, result.kind));
stacked = stacked.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsAiTestTarget,
&[
("base_url", result.base_url.as_str()),
("model", result.model.as_str()),
("ms", result.elapsed_ms.to_string().as_str()),
],
),
ui.color(Role::OnSurfaceVariant),
));
// 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target,
// 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。
if !(result.base_url.is_empty() && result.model.is_empty()) {
stacked = stacked.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsAiTestTarget,
&[
("base_url", result.base_url.as_str()),
("model", result.model.as_str()),
("ms", result.elapsed_ms.to_string().as_str()),
],
),
ui.color(Role::OnSurfaceVariant),
));
}
if !result.detail.trim().is_empty() {
stacked = stacked.push(text_role_owned(
ui,
@@ -240,7 +244,7 @@ fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a,
),
);
let field = container(text_field(ui, field, |value| {
msg(ai_about::Message::EditKey(value))
msg(ai_about::Message::EditKey(Secret::new(value)))
}))
.width(Length::Fill);
column![
@@ -54,8 +54,12 @@ pub enum Message {
EditBaseUrl(String),
/// 模型名草稿(`ai.model`)
EditModel(String),
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)
EditKey(String),
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。
///
/// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness
/// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里,
/// 用裸 `String` 等于给密钥开了一条到日志的路。
EditKey(super::ai::Secret),
/// 密钥输入框明文/掩码
Reveal(bool),
/// 保存(校验 + 落盘)