feat(presenter): M103 AI 助手栏视图模型与事件投影
- 新增 crates/presenter/src/agent.rs(Presenter 门面:agent_rail / agent_submit /
agent_mark_read / agent_confirm + AgentTransport 端口 + 裁决审计镜像)
- 新增 agent/rail.rs:AgentRail(messages / unread / busy / draft / confirm / streaming /
yolo / endpoint_label / error),四类 AgentEvent 的投影与唯一脱敏闸门
- 新增 agent/mock.rs:脚本化后端(正常问答 / 流式中途出错 / 需确认的写操作)
- dto/agent.rs:ConfirmRequest / Sensitivity + AgentEvent::{Failed, ConfirmRequested,
ConfirmResolved};AgentEvent 去掉 Eq(details 含 serde_json::Value)
- state.rs:AiSession 迁成 AgentRail,apply_agent 走 rail.ingest + 未读规则(可见性 =
独立页或栏展开);navigate/toggle_ai 展开时清未读
- 测试:agent/tests.rs 14 条(四条路径 + JWT 红线 + YOLO 语义 + 提交前置条件),
state_tests.rs 补 4 条(Failed / ConfirmRequested / 未读可见性 / 用户消息不计未读)
- 两行声明补进 lib.rs(pub mod agent)与 dto/mod.rs(重导出 ConfirmRequest/Sensitivity)
This commit is contained in:
1 parent
15eec1160d
commit
a8d4cce3e2
9 files changed
+1295
-99
No files matched your search
@@ -0,0 +1,341 @@
|
||||
//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+
|
||||
//! 红线(凭据形状的串不得进 rail)+ YOLO 语义 + 提交前置条件。
|
||||
//!
|
||||
//! 断言口径照 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)))
|
||||
}
|
||||
|
||||
// ───────────────────────── 路径 1:正常问答 ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normal_answer_accumulates_into_one_assistant_message() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
assert!(presenter.agent_submit("怎么装 1.20.4".to_string()).is_ok());
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
// 抓得住:用户气泡 + 一条助手消息(增量累积在同一条上,不是一个字一个气泡)
|
||||
assert_eq!(rail.messages.len(), 2);
|
||||
assert_eq!(rail.messages[0].role, ChatRole::User);
|
||||
assert_eq!(rail.messages[0].text, "怎么装 1.20.4");
|
||||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||||
assert_eq!(
|
||||
rail.messages[1].text,
|
||||
"先确认版本:官方 1.20.4 是正式版,装完再加 Fabric。"
|
||||
);
|
||||
assert!(!rail.messages[1].streaming);
|
||||
assert!(!rail.streaming);
|
||||
assert!(!rail.busy);
|
||||
assert!(rail.confirm.is_none());
|
||||
assert!(rail.error.is_none());
|
||||
// 抓不住:不校验助手措辞是否正确(那是工具/模型的事)
|
||||
}
|
||||
|
||||
// ─────────────────── 路径 2:流式中途出错 ───────────────────
|
||||
|
||||
#[test]
|
||||
fn mid_stream_error_keeps_partial_text_and_flags_the_turn() {
|
||||
let mut presenter = presenter(Script::FailsMidStream);
|
||||
assert!(presenter.agent_submit("日志里有错吗".to_string()).is_ok());
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
// 抓得住:已到的增量不丢、流式收敛(不会永远转圈)、错误进横幅也进会话历史
|
||||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||||
assert_eq!(rail.messages[1].text, "我先读一下最近的日志");
|
||||
assert!(!rail.streaming);
|
||||
assert!(!rail.busy, "出错后输入框必须重新可用");
|
||||
assert_eq!(rail.error.as_deref(), Some("provider 断流:read timeout"));
|
||||
assert_eq!(rail.messages[2].role, ChatRole::System);
|
||||
assert_eq!(rail.messages[2].text, "provider 断流:read timeout");
|
||||
// 抓不住:不判断这是网络问题还是模型问题(归因在 core::diagnose)
|
||||
}
|
||||
|
||||
// ───────────── 路径 3a/3b:需确认的写操作(接受 / 拒绝)─────────────
|
||||
|
||||
#[test]
|
||||
fn write_op_accept_applies_and_audits() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
assert!(presenter.agent_submit("并发调小点".to_string()).is_ok());
|
||||
|
||||
// 确认之前:rail 挂着请求,且 UI 靠 busy 禁用输入
|
||||
{
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.busy);
|
||||
let request = rail.confirm.as_ref().expect("应当有待确认请求");
|
||||
assert_eq!(request.tool, "set_config");
|
||||
assert_eq!(request.details["path"], "download.concurrency");
|
||||
assert!(request.sensitivity.needs_confirm());
|
||||
assert_eq!(rail.messages.len(), 3); // user / assistant / tool
|
||||
assert_eq!(rail.messages[2].role, ChatRole::Tool);
|
||||
}
|
||||
|
||||
presenter.agent_confirm(true);
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_none());
|
||||
assert!(!rail.busy);
|
||||
assert_eq!(rail.messages[2].text, "已写入 download.concurrency = 16");
|
||||
assert!(rail.messages[2].tool.as_ref().is_some_and(|tool| tool.ok));
|
||||
assert_eq!(
|
||||
rail.messages.last().map(|message| message.role),
|
||||
Some(ChatRole::Assistant)
|
||||
);
|
||||
|
||||
let audit = presenter.audit();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert_eq!(audit[0].verdict, ConfirmVerdict::Approved);
|
||||
assert!(!audit[0].yolo && audit[0].confirmed, "用户亲手点的允许");
|
||||
assert_eq!(audit[0].sensitivity, Sensitivity::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_op_reject_denies_and_audits() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
assert!(presenter.agent_submit("并发调小点".to_string()).is_ok());
|
||||
presenter.agent_confirm(false);
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_none());
|
||||
assert!(!rail.busy);
|
||||
assert_eq!(rail.messages[2].text, "用户拒绝,未写入");
|
||||
assert!(rail.messages[2].tool.as_ref().is_some_and(|tool| !tool.ok));
|
||||
assert_eq!(
|
||||
rail.messages.last().map(|text| text.text.clone()),
|
||||
Some("那就不改了。".to_string())
|
||||
);
|
||||
|
||||
let audit = presenter.audit();
|
||||
assert_eq!(audit[0].verdict, ConfirmVerdict::Denied);
|
||||
assert!(!audit[0].yolo && audit[0].confirmed);
|
||||
}
|
||||
|
||||
// ─────────────────────── 路径 4:未读计数 ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn unread_grows_while_collapsed_and_mark_read_clears() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
presenter.state_mut().prefs.ai_collapsed = true; // 收成 icon 轨 = 用户离开了栏
|
||||
presenter
|
||||
.agent_submit("并发调小点".to_string())
|
||||
.expect("提交应当成功");
|
||||
|
||||
// 抓得住:助手消息 + 工具条目 + 待确认各算一条;用户自己的话与纯增量不算
|
||||
assert_eq!(presenter.agent_rail().unread, 3);
|
||||
|
||||
presenter.agent_confirm(true);
|
||||
// 工具收尾不是新内容,收尾的助手消息算一条
|
||||
assert_eq!(presenter.agent_rail().unread, 4);
|
||||
|
||||
presenter.agent_mark_read();
|
||||
assert_eq!(presenter.agent_rail().unread, 0);
|
||||
}
|
||||
|
||||
// ───────────────────────── YOLO 语义 ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn yolo_auto_accepts_but_still_records_the_verdict() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
presenter.state_mut().ai.yolo = true;
|
||||
presenter
|
||||
.agent_submit("并发调小点".to_string())
|
||||
.expect("提交应当成功");
|
||||
|
||||
// 用户点了「拒绝」,但 YOLO 下自动放行
|
||||
presenter.agent_confirm(false);
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_none());
|
||||
assert_eq!(rail.messages[2].text, "已写入 download.concurrency = 16");
|
||||
|
||||
// 不得静默跳过:审计里必须有这一条,且两种标记分开记
|
||||
let audit = presenter.audit();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert_eq!(audit[0].verdict, ConfirmVerdict::Approved);
|
||||
assert!(audit[0].yolo, "YOLO 自动放行要留痕");
|
||||
assert!(!audit[0].confirmed, "自动放行不是「用户确认」");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_confirm_forces_busy_without_yolo() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
assert!(!presenter.agent_rail().yolo);
|
||||
presenter
|
||||
.agent_submit("并发调小点".to_string())
|
||||
.expect("提交应当成功");
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_some());
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_text_is_idempotent_on_already_redacted_text() {
|
||||
let once = scrub_text(JWT);
|
||||
assert_eq!(scrub_text(&once), once);
|
||||
}
|
||||
|
||||
// ─────────────────────── 提交前置条件 ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn submit_rejects_empty_busy_and_pending_confirm() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
assert!(presenter.agent_submit(" \n ".to_string()).is_err());
|
||||
assert!(presenter.agent_rail().messages.is_empty());
|
||||
|
||||
// 上一轮还在跑
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
assert!(presenter.agent_submit("再说一句".to_string()).is_err());
|
||||
|
||||
// 有写操作待确认
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::Failed {
|
||||
message: "断流".to_string(),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-1".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "改内存".to_string(),
|
||||
details: serde_json::json!({}),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
let error = presenter.agent_submit("再说一句".to_string());
|
||||
assert!(error.is_err(), "待确认时不许再提交");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_keeps_the_draft_when_the_backend_fails() {
|
||||
let mut presenter = Presenter::new(Box::new(BrokenTransport));
|
||||
presenter.state_mut().ai.draft = "还没发出去的话".to_string();
|
||||
assert!(presenter
|
||||
.agent_submit("还没发出去的话".to_string())
|
||||
.is_err());
|
||||
// 抓得住:起不来的一轮不产生事件、草稿保留(用户能改完再发)
|
||||
assert!(presenter.agent_rail().messages.is_empty());
|
||||
assert_eq!(presenter.agent_rail().draft, "还没发出去的话");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_is_cleared_only_on_successful_submit() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.state_mut().ai.draft = " 怎么装 ".to_string();
|
||||
presenter
|
||||
.agent_submit(" 怎么装 ".to_string())
|
||||
.expect("提交应当成功");
|
||||
assert!(presenter.agent_rail().draft.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_without_pending_is_a_noop() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.agent_confirm(true);
|
||||
presenter.agent_confirm(false);
|
||||
assert!(presenter.audit().is_empty());
|
||||
assert!(presenter.agent_rail().messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_forwards_every_event_to_app_state() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
assert_eq!(presenter.state().ai.messages.len(), 1);
|
||||
assert_eq!(presenter.agent_rail(), &presenter.state().ai);
|
||||
}
|
||||
|
||||
// ───────────────────────── 测试替身 ─────────────────────────
|
||||
|
||||
/// 起不来的后端(返回 `Err`,不产生任何事件)。
|
||||
struct BrokenTransport;
|
||||
|
||||
impl AgentTransport for BrokenTransport {
|
||||
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
Err("本地模型还没加载".to_string())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 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);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user