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,165 @@
|
||||
//! mock 版 agent 后端:四条路径的脚本化事件流(正常问答 / 流式中途出错 / 需确认的写操作 /
|
||||
//! 未读计数)。
|
||||
//!
|
||||
//! 与 [`crate::mock::MockBackend`] 的分工:那个是**整机演示后端**(按 tick 推进剧情,带定时器);
|
||||
//! 这个是 [`crate::agent::AgentTransport`] 的最小实现,**同步返回整轮事件分片**,专供 presenter
|
||||
//! 单测与 UI 壳的回归预览(没有真实 LLM 也能把栏画全)。
|
||||
//!
|
||||
//! 抓得住什么:事件顺序、投影结果(消息/工具/确认/未读/审计)、脱敏闸门。
|
||||
//! 抓不住什么:真实时序(这里一次性返回,真实后端是逐帧推流)、真实模型的措辞与工具选择、
|
||||
//! 网络/超时/取消语义——那些要接 rig 之后跑回放回归。
|
||||
|
||||
use crate::agent::{AgentRail, AgentTransport};
|
||||
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||
|
||||
/// 脚本:一轮输入会产生哪种剧情。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
/// 正常问答:流式增量收敛成一条完整助手消息
|
||||
Qa,
|
||||
/// 流式中途断流:已到的增量保留,整轮以 `Failed` 收尾
|
||||
FailsMidStream,
|
||||
/// 需确认的写操作:工具调用开始后挂一个 High 级确认请求
|
||||
WriteOp,
|
||||
}
|
||||
|
||||
/// 脚本化后端。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockAgentTransport {
|
||||
script: Script,
|
||||
/// 已发起过几轮(mock 用它算消息下标;真实后端自己维护会话)
|
||||
turns: usize,
|
||||
/// 当前待确认的写操作(`None` = 没有)
|
||||
pending: Option<ConfirmRequest>,
|
||||
}
|
||||
|
||||
impl MockAgentTransport {
|
||||
/// 指定脚本。
|
||||
#[must_use]
|
||||
pub fn new(script: Script) -> Self {
|
||||
Self {
|
||||
script,
|
||||
turns: 0,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 正常问答。
|
||||
#[must_use]
|
||||
pub fn qa() -> Self {
|
||||
Self::new(Script::Qa)
|
||||
}
|
||||
|
||||
/// 流式中途出错。
|
||||
#[must_use]
|
||||
pub fn fails_mid_stream() -> Self {
|
||||
Self::new(Script::FailsMidStream)
|
||||
}
|
||||
|
||||
/// 需确认的写操作(改下载并发,High 级)。
|
||||
#[must_use]
|
||||
pub fn write_op() -> Self {
|
||||
Self::new(Script::WriteOp)
|
||||
}
|
||||
|
||||
/// 当前待确认请求(测试断言用)。
|
||||
#[must_use]
|
||||
pub fn pending(&self) -> Option<&ConfirmRequest> {
|
||||
self.pending.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentTransport for MockAgentTransport {
|
||||
fn submit(&mut self, text: &str, history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
self.turns += 1;
|
||||
// 后端自己算下标:用户气泡先到,助手流式占位紧随其后。
|
||||
let assistant_index = history.messages.len() + 1;
|
||||
let mut events = vec![
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage::user(text))),
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage::assistant_stream())),
|
||||
];
|
||||
match self.script {
|
||||
Script::Qa => {
|
||||
for delta in [
|
||||
"先确认版本:",
|
||||
"官方 1.20.4 是正式版,",
|
||||
"装完再加 Fabric。",
|
||||
] {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant_index,
|
||||
delta: delta.to_string(),
|
||||
}));
|
||||
}
|
||||
events.push(agent_event(AgentEvent::MessageFinished {
|
||||
index: assistant_index,
|
||||
}));
|
||||
}
|
||||
Script::FailsMidStream => {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant_index,
|
||||
delta: "我先读一下最近的日志".to_string(),
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::Failed {
|
||||
message: "provider 断流:read timeout".to_string(),
|
||||
}));
|
||||
}
|
||||
Script::WriteOp => {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant_index,
|
||||
delta: "我准备把下载并发从 64 改成 16。".to_string(),
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::MessageFinished {
|
||||
index: assistant_index,
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::ToolCallStarted {
|
||||
name: "set_config".to_string(),
|
||||
summary: "修改 download.concurrency".to_string(),
|
||||
}));
|
||||
let request = ConfirmRequest {
|
||||
id: format!("req-{}", self.turns),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "把下载并发 64 → 16".to_string(),
|
||||
details: serde_json::json!({
|
||||
"path": "download.concurrency",
|
||||
"old": 64,
|
||||
"new": 16,
|
||||
}),
|
||||
sensitivity: Sensitivity::High,
|
||||
};
|
||||
self.pending = Some(request.clone());
|
||||
events.push(agent_event(AgentEvent::ConfirmRequested { request }));
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent> {
|
||||
self.pending = None;
|
||||
let (ok, summary, reply) = if accept {
|
||||
(true, "已写入 download.concurrency = 16", "改好了。")
|
||||
} else {
|
||||
(false, "用户拒绝,未写入", "那就不改了。")
|
||||
};
|
||||
vec![
|
||||
agent_event(AgentEvent::ToolCallFinished {
|
||||
name: "set_config".to_string(),
|
||||
ok,
|
||||
summary: summary.to_string(),
|
||||
}),
|
||||
agent_event(AgentEvent::ConfirmResolved {
|
||||
id: request_id.to_string(),
|
||||
}),
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: reply.to_string(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 包一层 [`AppEvent::Agent`](presenter 的事件队列只认顶层事件)。
|
||||
fn agent_event(event: AgentEvent) -> AppEvent {
|
||||
AppEvent::Agent(event)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! AI 助手栏(D16 双形态:右侧常驻栏 / 独立页)的**唯一**视图模型。
|
||||
//!
|
||||
//! 两形态共用同一份 [`AgentRail`]:折叠/展开、停靠/拆页都只是换渲染位置,切形态不丢会话。
|
||||
//!
|
||||
//! # 1. 事件从哪里来、到哪里去
|
||||
//!
|
||||
//! ```text
|
||||
//! agent/host 后端 ──AgentEvent──► AppState::apply ──► AgentRail::ingest ──► 字段
|
||||
//! └──► unread(栏不可见时累加)
|
||||
//! ```
|
||||
//!
|
||||
//! 投影规则(四类事件各归各位):
|
||||
//!
|
||||
//! | 事件 | 落到哪 |
|
||||
//! | ---- | ------ |
|
||||
//! | `MessageAppended` | 追加一条消息;`streaming` 的置 `rail.streaming` |
|
||||
//! | `MessageDelta` | 追加到最后一条 assistant 消息的正文(**累积在一条上**,不是每条一个气泡) |
|
||||
//! | `MessageFinished` | 清掉该条(以及全部)流式标记,`streaming` 由消息重算 |
|
||||
//! | `ToolCallStarted` / `ToolCallFinished` | 生成 / 收敛 [`ToolCallView`] 条目(D4:工具调用会话可见) |
|
||||
//! | `Failed` | 收敛流式 + 立横幅 + 追加系统条目(错误也要能回翻) |
|
||||
//! | `ConfirmRequested` / `ConfirmResolved` | `rail.confirm` 置位 / 清除 |
|
||||
//!
|
||||
//! # 2. 红线:进入 rail 的文本一律脱敏
|
||||
//!
|
||||
//! [`AgentRail::ingest`] 是所有 agent 文本进入 rail 的**唯一闸门**,它逐字段过
|
||||
//! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` /
|
||||
//! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是
|
||||
//! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,rail 上也不会出现原文。
|
||||
//! 复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」规则),不另写一套正则。
|
||||
//!
|
||||
//! # 3. 刻意不做的事
|
||||
//!
|
||||
//! - **不裁剪 `messages`**:`MessageDelta` 用下标寻址,从头丢弃会让下标漂移。会话长度由后端
|
||||
//! 的轮次上限控制;这里只保证下标稳定。
|
||||
//! - **不持有后端**:rail 是纯数据 + 投影,提交/确认在 [`crate::agent::Presenter`]。
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER};
|
||||
|
||||
use crate::dto::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, ToolCallView};
|
||||
|
||||
/// 键名命中即整值打码(与 `unlucky_agent::redact::is_sensitive_key` 同一份名单)。
|
||||
///
|
||||
/// 为什么在这里再写一遍:presenter 不依赖 `unlucky-agent`(那会把 rig / tokio / rmcp 整棵树
|
||||
/// 拖进共用宿主层)。名单漂移的风险已记进 M103 报告,长期该由 core 出一份公共实现。
|
||||
const SENSITIVE_KEY_NEEDLES: [&str; 8] = [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"authorization",
|
||||
"credential",
|
||||
];
|
||||
|
||||
/// 文本是否像凭据键名(`access_token` / `refresh_token` / `microsoft.password` …)。
|
||||
#[must_use]
|
||||
pub fn is_sensitive_key(key: &str) -> bool {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
SENSITIVE_KEY_NEEDLES
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
/// 文本脱敏:JWT 形状(带点 token)/ ≥64 不透明串由 core 的规则打码。
|
||||
///
|
||||
/// 不登记主目录(rail 不持有数据根):`<home>` 掩码是 host 侧的职责。
|
||||
#[must_use]
|
||||
pub fn scrub_text(text: &str) -> String {
|
||||
Redactor::new().scrub(text).into_owned()
|
||||
}
|
||||
|
||||
/// 结构化细节脱敏:先按键名掩码,再按内容掩码(两道都要,见模块文档 §2)。
|
||||
#[must_use]
|
||||
pub fn scrub_details(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, child) in map {
|
||||
let masked = if is_sensitive_key(key) {
|
||||
Value::String(TOKEN_PLACEHOLDER.to_string())
|
||||
} else {
|
||||
scrub_details(child)
|
||||
};
|
||||
out.insert(key.clone(), masked);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(scrub_details).collect()),
|
||||
Value::String(text) => Value::String(scrub_text(text)),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 助手栏视图模型(UI 壳只读它,不自己攒会话状态)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AgentRail {
|
||||
/// 会话消息(user / assistant / tool / system),下标稳定
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 栏不可见期间攒下的未读条数(`agent_mark_read` 清零)
|
||||
pub unread: usize,
|
||||
/// 是否该禁用输入(一轮回复进行中,或有写操作待确认)
|
||||
pub busy: bool,
|
||||
/// 输入框草稿(切形态不丢)
|
||||
pub draft: String,
|
||||
/// 待确认的写操作(`Some` = 弹确认框;非 YOLO 下 `busy` 必为 true)
|
||||
pub confirm: Option<ConfirmRequest>,
|
||||
/// 是否有流式消息在跑(Slint 壳读它切换「发送/停止」;新壳用 `busy` 即可)
|
||||
pub streaming: bool,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
pub yolo: bool,
|
||||
/// 端点标签(栏顶显示)
|
||||
pub endpoint_label: String,
|
||||
/// 最近一次错误(横幅;下一次提交或 ingest 到新消息时清掉)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentRail {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentRail {
|
||||
/// 空会话。默认端点标签是 P0 的本地 llama.cpp(P1 由设置页决定)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
unread: 0,
|
||||
busy: false,
|
||||
draft: String::new(),
|
||||
confirm: None,
|
||||
streaming: false,
|
||||
yolo: false,
|
||||
endpoint_label: "llama.cpp · 本地".to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式收敛:清掉全部流式标记并按消息重算 `streaming` / `busy`。
|
||||
pub fn finish_stream(&mut self) {
|
||||
for message in &mut self.messages {
|
||||
message.streaming = false;
|
||||
}
|
||||
self.streaming = false;
|
||||
self.sync_busy();
|
||||
}
|
||||
|
||||
/// 清零未读(用户正在看 / 主动点掉红点)。
|
||||
pub fn mark_read(&mut self) {
|
||||
self.unread = 0;
|
||||
}
|
||||
|
||||
/// 取走待确认请求(`confirm` 置空并按需重算 `busy`);没有则返回 `None`。
|
||||
pub fn take_confirm(&mut self) -> Option<ConfirmRequest> {
|
||||
let request = self.confirm.take();
|
||||
self.sync_busy();
|
||||
request
|
||||
}
|
||||
|
||||
/// 投影一条 agent 事件。
|
||||
///
|
||||
/// 返回 `true` 表示这是一条「用户不在栏前也会想知道」的新内容(未读计数的原料):
|
||||
/// 助手/工具消息、错误、确认请求算;用户自己说的话与纯增量不算。
|
||||
pub fn ingest(&mut self, event: &AgentEvent) -> bool {
|
||||
match event {
|
||||
AgentEvent::MessageAppended(message) => {
|
||||
let message = scrub_message(message);
|
||||
let fresh = matches!(message.role, ChatRole::Assistant | ChatRole::Tool);
|
||||
if message.streaming {
|
||||
self.streaming = true;
|
||||
}
|
||||
if fresh {
|
||||
self.error = None;
|
||||
}
|
||||
self.messages.push(message);
|
||||
self.sync_busy();
|
||||
fresh
|
||||
}
|
||||
AgentEvent::MessageDelta { index, delta } => {
|
||||
// 只追加正文,不动流式标记:迟到的尾增量(`Failed` 之后)不该把
|
||||
// 「一轮在跑」重新点亮,否则输入框会一直禁用。
|
||||
if let Some(message) = self.messages.get_mut(*index) {
|
||||
message.text.push_str(&scrub_text(delta));
|
||||
}
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
AgentEvent::MessageFinished { index } => {
|
||||
if let Some(message) = self.messages.get_mut(*index) {
|
||||
message.streaming = false;
|
||||
}
|
||||
// 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄
|
||||
self.streaming = self.messages.iter().any(|message| message.streaming);
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
AgentEvent::ToolCallStarted { name, summary } => {
|
||||
let summary = scrub_text(summary);
|
||||
self.streaming = true;
|
||||
self.error = None;
|
||||
self.messages.push(ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: summary.clone(),
|
||||
tool: Some(ToolCallView {
|
||||
name: scrub_text(name),
|
||||
ok: false,
|
||||
summary,
|
||||
}),
|
||||
streaming: true,
|
||||
});
|
||||
self.sync_busy();
|
||||
true
|
||||
}
|
||||
AgentEvent::ToolCallFinished { name, ok, summary } => {
|
||||
let name = scrub_text(name);
|
||||
let summary = scrub_text(summary);
|
||||
// 优先收敛「还没结束的同名调用」(并发同名工具时按最近一条),
|
||||
// 找不到再退回最近一条同名条目(重放/重复结束事件)。
|
||||
let pending = self
|
||||
.messages
|
||||
.iter()
|
||||
.rposition(|message| message.streaming && tool_named(message, &name));
|
||||
let target = pending.or_else(|| {
|
||||
self.messages
|
||||
.iter()
|
||||
.rposition(|message| tool_named(message, &name))
|
||||
});
|
||||
if let Some(index) = target {
|
||||
let message = &mut self.messages[index];
|
||||
message.streaming = false;
|
||||
message.text.clone_from(&summary);
|
||||
if let Some(tool) = &mut message.tool {
|
||||
tool.ok = *ok;
|
||||
tool.summary = summary;
|
||||
}
|
||||
}
|
||||
self.streaming = self.messages.iter().any(|message| message.streaming);
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
AgentEvent::Failed { message } => {
|
||||
let text = scrub_text(message);
|
||||
self.finish_stream();
|
||||
self.error = Some(text.clone());
|
||||
self.messages.push(ChatMessage::system(text));
|
||||
true
|
||||
}
|
||||
AgentEvent::ConfirmRequested { request } => {
|
||||
self.confirm = Some(scrub_confirm(request));
|
||||
self.sync_busy();
|
||||
true
|
||||
}
|
||||
AgentEvent::ConfirmResolved { id } => {
|
||||
if self
|
||||
.confirm
|
||||
.as_ref()
|
||||
.is_some_and(|pending| &pending.id == id)
|
||||
{
|
||||
self.confirm = None;
|
||||
}
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `busy` 的唯一写入口:`busy == streaming || confirm.is_some()`。
|
||||
///
|
||||
/// UI 靠 `busy` 禁用输入框,所以「等确认」必须算忙——否则用户能在一轮写操作中间再发一句,
|
||||
/// 两个工具调用会踩同一份配置。
|
||||
fn sync_busy(&mut self) {
|
||||
self.busy = self.streaming || self.confirm.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息脱敏(正文 + 工具摘要;工具名也过一遍,防止实现方把凭据塞进名字)。
|
||||
fn scrub_message(message: &ChatMessage) -> ChatMessage {
|
||||
let tool = message.tool.as_ref().map(|tool| ToolCallView {
|
||||
name: scrub_text(&tool.name),
|
||||
ok: tool.ok,
|
||||
summary: scrub_text(&tool.summary),
|
||||
});
|
||||
ChatMessage {
|
||||
role: message.role,
|
||||
text: scrub_text(&message.text),
|
||||
tool,
|
||||
streaming: message.streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认请求脱敏:摘要按内容、细节按键名 + 内容双道。
|
||||
fn scrub_confirm(request: &ConfirmRequest) -> ConfirmRequest {
|
||||
ConfirmRequest {
|
||||
id: request.id.clone(),
|
||||
tool: scrub_text(&request.tool),
|
||||
summary: scrub_text(&request.summary),
|
||||
details: scrub_details(&request.details),
|
||||
sensitivity: request.sensitivity,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这条消息是不是指定名字的工具条目。
|
||||
fn tool_named(message: &ChatMessage, name: &str) -> bool {
|
||||
message.tool.as_ref().is_some_and(|tool| tool.name == name)
|
||||
}
|
||||
@@ -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