fix(presenter): M103 r1 —— 脱敏闸门补守卫,P2 五条带走

P1(守卫缺失,产品代码本来是对的):
- 新增 agent/tests_red_line.rs:MessageAppended(绕过 agent_submit)、
  ToolCallFinished(name+summary)、非 JWT 形状的 details(键名 / 嵌套 / 数组 /
  值随 path 敏感)各一条;变异 A/H/C2 复跑确认变红
- tests.rs 拆出红线组(547 → 334 行,单文件 ≤400)

P2:
- scrub_details 补「值随 path 敏感」规则(对齐 agent::redact::redact_args)
- scrub_text 补短 Bearer 掩码(对齐 agent::redact::mask_bearer)
- pending_confirm_forces_busy_without_yolo 先 finish_stream 再断言(不再空过),
  变异 F 现在两条测试同时变红
- MessageDelta 越界下标 debug_assert;ToolCallFinished 没有 pending 时忽略,
  不再改写已收敛的历史条目
- Presenter::state_mut() 收窄成 #[cfg(test)],换成 navigate/back/toggle_ai/
  prefs_mut/agent_set_yolo/agent_set_draft 等窄入口
- agent_submit 成功后立刻 begin_turn() 置忙,事件投影完 settle_streaming() 重算,
  异步后端空窗不会再让用户点两次发送
- 补测:begin_turn 置忙、静默后端不留假忙、工具收尾不覆盖历史
This commit is contained in:
AskaEth committed 2026-11-04 00:21:19 +08:00
1 parent a8d4cce3e2
commit 06461df91b
5 files changed
+435 -96

No files matched your search

+63 -70
View File
@@ -1,18 +1,15 @@
//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+
//! 红线(凭据形状的串不得进 rail)+ YOLO 语义 + 提交前置条件。
//! YOLO 语义 + 提交前置条件 + 投影健壮性。
//!
//! 红线(凭据形状的串不得进 rail)单列在 `agent/tests_red_line.rs`(单文件 ≤400 行)。
//! 断言口径照 M103 的要求写清「抓得住什么 / 抓不住什么」:这些测试盯的是**投影与裁决**,
//! 不盯真实模型的措辞、真实时序与网络错误——那些要接 rig 之后跑回放回归。
use super::mock::{MockAgentTransport, Script};
use super::{AgentTransport, ConfirmVerdict, Presenter};
use crate::agent::rail::scrub_text;
use crate::agent::AgentRail;
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
fn presenter(script: Script) -> Presenter {
Presenter::new(Box::new(MockAgentTransport::new(script)))
}
@@ -172,65 +169,68 @@ fn pending_confirm_forces_busy_without_yolo() {
presenter
.agent_submit("并发调小点".to_string())
.expect("提交应当成功");
assert!(presenter.agent_rail().confirm.is_some());
// 先把流式标记清干净:否则 `busy` 可能只是「streaming 顺手置真」,
// 这条测试就钉不住「待确认算忙」这条规则(r1 P2-2:变异掉 `confirm.is_some()`
// 之后这里必须变红)。
presenter.state_mut().ai.finish_stream();
let rail = presenter.agent_rail();
assert!(rail.confirm.is_some());
assert!(!rail.streaming, "此刻没有流式消息在跑");
assert!(rail.busy, "非 YOLO 下待确认必须算忙,UI 靠它禁用输入");
}
// ───────────────────── 红线:凭据形状的串 ─────────────────────
#[test]
fn jwt_shaped_text_never_reaches_the_rail() {
let mut presenter = presenter(Script::Qa);
// 五个入口各注入一次:用户提交、增量、工具摘要、错误、确认请求细节
presenter
.agent_submit(format!("我的 token 是 {JWT}"))
.expect("提交应当成功");
presenter.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 1,
delta: format!("顺手贴一个 {JWT}"),
}));
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "read_game_log".to_string(),
summary: format!("Authorization: Bearer {JWT}"),
}));
presenter.apply(AppEvent::Agent(AgentEvent::Failed {
message: format!("401: access_token={JWT}"),
}));
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
request: ConfirmRequest {
id: "req-jwt".to_string(),
tool: "set_config".to_string(),
summary: format!("把 access_token 改成 {JWT}"),
details: serde_json::json!({ "access_token": JWT, "note": JWT }),
sensitivity: Sensitivity::High,
},
}));
fn begin_turn_marks_busy_before_any_event_arrives() {
// 提交成功到第一个事件之间异步后端有空窗:这段时间必须已经算忙,
// 否则用户能再点一次发送(两条 user 消息、两轮回复)。
let mut rail = AgentRail::new();
rail.begin_turn();
assert!(rail.busy && rail.streaming);
let rail = presenter.agent_rail();
let text = rail_text(rail);
assert!(
!text.contains("eyJhbGci"),
"rail 上不得出现 JWT 原文:{text}"
);
assert!(
text.contains("<redacted:token>"),
"应当被脱敏成占位符:{text}"
);
// 凭据键名的值整段打码(不只是靠内容规则)
let details = &rail.confirm.as_ref().expect("确认请求仍在").details;
assert_eq!(details["access_token"], "<redacted:token>");
assert!(!details["note"]
.as_str()
.unwrap_or_default()
.contains("eyJhbGci"));
// 空事件流也不会把输入框永久锁死:收敛时按消息重算
rail.settle_streaming();
assert!(!rail.busy && !rail.streaming);
}
#[test]
fn scrub_text_is_idempotent_on_already_redacted_text() {
let once = scrub_text(JWT);
assert_eq!(scrub_text(&once), once);
fn submit_with_a_silent_backend_does_not_leave_the_shell_busy() {
let mut presenter = Presenter::new(Box::new(SilentTransport));
presenter
.agent_submit("在吗".to_string())
.expect("提交应当成功");
assert!(
!presenter.agent_rail().busy,
"后端一个事件都没回,输入框必须重新可用"
);
}
/// 工具收尾事件早于开始事件(或重复投递):不许覆盖已收敛的历史条目(r1 P2-3)。
#[test]
fn tool_call_finished_without_a_pending_call_never_rewrites_history() {
let mut presenter = presenter(Script::Qa);
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "set_config".to_string(),
summary: "修改 download.concurrency".to_string(),
}));
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "set_config".to_string(),
ok: true,
summary: "并发已改成 16".to_string(),
}));
// 重放 / 乱序:没有 pending 的收尾事件只能被忽略
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "set_config".to_string(),
ok: false,
summary: "另一次失败".to_string(),
}));
let tool = presenter.agent_rail().messages[0]
.tool
.as_ref()
.expect("工具条目");
assert_eq!(tool.summary, "并发已改成 16", "历史条目被改写了");
assert!(tool.ok);
}
// ─────────────────────── 提交前置条件 ───────────────────────
@@ -320,22 +320,15 @@ impl AgentTransport for BrokenTransport {
}
}
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
fn rail_text(rail: &AgentRail) -> String {
let mut out = String::new();
for message in &rail.messages {
out.push_str(&message.text);
if let Some(tool) = &message.tool {
out.push_str(&tool.name);
out.push_str(&tool.summary);
}
/// 收下输入但一个事件都不回的后端(异步推流的极端形态:这一拍还没到)。
struct SilentTransport;
impl AgentTransport for SilentTransport {
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
Ok(Vec::new())
}
out.push_str(rail.error.as_deref().unwrap_or_default());
out.push_str(&rail.endpoint_label);
if let Some(confirm) = &rail.confirm {
out.push_str(&confirm.tool);
out.push_str(&confirm.summary);
out.push_str(&confirm.details.to_string());
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
Vec::new()
}
out
}