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:
1 parent
a8d4cce3e2
commit
06461df91b
5 files changed
+435
-96
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user