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:
AskaEth committed 2026-11-04 00:21:19 +08:00
1 parent 15eec1160d
commit a8d4cce3e2
9 files changed
+1295 -99

No files matched your search

+341
View File
@@ -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
}