Merge branch 'feat/m103-agentstream-presenter-agent-rail-mo'
This commit is contained in:
commit
875ac159ee
10 files changed
+1839
-99
No files matched your search
@@ -0,0 +1,311 @@
|
||||
//! AI 助手(D16 双形态)的 presenter 门面:视图模型 + 提交/确认 + 裁决审计。
|
||||
//!
|
||||
//! # 1. 这一层为什么单独存在
|
||||
//!
|
||||
//! UI 壳(右侧常驻栏 = AGENTRAIL)只做两件事:读 [`AgentRail`] 画、把点击翻成这五个方法。
|
||||
//! 会话怎么攒、未读怎么算、YOLO 怎么裁决、审计记什么,全在这里与
|
||||
//! [`crate::state::AppState::apply_agent`],壳里不重复实现——否则两形态(栏 / 页)会各写一套
|
||||
//! 而漂移。
|
||||
//!
|
||||
//! ```text
|
||||
//! UI 壳 ──agent_submit/agent_confirm/agent_mark_read──► Presenter ──AgentTransport──► 后端
|
||||
//! ▲ │
|
||||
//! └────────── agent_rail()(只读视图模型)◄──────────────┘ events → AppState::apply
|
||||
//! ```
|
||||
//!
|
||||
//! # 2. 与 UI 壳的契约(签名已定,改名要先报 tower)
|
||||
//!
|
||||
//! ```text
|
||||
//! Presenter::agent_rail(&self) -> &AgentRail
|
||||
//! Presenter::agent_submit(&mut self, text: String) -> Result<(), String>
|
||||
//! Presenter::agent_mark_read(&mut self)
|
||||
//! Presenter::agent_confirm(&mut self, accept: bool)
|
||||
//! ```
|
||||
//!
|
||||
//! # 3. 铁律落点(AGENTS §6)
|
||||
//!
|
||||
//! - **AI 永不接触账户凭据**:事件里没有凭据字段;文本一律过 [`rail::scrub_text`](第二道防线)。
|
||||
//! - **写配置受 schema 白名单约束**:白名单在 agent 侧工具层,presenter 只负责「把确认问出来」。
|
||||
//! - **敏感键分级确认,YOLO 只跳确认且审计照记**:[`Presenter::agent_confirm`] 在 YOLO 下
|
||||
//! 一律放行,但**照样**记一条 [`AuditEntry`](`yolo = true, confirmed = false`)——不静默跳过。
|
||||
//!
|
||||
//! # 4. 没做的事(P1 再接)
|
||||
//!
|
||||
//! - 真实后端:本模块只认 [`AgentTransport`] 端口,`rig` 那侧由 host 装配时实现;
|
||||
//! - 落盘审计:agent 侧 `brain::exec` → `audit` 才是权威记录,这里的 [`AuditEntry`] 只是
|
||||
//! 本进程内、给 UI 看的裁决镜像(`presenter.audit()`);
|
||||
//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。
|
||||
|
||||
use crate::dto::{AppEvent, Sensitivity};
|
||||
use crate::prefs::UiPrefs;
|
||||
use crate::router::Route;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod mock;
|
||||
pub mod rail;
|
||||
|
||||
use rail::scrub_text;
|
||||
pub use rail::AgentRail;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "agent/tests_red_line.rs"]
|
||||
mod tests_red_line;
|
||||
|
||||
/// 裁决记录上限(只留最近若干条;权威审计不在这里)。
|
||||
pub const MAX_AUDIT_ENTRIES: usize = 256;
|
||||
|
||||
/// 一次确认的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfirmVerdict {
|
||||
/// 放行(用户点了允许,或 YOLO 自动放行)
|
||||
Approved,
|
||||
/// 拒绝(工具调用会带「用户拒绝」回到模型)
|
||||
Denied,
|
||||
}
|
||||
|
||||
impl ConfirmVerdict {
|
||||
/// 是否放行。
|
||||
#[must_use]
|
||||
pub fn is_approved(self) -> bool {
|
||||
matches!(self, Self::Approved)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条确认裁决的审计记录。
|
||||
///
|
||||
/// `yolo` 与 `confirmed` 必须分开记:`yolo = true, confirmed = false`(自动放行)与
|
||||
/// `yolo = false, confirmed = true`(用户亲手点的允许)在事故复盘里是完全不同的两件事。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuditEntry {
|
||||
/// 请求 id(与 `AgentEvent::ConfirmRequested` 同一个)
|
||||
pub request_id: String,
|
||||
/// 工具名
|
||||
pub tool: String,
|
||||
/// 敏感等级
|
||||
pub sensitivity: Sensitivity,
|
||||
/// 结局
|
||||
pub verdict: ConfirmVerdict,
|
||||
/// 是否运行在 YOLO 模式
|
||||
pub yolo: bool,
|
||||
/// 是否经过用户确认(YOLO 下恒为 false)
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
/// agent 后端端口:presenter 不认 `rig` / 异步运行时的具体类型(依赖方向见 crate 文档)。
|
||||
///
|
||||
/// 方法同步返回事件分片是刻意的:真实后端在后台线程里跑,事件经
|
||||
/// [`crate::dto::Backend::drain_events`] 回流;端口这层只表达「一轮输入会产生哪些事件」,
|
||||
/// 这样单测可以不碰定时器与网络。
|
||||
pub trait AgentTransport {
|
||||
/// 用户提交一句话。
|
||||
///
|
||||
/// `history` 是当前 rail 的只读快照(真实后端要靠它拼上下文,mock 靠它算消息下标)。
|
||||
/// 返回的事件分片由 [`Presenter`] 顺序 `apply`;`Err` 表示这一轮根本没起来(不产生事件)。
|
||||
fn submit(&mut self, text: &str, history: &AgentRail) -> Result<Vec<AppEvent>, String>;
|
||||
|
||||
/// 用户对写操作做了决定(`accept = false` 表示拒绝)。
|
||||
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent>;
|
||||
}
|
||||
|
||||
/// AI 助手的 presenter 门面:持有一份 [`AppState`] 与一个后端端口。
|
||||
///
|
||||
/// 事件仍走 [`AppState::apply`](唯一状态变更入口)——[`Presenter::apply`] 只是转发,
|
||||
/// 不在这一层另做投影。
|
||||
pub struct Presenter {
|
||||
state: AppState,
|
||||
transport: Box<dyn AgentTransport>,
|
||||
audit: Vec<AuditEntry>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Presenter {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("Presenter")
|
||||
.field("rail", self.agent_rail())
|
||||
.field("audit_len", &self.audit.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
/// 空状态 + 指定后端端口。
|
||||
#[must_use]
|
||||
pub fn new(transport: Box<dyn AgentTransport>) -> Self {
|
||||
Self::with_state(AppState::new(), transport)
|
||||
}
|
||||
|
||||
/// 接管一份已有状态(UI 壳先建 `AppState` 再交给 presenter 时用)。
|
||||
#[must_use]
|
||||
pub fn with_state(state: AppState, transport: Box<dyn AgentTransport>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
transport,
|
||||
audit: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读状态(UI 画其它页面用)。
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &AppState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// 可写状态——**只在测试构建里存在**。
|
||||
///
|
||||
/// 非测试代码没有这个后门:`AppState::ai` 的字段全是 pub,返回 `&mut AppState` 就等于
|
||||
/// 让调用方绕开 [`AgentRail::ingest`] 的脱敏与 `busy` 不变量。需要写状态时走下面这些
|
||||
/// 窄入口(prefs / 导航 / rail 的那几个字段),rail 的内容一律走四个契约方法。
|
||||
#[cfg(test)]
|
||||
pub fn state_mut(&mut self) -> &mut AppState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
/// 导航(一级页替换、子页压栈;去 AI 独立页会展开栏并清未读)。
|
||||
pub fn navigate(&mut self, route: Route) {
|
||||
self.state.navigate(route);
|
||||
}
|
||||
|
||||
/// 回退一层(子页面);已在根页面时返回 `false`。
|
||||
pub fn back(&mut self) -> bool {
|
||||
self.state.back()
|
||||
}
|
||||
|
||||
/// 折叠/展开 AI 栏(独立页形态下等价于收回侧栏;展开时清未读)。
|
||||
pub fn toggle_ai(&mut self) {
|
||||
self.state.toggle_ai();
|
||||
}
|
||||
|
||||
/// UI 偏好(主题、磁贴尺寸、栏折叠位…)。**不含** rail 字段。
|
||||
pub fn prefs_mut(&mut self) -> &mut UiPrefs {
|
||||
&mut self.state.prefs
|
||||
}
|
||||
|
||||
/// YOLO 开关(设置 → AI 的那一项)。
|
||||
///
|
||||
/// 只翻开关不裁决:若此刻正有待确认请求,调用方仍要走一次
|
||||
/// [`Presenter::agent_confirm`](YOLO 下它会自动放行并记审计)。
|
||||
pub fn agent_set_yolo(&mut self, on: bool) {
|
||||
self.state.ai.yolo = on;
|
||||
}
|
||||
|
||||
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
||||
///
|
||||
/// 草稿**是** rail 上唯一能合法躺着凭据原文的字段:它是「还没发出去的字」,写进来时不过
|
||||
/// 脱敏(UI 要能原样显示用户正在打的字)。安全性靠另一条:`agent_submit` 脱敏的是**入参**
|
||||
/// 而不是草稿,所以原文既不会进会话历史,也不会被发给后端。
|
||||
pub fn agent_set_draft(&mut self, text: String) {
|
||||
self.state.ai.draft = text;
|
||||
}
|
||||
|
||||
/// 喂一条事件(纯转发给 [`AppState::apply`])。
|
||||
pub fn apply(&mut self, event: AppEvent) {
|
||||
self.state.apply(event);
|
||||
}
|
||||
|
||||
/// 当前会话的视图模型(唯一读入口;两形态共享同一份)。
|
||||
#[must_use]
|
||||
pub fn agent_rail(&self) -> &AgentRail {
|
||||
&self.state.ai
|
||||
}
|
||||
|
||||
/// 提交一句话。
|
||||
///
|
||||
/// 拒绝的三种情形都不改状态(草稿保留,用户能接着改):
|
||||
/// 空文本、上一轮还在跑、有写操作待确认。
|
||||
///
|
||||
/// 成功时清空草稿、立刻把 `busy` 置真(**然后**才投影后端返回的事件分片),最后按消息
|
||||
/// 重算一次 `streaming`——异步后端在「提交成功 → 第一个事件到达」之间的空窗因此不会
|
||||
/// 让用户再点一次发送(那会产生两条 user 消息、两轮回复)。
|
||||
/// 用户气泡**由事件产生**(不在本地乐观回显,见 `lib.rs` §3)。
|
||||
pub fn agent_submit(&mut self, text: String) -> Result<(), String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入内容".to_string());
|
||||
}
|
||||
if self.state.ai.confirm.is_some() {
|
||||
return Err("有写操作待确认:先允许或拒绝,再继续对话".to_string());
|
||||
}
|
||||
if self.state.ai.busy {
|
||||
return Err("上一轮回复还没结束".to_string());
|
||||
}
|
||||
// 用户输入也过脱敏:他可能顺手把 token 粘进对话框,而这段文本会进会话历史、
|
||||
// 会作为 prompt **发到模型服务商**、也会进导出。这道是**出站**闸门(rail 侧那道是
|
||||
// 显示闸门,两者不能互相替代:删掉这道,界面照样干净,但凭据已经出去了)。
|
||||
// 守卫见 agent/tests_red_line.rs::outbound_prompt_is_scrubbed_before_it_reaches_the_backend。
|
||||
let text = scrub_text(trimmed);
|
||||
let events = self
|
||||
.transport
|
||||
.submit(&text, &self.state.ai)
|
||||
// 失败原因同样可能回显 key(provider 的 401 常把 key 抄进 message),
|
||||
// 这条 Err 会直接显示在 UI 上,守卫见 outbound_error_text_is_scrubbed。
|
||||
.map_err(|error| scrub_text(&error))?;
|
||||
self.state.ai.draft.clear();
|
||||
self.state.ai.error = None;
|
||||
self.state.ai.begin_turn();
|
||||
for event in events {
|
||||
self.state.apply(event);
|
||||
}
|
||||
self.state.ai.settle_streaming();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 未读清零(UI 在「用户打开栏 / 停在独立页」时调用)。
|
||||
pub fn agent_mark_read(&mut self) {
|
||||
self.state.ai.mark_read();
|
||||
}
|
||||
|
||||
/// 对当前待确认的写操作做决定。
|
||||
///
|
||||
/// - 没有待确认:no-op(不记审计,不 panic);
|
||||
/// - YOLO 下:`accept` 被忽略,一律放行,审计记 `yolo = true, confirmed = false`;
|
||||
/// - 非 YOLO:按 `accept` 裁决,审计记 `confirmed = true`。
|
||||
///
|
||||
/// 两种情形都走同一条事件回流(后端返回的工具收尾事件),所以会话里看到的工具条目
|
||||
/// 与审计记录不会各行其是。
|
||||
///
|
||||
/// # UI 壳的义务
|
||||
///
|
||||
/// 看到 `rail.confirm.is_some()` 就要给出决定:非 YOLO 弹确认框,YOLO 直接调
|
||||
/// `agent_confirm(true)`(不弹窗,但**必须**调——否则 `busy` 一直锁着输入)。
|
||||
/// 真实后端在 YOLO 下不会发 `ConfirmRequested`(agent 侧 ConfirmGate 已自动放行),
|
||||
/// 这条路径是防御性的;另一条收敛途径是后端发 `AgentEvent::ConfirmResolved`。
|
||||
pub fn agent_confirm(&mut self, accept: bool) {
|
||||
let Some(request) = self.state.ai.take_confirm() else {
|
||||
return;
|
||||
};
|
||||
let yolo = self.state.ai.yolo;
|
||||
let verdict = if yolo || accept {
|
||||
ConfirmVerdict::Approved
|
||||
} else {
|
||||
ConfirmVerdict::Denied
|
||||
};
|
||||
self.record(AuditEntry {
|
||||
request_id: request.id.clone(),
|
||||
tool: request.tool.clone(),
|
||||
sensitivity: request.sensitivity,
|
||||
verdict,
|
||||
yolo,
|
||||
confirmed: !yolo,
|
||||
});
|
||||
let events = self.transport.confirm(&request.id, verdict.is_approved());
|
||||
for event in events {
|
||||
self.state.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本进程内的裁决记录(最旧在前;权威审计在 agent 侧)。
|
||||
#[must_use]
|
||||
pub fn audit(&self) -> &[AuditEntry] {
|
||||
&self.audit
|
||||
}
|
||||
|
||||
/// 记一条裁决,超出 [`MAX_AUDIT_ENTRIES`] 时丢最旧的。
|
||||
fn record(&mut self, entry: AuditEntry) {
|
||||
if self.audit.len() == MAX_AUDIT_ENTRIES {
|
||||
self.audit.remove(0);
|
||||
}
|
||||
self.audit.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -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,393 @@
|
||||
//! 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,也不得**出站**
|
||||
//!
|
||||
//! 两道闸门,各守一侧,**不能互相替代**:
|
||||
//!
|
||||
//! | 闸门 | 位置 | 守什么 |
|
||||
//! | ---- | ---- | ------ |
|
||||
//! | 出站 | [`crate::agent::Presenter::agent_submit`] 的 `scrub_text(trimmed)` / `map_err` | 发给后端的 prompt、回给 UI 的 `Err` 文案 |
|
||||
//! | 显示 | [`AgentRail::ingest`] 这里 | 进入 rail 的所有事件文本(消息正文 / 工具名与摘要 / 错误 / 确认请求) |
|
||||
//!
|
||||
//! 只留出站闸门 → 后端事件里的凭据会直接显示在聊天栏;只留显示闸门 → 界面一片干净而
|
||||
//! **凭据已经发出去了**。两边各有独立守卫(`agent/tests_red_line.rs`)。
|
||||
//!
|
||||
//! [`AgentRail::ingest`] 逐字段过 [`scrub_text`] / [`scrub_details`]。凭据字段(`token` /
|
||||
//! `refresh_token` / `password` / `access_token` …)本就不该出现在事件里(AGENTS §6:AI 永不
|
||||
//! 接触账户凭据),这里的脱敏是**第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,
|
||||
//! rail 上也不会出现原文。
|
||||
//!
|
||||
//! 规则来源(刻意不另写一套正则):
|
||||
//! - 内容侧:复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」+ ≥64 不透明串);
|
||||
//! - 上下文侧:照 `unlucky_agent::redact` 补两条——`Bearer <短令牌>` 掩码、
|
||||
//! `{"path": 敏感键}` 时 `value`/`old`/`new` 一并掩码(`set_config` 的 details 正是它)。
|
||||
//!
|
||||
//! 与 `unlucky_agent::redact` 的实际关系(**别读成「已无差异」,也别读成「已经漂移」**):
|
||||
//! - 键名名单(8 条 needle):**逐字相同**;
|
||||
//! - path 规则:**语义相同**(presenter 额外对字符串值跑 `scrub_text`,比 `redact_args` 更严);
|
||||
//! - [`mask_bearer`]:**行为不完全等价的自写迁移**——停字符集 presenter 8 个(含 `;`,
|
||||
//! 不含 `/` `\`)vs agent `segment_end` 9 个(含 `/` `\`,不含 `;`),占位符也不同
|
||||
//! (`<redacted:token>` vs `***`)。`Bearer abc;def` presenter 掩得更少(`abc` 被掩、
|
||||
//! `;def` 留着),`Bearer abc/def` 掩得更多。方向安全(多掩不漏;少掩只发生在 `;` 上),
|
||||
//! 右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。
|
||||
//! - 两处重复实现(键名名单 + Bearer)是**已知欠账**:上提一份公共实现要单开 mission
|
||||
//! (presenter 不能依赖 `unlucky-agent`,那会拖进 rig / tokio / rmcp)。
|
||||
//!
|
||||
//! # 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))
|
||||
}
|
||||
|
||||
/// 文本脱敏:内容规则三件套。
|
||||
///
|
||||
/// 1. JWT 形状(带点 token)/ ≥64 不透明串 —— 复用 core 的 `Redactor`(M40 上提的那份);
|
||||
/// 2. `Bearer` 后面的短令牌 —— core 没有这条,照 `unlucky_agent::redact::mask_bearer` 补;
|
||||
/// 3. 主目录不登记:rail 不持有数据根,`<home>` 掩码是 host 侧的职责。
|
||||
#[must_use]
|
||||
pub fn scrub_text(text: &str) -> String {
|
||||
mask_bearer(&Redactor::new().scrub(text))
|
||||
}
|
||||
|
||||
/// 结构化细节脱敏:按**键名**、按**值随 path 敏感**、按内容三道(见模块文档 §2)。
|
||||
///
|
||||
/// 同样对齐 `unlucky_agent::redact::redact_args` 的两条上下文规则:键名命中即掩码,
|
||||
/// 以及 `{"path": "ai.api_key", "old": …, "new": …}` 这种「值随路径敏感」的形状
|
||||
/// (`set_config` 的 details 正是它)——否则 API key 会原样躺在确认弹窗的 diff 里。
|
||||
#[must_use]
|
||||
pub fn scrub_details(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sensitive_path = map
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_sensitive_key);
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, child) in map {
|
||||
let masked = is_sensitive_key(key)
|
||||
|| (sensitive_path && matches!(key.as_str(), "value" | "old" | "new"));
|
||||
let child = if masked {
|
||||
Value::String(TOKEN_PLACEHOLDER.to_string())
|
||||
} else {
|
||||
scrub_details(child)
|
||||
};
|
||||
out.insert(key.clone(), child);
|
||||
}
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Bearer` 之后的分隔符(令牌到此为止)。与 agent `segment_end` 的差异见模块文档 §2:
|
||||
/// 多 `;`、少 `/` `\`(方向安全:多掩不漏),右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。
|
||||
const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';'];
|
||||
|
||||
/// `Bearer <token>` → `Bearer <redacted:token>`。
|
||||
///
|
||||
/// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`)
|
||||
/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 的意图补上(实现是自写的,
|
||||
/// 见模块文档 §2 的差异说明)。幂等:已经是 `<redacted:token>` 的再跑一遍结果不变。
|
||||
fn mask_bearer(text: &str) -> String {
|
||||
const MARKER: &str = "Bearer ";
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some(position) = text[cursor..].find(MARKER) {
|
||||
let token_start = cursor + position + MARKER.len();
|
||||
let token_end = bearer_token_end(text, token_start);
|
||||
out.push_str(&text[cursor..token_start]);
|
||||
if token_end > token_start {
|
||||
out.push_str(TOKEN_PLACEHOLDER);
|
||||
}
|
||||
cursor = token_end;
|
||||
}
|
||||
out.push_str(&text[cursor..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// 从 `from` 起取到下一个分隔符为止(令牌的右边界)。
|
||||
fn bearer_token_end(text: &str, from: usize) -> usize {
|
||||
text[from..]
|
||||
.find(BEARER_STOPS)
|
||||
.map_or(text.len(), |offset| from + offset)
|
||||
}
|
||||
|
||||
/// 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.settle_streaming();
|
||||
}
|
||||
|
||||
/// 提交成功、后端还没回事件时先把「一轮在跑」点亮。
|
||||
///
|
||||
/// 异步后端在「提交成功 → 第一个事件到达」之间有空窗;不先置忙的话用户能再点一次发送,
|
||||
/// 于是两条 user 消息、两轮回复并行。反正事件流收敛时会用
|
||||
/// [`AgentRail::settle_streaming`] 按消息重算,不会留下假忙。
|
||||
pub fn begin_turn(&mut self) {
|
||||
self.streaming = true;
|
||||
self.sync_busy();
|
||||
}
|
||||
|
||||
/// 按消息重算 `streaming`(有任一条还在流式即为真)并同步 `busy`。
|
||||
pub fn settle_streaming(&mut self) {
|
||||
self.streaming = self.messages.iter().any(|message| message.streaming);
|
||||
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` 之后)不该把
|
||||
// 「一轮在跑」重新点亮,否则输入框会一直禁用。
|
||||
match self.messages.get_mut(*index) {
|
||||
Some(message) => message.text.push_str(&scrub_text(delta)),
|
||||
// 分片乱序/重放时正文会无声消失(用户只看到半截话):开发期吵起来,
|
||||
// 发布期宁可丢这一片也不去猜该拼到哪条上。
|
||||
None => debug_assert!(
|
||||
false,
|
||||
"MessageDelta 指向不存在的消息下标 {index}(会话 {} 条)",
|
||||
self.messages.len()
|
||||
),
|
||||
}
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
AgentEvent::MessageFinished { index } => {
|
||||
if let Some(message) = self.messages.get_mut(*index) {
|
||||
message.streaming = false;
|
||||
}
|
||||
// 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄
|
||||
self.settle_streaming();
|
||||
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);
|
||||
// 只收敛「还在跑的同名调用」。找不到就**忽略**,绝不退回改写已收敛的旧条目
|
||||
// ——那会把历史结果覆盖掉(重放、重复结束事件、丢过 ToolCallStarted 都会命中)。
|
||||
let pending = self
|
||||
.messages
|
||||
.iter()
|
||||
.rposition(|message| message.streaming && tool_named(message, &name));
|
||||
if let Some(index) = pending {
|
||||
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.settle_streaming();
|
||||
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,362 @@
|
||||
//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+
|
||||
//! 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};
|
||||
|
||||
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("提交应当成功");
|
||||
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.streaming, "此刻没有流式消息在跑");
|
||||
assert!(rail.busy, "非 YOLO 下待确认必须算忙,UI 靠它禁用输入");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_turn_marks_busy_before_any_event_arrives() {
|
||||
// 提交成功到第一个事件之间异步后端有空窗:这段时间必须已经算忙,
|
||||
// 否则用户能再点一次发送(两条 user 消息、两轮回复)。
|
||||
let mut rail = AgentRail::new();
|
||||
rail.begin_turn();
|
||||
assert!(rail.busy && rail.streaming);
|
||||
|
||||
// 空事件流也不会把输入框永久锁死:收敛时按消息重算
|
||||
rail.settle_streaming();
|
||||
assert!(!rail.busy && !rail.streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
// ────────────────── scrub_text 的 Bearer 右边界 ──────────────────
|
||||
|
||||
/// 钉住 `mask_bearer` 的**右边界**(r2 P2-2:停字符集收窄或换字符都必须让这条红)。
|
||||
///
|
||||
/// 方向说明:停字符越少 = 吞得越多(安全);放宽才会裸奔。所以句点故意不在停字符集里
|
||||
/// (吞掉句点,换「短 JWT 只掩前半段」是不划算的)。
|
||||
#[test]
|
||||
fn bearer_right_boundary_is_pinned() {
|
||||
// 空白 / 逗号 / 分号 / 引号是停字符:令牌之后的内容原样保留
|
||||
assert_eq!(
|
||||
scrub_text("Authorization: Bearer abc123, retry=1"),
|
||||
"Authorization: Bearer <redacted:token>, retry=1"
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_text("Bearer abc123; rest"),
|
||||
"Bearer <redacted:token>; rest"
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_text("\"Bearer abc123\""),
|
||||
"\"Bearer <redacted:token>\""
|
||||
);
|
||||
assert_eq!(scrub_text("Bearer abc123"), "Bearer <redacted:token>");
|
||||
// 句点不是停字符:整段吞掉(宁可多掩,也不让短 JWT 只掩前半段)
|
||||
assert_eq!(scrub_text("Bearer abc123."), "Bearer <redacted:token>");
|
||||
assert_eq!(scrub_text("Bearer eyJ.abc.def"), "Bearer <redacted:token>");
|
||||
}
|
||||
|
||||
// ─────────────────────── 提交前置条件 ───────────────────────
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收下输入但一个事件都不回的后端(异步推流的极端形态:这一拍还没到)。
|
||||
struct SilentTransport;
|
||||
|
||||
impl AgentTransport for SilentTransport {
|
||||
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`],也不得**出站**。
|
||||
//!
|
||||
//! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**:
|
||||
//! 两道闸门(出站 [`Presenter::agent_submit`] 的 prompt/Err、显示 [`crate::agent::rail`] 的
|
||||
//! ingest 投影)与每条具体入口各自注入一次,任何一条把脱敏摘掉都必须变红。
|
||||
//! r1 / r2 复审都是靠「逐入口造变异」抓出假绿的,改动这里时请照做自证。
|
||||
//!
|
||||
//! 注入的凭据形状两类:
|
||||
//! - JWT(`header.payload.signature`,靠 core `Redactor` 的「带点 token」规则拦);
|
||||
//! - 非 JWT 形状(`{"password":"hunter2"}`、短 `Bearer abcdef123456`,靠键名与 Bearer 规则拦)。
|
||||
//!
|
||||
//! **`rail.draft` 不在这组断言的覆盖范围**:草稿是「还没发出去的字」,UI 要原样显示,
|
||||
//! 它按设计可以躺着凭据原文;安全性由「`agent_submit` 脱敏的是入参而非草稿」保证。
|
||||
//! 所以本文件的 `rail_text()` 刻意不含 draft——别把「rail 全文无 JWT」读成覆盖了 draft。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::mock::{MockAgentTransport, Script};
|
||||
use super::{AgentTransport, Presenter};
|
||||
use crate::agent::rail::scrub_text;
|
||||
use crate::agent::AgentRail;
|
||||
use crate::dto::{
|
||||
AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView,
|
||||
};
|
||||
|
||||
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
|
||||
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
||||
|
||||
fn presenter(script: Script) -> Presenter {
|
||||
Presenter::new(Box::new(MockAgentTransport::new(script)))
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
/// 上一条测试里 `MessageAppended` 走的是 `agent_submit`(已先 scrub),踩不到 rail 自己的
|
||||
/// `scrub_message`。这条**绕过 submit** 直接投递,钉住那条入口(r1 P1 变异 A)。
|
||||
#[test]
|
||||
fn message_appended_from_the_backend_is_scrubbed_inside_the_rail() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user(format!("access_token={JWT}")),
|
||||
)));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: format!("听到你贴了 {JWT}"),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
})));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::system(format!("refresh_token={JWT}")),
|
||||
)));
|
||||
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
assert!(
|
||||
!text.contains("eyJhbGci"),
|
||||
"MessageAppended 入口未脱敏:{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
presenter.agent_rail().messages.len(),
|
||||
3,
|
||||
"三条消息都要在(脱敏不等于丢消息)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 工具**收尾**事件(`ToolCallFinished`)同样带 name + summary,必须过闸门(r1 P1 变异 C2)。
|
||||
///
|
||||
/// 两边用同一个「名字里带 JWT」的工具名:脱敏后仍然相等,所以**只要有一侧没脱敏**,
|
||||
/// 名字就对不上(条目收敛不了、`ok` 仍是 false),断言会红。
|
||||
#[test]
|
||||
fn tool_call_finished_summary_is_scrubbed_inside_the_rail() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
let name = format!("set_config_{JWT}");
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: name.clone(),
|
||||
summary: "修改 download.concurrency".to_string(),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||
name,
|
||||
ok: true,
|
||||
summary: format!("写进去了:Authorization: Bearer {JWT}"),
|
||||
}));
|
||||
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
assert!(
|
||||
!text.contains("eyJhbGci"),
|
||||
"ToolCallFinished 入口未脱敏:{text}"
|
||||
);
|
||||
let tool = presenter.agent_rail().messages[0]
|
||||
.tool
|
||||
.as_ref()
|
||||
.expect("工具条目");
|
||||
assert!(tool.ok, "结论位要照实写");
|
||||
assert!(!tool.name.contains("eyJhbGci"), "工具名也要过闸门");
|
||||
}
|
||||
|
||||
/// 非 JWT 形状的凭据(`{"password":"hunter2"}`)只能靠**键名**规则拦(r1 P1 变异 H)。
|
||||
///
|
||||
/// 同时钉住「值随 path 敏感」这条上下文规则(`set_config` 的 details 形状)。
|
||||
#[test]
|
||||
fn sensitive_keys_in_confirm_details_are_masked_without_jwt_shape() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-keys".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "改几个设置".to_string(),
|
||||
details: serde_json::json!({
|
||||
"password": "hunter2",
|
||||
"api_key": "short-key-123",
|
||||
"nested": { "refresh_token": "rt-1" },
|
||||
"list": [{ "token": "xyz" }],
|
||||
"plain": "nothing here",
|
||||
"path": "ai.api_key",
|
||||
"old": "",
|
||||
"new": "sk-proj-abcdefghijklmnop",
|
||||
}),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
|
||||
let details = &presenter
|
||||
.agent_rail()
|
||||
.confirm
|
||||
.as_ref()
|
||||
.expect("确认请求")
|
||||
.details;
|
||||
let placeholder = serde_json::json!("<redacted:token>");
|
||||
// 键名命中:对象 / 嵌套 / 数组各一
|
||||
assert_eq!(details["password"], placeholder);
|
||||
assert_eq!(details["api_key"], placeholder);
|
||||
assert_eq!(details["nested"]["refresh_token"], placeholder);
|
||||
assert_eq!(details["list"][0]["token"], placeholder);
|
||||
// 值随 path 敏感:old/new 一并掩码(不然 API key 就躺在确认弹窗的 diff 里)
|
||||
assert_eq!(details["new"], placeholder);
|
||||
assert_eq!(details["old"], placeholder);
|
||||
// 无关键原样保留(审计要看得出改的是什么)
|
||||
assert_eq!(details["plain"], serde_json::json!("nothing here"));
|
||||
assert_eq!(details["path"], serde_json::json!("ai.api_key"));
|
||||
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
for leak in [
|
||||
"hunter2",
|
||||
"short-key-123",
|
||||
"rt-1",
|
||||
"xyz",
|
||||
"sk-proj-abcdefghijklmnop",
|
||||
] {
|
||||
assert!(!text.contains(leak), "凭据泄漏到 rail:{leak} / {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 短 `Bearer` 令牌不是 JWT 形状,core 的 `Redactor` 会放过它(r1 P2-1)。
|
||||
#[test]
|
||||
fn short_bearer_token_is_masked() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "read_game_log".to_string(),
|
||||
summary: "Authorization: Bearer abcdef123456".to_string(),
|
||||
}));
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
assert!(
|
||||
!text.contains("abcdef123456"),
|
||||
"短 Bearer 令牌要掩码:{text}"
|
||||
);
|
||||
assert!(text.contains("Bearer <redacted:token>"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_text_is_idempotent_on_already_redacted_text() {
|
||||
let once = scrub_text(JWT);
|
||||
assert_eq!(scrub_text(&once), once);
|
||||
let bearer = scrub_text("Authorization: Bearer abcdef123456");
|
||||
assert_eq!(scrub_text(&bearer), bearer);
|
||||
}
|
||||
|
||||
// ───────────────── 出站闸门:发给后端的那份 payload ─────────────────
|
||||
|
||||
/// 记录 `submit` 实参的后端替身:红线里最重要的是「**真正发出去的是什么**」,
|
||||
/// 而不是 rail 上显示成什么(显示层自己还会再脱一次,会把出站的漏洞盖住)。
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingTransport {
|
||||
seen: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl AgentTransport for RecordingTransport {
|
||||
fn submit(&mut self, text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
self.seen.borrow_mut().push(text.to_string());
|
||||
// 照常回一条用户气泡,让整轮流程跟真实后端一致(rail 会再脱一次,所以
|
||||
// 只断言 rail 干净是抓不住出站泄漏的)
|
||||
Ok(vec![AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user(text),
|
||||
))])
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一律报错的后端替身(用来守 Err 文案那道 scrub)。
|
||||
struct ErrorTransport(String);
|
||||
|
||||
impl AgentTransport for ErrorTransport {
|
||||
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
Err(self.0.clone())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 出站守卫(r2 P1):`agent_submit` 送去后端的那句 prompt 必须已经脱敏。
|
||||
///
|
||||
/// 关键在**看后端收到什么**:rail 侧也会脱敏,所以「界面上没有 JWT」证明不了出站安全。
|
||||
#[test]
|
||||
fn outbound_prompt_is_scrubbed_before_it_reaches_the_backend() {
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut presenter = Presenter::new(Box::new(RecordingTransport {
|
||||
seen: Rc::clone(&seen),
|
||||
}));
|
||||
presenter
|
||||
.agent_submit(format!("帮我看看这个 token:{JWT}"))
|
||||
.expect("提交应当成功");
|
||||
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 1, "一轮输入只该发一次");
|
||||
assert!(
|
||||
!seen[0].contains("eyJhbGci"),
|
||||
"出站 prompt 里还有 JWT 原文:{}",
|
||||
seen[0]
|
||||
);
|
||||
assert!(
|
||||
seen[0].contains("<redacted:token>"),
|
||||
"出站 prompt 应当带占位符:{}",
|
||||
seen[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 失败原因(provider 的 401 常把 key 抄进 message)同样是 UI 可见文本(r2 P2-1 / N)。
|
||||
#[test]
|
||||
fn outbound_error_text_is_scrubbed() {
|
||||
let mut presenter = Presenter::new(Box::new(ErrorTransport(format!(
|
||||
"401 Unauthorized: key={JWT}"
|
||||
))));
|
||||
let error = presenter
|
||||
.agent_submit("随便问一句".to_string())
|
||||
.expect_err("后端报错时应当返回 Err");
|
||||
assert!(!error.contains("eyJhbGci"), "Err 文案里还有 JWT:{error}");
|
||||
assert!(error.contains("<redacted:token>"), "{error}");
|
||||
}
|
||||
|
||||
// ─────────── 显示闸门:另三处 scrub 站点(r2 P2-1 / K、M)───────────
|
||||
|
||||
/// `ConfirmRequest.tool` 也过闸门(工具名来自注册表,凭据落名字不现实,但「删了没人发现」)。
|
||||
#[test]
|
||||
fn confirm_tool_name_is_scrubbed() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-tool".to_string(),
|
||||
tool: format!("set_config_{JWT}"),
|
||||
summary: "改设置".to_string(),
|
||||
details: serde_json::json!({}),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
|
||||
let tool = &presenter
|
||||
.agent_rail()
|
||||
.confirm
|
||||
.as_ref()
|
||||
.expect("确认请求")
|
||||
.tool;
|
||||
assert!(!tool.contains("eyJhbGci"), "confirm.tool 未脱敏:{tool}");
|
||||
assert!(!rail_text(presenter.agent_rail()).contains("eyJhbGci"));
|
||||
}
|
||||
|
||||
/// `MessageAppended` 里带 `ToolCallView` 的那条分支(role = Tool)也要脱敏——
|
||||
/// mock 只会发 user,只有真实后端会走到,所以必须单独钉住。
|
||||
#[test]
|
||||
fn tool_message_from_the_backend_has_its_tool_view_scrubbed() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: format!("工具摘要:{JWT}"),
|
||||
tool: Some(ToolCallView {
|
||||
name: format!("read_game_log_{JWT}"),
|
||||
ok: true,
|
||||
summary: format!("Authorization: Bearer {JWT}"),
|
||||
}),
|
||||
streaming: false,
|
||||
})));
|
||||
|
||||
let tool = presenter.agent_rail().messages[0]
|
||||
.tool
|
||||
.as_ref()
|
||||
.expect("工具条目");
|
||||
assert!(
|
||||
!tool.name.contains("eyJhbGci"),
|
||||
"tool.name 未脱敏:{}",
|
||||
tool.name
|
||||
);
|
||||
assert!(
|
||||
!tool.summary.contains("eyJhbGci"),
|
||||
"tool.summary 未脱敏:{}",
|
||||
tool.summary
|
||||
);
|
||||
assert!(!rail_text(presenter.agent_rail()).contains("eyJhbGci"));
|
||||
}
|
||||
|
||||
// ───────────────────────── 断言辅助 ─────────────────────────
|
||||
|
||||
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
|
||||
///
|
||||
/// **不含 `draft`**(见模块文档:草稿按设计可以存原文)。
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
|
||||
//! AI 会话 DTO:消息、工具调用可视化、确认请求与 agent 事件。
|
||||
//!
|
||||
//! 与 agent 侧的对应关系(`crates/agent/src/event.rs` / `confirm.rs`):这里是**扁平镜像**,
|
||||
//! 不 import `unlucky_agent::*`(dto 是 IPC 边界,见 `dto/mod.rs` 的纪律)。字段口径刻意逐字
|
||||
//! 对齐,翻译在接线层做:`AgentEvent::ConfirmRequested` ← `confirm::ConfirmRequest`。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -59,10 +63,70 @@ impl ChatMessage {
|
||||
streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统提示(弱化展示;错误横幅也进会话历史,用户回翻时看得到)。
|
||||
pub fn system(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: ChatRole::System,
|
||||
text: text.into(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 敏感等级(与 agent 侧 `confirm::Sensitivity` 逐字一致)。
|
||||
///
|
||||
/// | 等级 | 语义 |
|
||||
/// |------|------|
|
||||
/// | `Low` | 静默生效 |
|
||||
/// | `Medium` | 静默生效,审计里能查到 |
|
||||
/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) |
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sensitivity {
|
||||
/// 静默生效。
|
||||
#[default]
|
||||
Low,
|
||||
/// 静默生效但审计标记。
|
||||
Medium,
|
||||
/// 必须用户确认。
|
||||
High,
|
||||
}
|
||||
|
||||
impl Sensitivity {
|
||||
/// 是否会触发确认弹窗(非 YOLO 时)。
|
||||
#[must_use]
|
||||
pub fn needs_confirm(self) -> bool {
|
||||
self == Self::High
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次待确认的写操作(确认弹窗的原料;与 agent 侧 `confirm::ConfirmRequest` 对齐)。
|
||||
///
|
||||
/// 构造时**不要**放凭据:`details` 会按键名与内容双道脱敏([`crate::agent::rail`]),
|
||||
/// 但白名单之外的键本来就不该出现在这里。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfirmRequest {
|
||||
/// 请求 id(编排层生成,UI 回填决定时原样带回)
|
||||
pub id: String,
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub tool: String,
|
||||
/// 一句话请求(「AI 请求把下载并发 64 改为 16」)
|
||||
pub summary: String,
|
||||
/// 结构化细节(`{"path":…, "old":…, "new":…}`),UI 画 diff 用
|
||||
pub details: serde_json::Value,
|
||||
/// 敏感等级
|
||||
pub sensitivity: Sensitivity,
|
||||
}
|
||||
|
||||
/// agent 侧事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// 四类都投到 [`crate::agent::AgentRail`] 上:流式增量 → 最后一条 assistant 消息;工具调用 →
|
||||
/// 会话里可见的 tool 条目(D4);错误 → 系统条目 + 横幅;确认请求 → `rail.confirm`。
|
||||
///
|
||||
/// 注意这里**没有** `Eq`:`ConfirmRequest::details` 是 `serde_json::Value`(可含浮点)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
/// 追加一条完整消息
|
||||
MessageAppended(ChatMessage),
|
||||
@@ -94,4 +158,22 @@ pub enum AgentEvent {
|
||||
/// 结果摘要
|
||||
summary: String,
|
||||
},
|
||||
/// 循环级错误(provider 断流、AI 被禁用、工具内部异常)。
|
||||
///
|
||||
/// 与 `ToolCallFinished { ok: false }` 的分工:那个是**某一次工具调用**失败,对话继续;
|
||||
/// 这个是**这一轮回复**中止,rail 要收敛掉所有流式标记并立一个错误横幅。
|
||||
Failed {
|
||||
/// 人类可读原因
|
||||
message: String,
|
||||
},
|
||||
/// 需要用户确认(分级确认:写操作先问再改)。
|
||||
ConfirmRequested {
|
||||
/// 待确认请求
|
||||
request: ConfirmRequest,
|
||||
},
|
||||
/// 确认请求已收敛(重放/超时/别处已裁决):把 rail 上同 id 的待确认清掉,避免僵尸弹窗。
|
||||
ConfirmResolved {
|
||||
/// 请求 id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
@@ -26,7 +26,7 @@ mod settings;
|
||||
mod versions;
|
||||
|
||||
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
|
||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||
pub use instances::{
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! | [`dto`] | [`dto::Backend`] / [`dto::AppCommand`] / [`dto::AppEvent`] / 各 `*View` 快照 | 派发指令、消费事件、把快照字段映射成视图模型 |
|
||||
//! | [`host`] | [`host::CoreHost`](实现 [`dto::Backend`])、[`host::HostEnv`] | 进程启动时构造一次,之后只当 `Box<dyn Backend>` 用 |
|
||||
//! | [`state`] | [`state::AppState`](`apply` / `navigate` / 各页面字段) | **唯一**状态入口:事件进来先 `apply`,再从字段取值画 |
|
||||
//! | [`agent`] | `Presenter`(`agent_rail` / `agent_submit` / `agent_mark_read` / `agent_confirm`)、`AgentRail` | AI 助手的唯一状态入口:读 rail 画、把点击翻成这四个方法 |
|
||||
//! | [`prefs`] | `UiPrefs` / `ThemeMode` / `TileSize` / `AppearancePrefs` / `keys` | 读字段做展示;不自己写盘 |
|
||||
//! | [`settings_bridge`] | `SettingsBridge`(`open` / `load` / `persist` / `reload_if_changed`) | 启动读一次、变更时写回、每拍问一次「变了吗」 |
|
||||
//! | [`theme`] | `CONFIG_POLL_TICKS` / `BACKGROUND_KEY` 一类纯判据、`tile_viewport` / `system_theme_value` | 「这一拍该不该重读/重建」由它回答,UI 只执行 |
|
||||
@@ -102,6 +103,7 @@
|
||||
// 这里沿用同一条策略统一放开,接线完成后再删掉本行暴露真死码。
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod backend;
|
||||
pub mod dialogs;
|
||||
pub mod dto;
|
||||
|
||||
@@ -3,59 +3,18 @@
|
||||
//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → UI 属性。
|
||||
//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。
|
||||
|
||||
use crate::agent::AgentRail;
|
||||
use crate::dialogs::DialogQueue;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView,
|
||||
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine,
|
||||
LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary,
|
||||
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
|
||||
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogSession,
|
||||
NoticeView, Progress, SettingGroupView, VersionSummary,
|
||||
};
|
||||
use crate::prefs::UiPrefs;
|
||||
use crate::router::{AiPanelMode, Route, Router};
|
||||
use crate::toast::{ToastCenter, ToastKind};
|
||||
use crate::wizard::WizardState;
|
||||
|
||||
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AiSession {
|
||||
/// 会话消息(user / assistant / tool / system)
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 是否有流式消息在跑
|
||||
pub streaming: bool,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
pub yolo: bool,
|
||||
/// 输入框草稿(切形态不丢)
|
||||
pub draft: String,
|
||||
/// 端点标签(显示在栏顶)
|
||||
pub endpoint_label: String,
|
||||
}
|
||||
|
||||
impl Default for AiSession {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AiSession {
|
||||
/// 默认端点标签(P0 是本地 llama.cpp;P1 由设置页决定)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
streaming: false,
|
||||
yolo: false,
|
||||
draft: String::new(),
|
||||
endpoint_label: "llama.cpp · 本地".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式收敛:清掉全部流式标记。
|
||||
pub fn finish_stream(&mut self) {
|
||||
self.streaming = false;
|
||||
for message in &mut self.messages {
|
||||
message.streaming = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TileCell<'a> {
|
||||
@@ -99,8 +58,8 @@ pub struct AppState {
|
||||
pub diagnosis: Option<DiagnosisReport>,
|
||||
/// 选中的日志会话
|
||||
pub selected_session: Option<String>,
|
||||
/// AI 会话状态
|
||||
pub ai: AiSession,
|
||||
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)
|
||||
pub ai: AgentRail,
|
||||
/// 对话框队列(一次一条)
|
||||
pub dialogs: DialogQueue,
|
||||
/// Toast 中心
|
||||
@@ -149,7 +108,7 @@ impl AppState {
|
||||
log_lines: Vec::new(),
|
||||
diagnosis: None,
|
||||
selected_session: None,
|
||||
ai: AiSession::new(),
|
||||
ai: AgentRail::new(),
|
||||
dialogs: DialogQueue::new(),
|
||||
toasts: ToastCenter::new(),
|
||||
instance_query: String::new(),
|
||||
@@ -176,6 +135,8 @@ impl AppState {
|
||||
pub fn navigate(&mut self, route: Route) {
|
||||
if route == Route::Agent {
|
||||
self.prefs.ai_collapsed = false;
|
||||
// 用户正在看 AI 页:攒下的未读立刻算已读(红点不该在眼前亮着)
|
||||
self.ai.mark_read();
|
||||
}
|
||||
self.router.navigate(route);
|
||||
}
|
||||
@@ -262,57 +223,31 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// agent 事件(AI 会话的流式消息与工具调用)。
|
||||
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
|
||||
///
|
||||
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
||||
/// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。
|
||||
pub fn apply_agent(&mut self, event: AgentEvent) {
|
||||
match event {
|
||||
AgentEvent::MessageAppended(message) => {
|
||||
// 流式占位到达即视为「一轮回复进行中」:UI 的发送键/停止键据此切换
|
||||
if message.streaming {
|
||||
self.ai.streaming = true;
|
||||
}
|
||||
self.ai.messages.push(message);
|
||||
}
|
||||
AgentEvent::MessageDelta { index, delta } => {
|
||||
if let Some(message) = self.ai.messages.get_mut(index) {
|
||||
message.text.push_str(&delta);
|
||||
}
|
||||
}
|
||||
AgentEvent::MessageFinished { index } => {
|
||||
if let Some(message) = self.ai.messages.get_mut(index) {
|
||||
message.streaming = false;
|
||||
}
|
||||
self.ai.streaming = false;
|
||||
}
|
||||
AgentEvent::ToolCallStarted { name, summary } => {
|
||||
self.ai.streaming = true;
|
||||
self.ai.messages.push(ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: summary.clone(),
|
||||
tool: Some(ToolCallView {
|
||||
name,
|
||||
ok: false,
|
||||
summary,
|
||||
}),
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
AgentEvent::ToolCallFinished { name, ok, summary } => {
|
||||
let target =
|
||||
self.ai.messages.iter_mut().rev().find(|message| {
|
||||
message.tool.as_ref().is_some_and(|tool| tool.name == name)
|
||||
});
|
||||
if let Some(message) = target {
|
||||
message.text.clone_from(&summary);
|
||||
message.streaming = false;
|
||||
if let Some(tool) = &mut message.tool {
|
||||
tool.ok = ok;
|
||||
tool.summary = summary;
|
||||
}
|
||||
}
|
||||
let visible = self.agent_rail_visible();
|
||||
let fresh = self.ai.ingest(&event);
|
||||
if fresh {
|
||||
if visible {
|
||||
self.ai.mark_read();
|
||||
} else {
|
||||
self.ai.unread = self.ai.unread.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 栏此刻是否「在用户眼前」。
|
||||
///
|
||||
/// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开——
|
||||
/// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。
|
||||
#[must_use]
|
||||
pub fn agent_rail_visible(&self) -> bool {
|
||||
self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed
|
||||
}
|
||||
|
||||
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
|
||||
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
|
||||
if self
|
||||
@@ -386,6 +321,10 @@ impl AppState {
|
||||
return;
|
||||
}
|
||||
self.prefs.ai_collapsed = !self.prefs.ai_collapsed;
|
||||
if !self.prefs.ai_collapsed {
|
||||
// 展开常驻栏 = 用户在看它
|
||||
self.ai.mark_read();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。
|
||||
|
||||
use super::{grid_positions, AiSession, AppState};
|
||||
use super::{grid_positions, AppState};
|
||||
use crate::agent::AgentRail;
|
||||
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
|
||||
use crate::dto::{
|
||||
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport,
|
||||
@@ -159,12 +160,13 @@ fn ai_stream_deltas_accumulate_and_finish() {
|
||||
|
||||
#[test]
|
||||
fn ai_session_finish_stream_clears_every_flag() {
|
||||
let mut session = AiSession::new();
|
||||
let mut session = AgentRail::new();
|
||||
session.messages.push(ChatMessage::user("hi"));
|
||||
session.messages.push(ChatMessage::assistant_stream());
|
||||
session.finish_stream();
|
||||
assert!(session.messages.iter().all(|message| !message.streaming));
|
||||
assert!(!session.streaming);
|
||||
assert!(!session.busy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -239,6 +241,110 @@ fn toggling_ai_from_the_page_form_docks_it_again() {
|
||||
assert!(state.prefs.ai_collapsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_event_settles_streaming_and_keeps_the_evidence() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
||||
index: 0,
|
||||
delta: "我先读一下".to_string(),
|
||||
}));
|
||||
state.apply(AppEvent::Agent(AgentEvent::Failed {
|
||||
message: "provider 断流:read timeout".to_string(),
|
||||
}));
|
||||
|
||||
// 抓得住:已到的增量不丢、流式标记收敛、错误进横幅也进会话历史
|
||||
assert_eq!(state.ai.messages[0].text, "我先读一下");
|
||||
assert!(!state.ai.messages[0].streaming);
|
||||
assert!(!state.ai.streaming);
|
||||
assert!(!state.ai.busy);
|
||||
assert_eq!(
|
||||
state.ai.error.as_deref(),
|
||||
Some("provider 断流:read timeout")
|
||||
);
|
||||
assert_eq!(state.ai.messages[1].role, ChatRole::System);
|
||||
// 抓不住:这里不判断错误归因(那是 core::diagnose 的事)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_request_keeps_the_shell_busy_until_it_resolves() {
|
||||
// 分工:这条钉 state 层的「待确认 ⇒ busy」与 `ConfirmResolved` 收敛;
|
||||
// presenter 层的同名守卫是 `agent::tests::pending_confirm_forces_busy_without_yolo`
|
||||
// (r1 P2-2:那条曾因 ToolCallStarted 顺手置真而空过,已改成先 finish_stream 再断言)。
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: confirm_request("req-1"),
|
||||
}));
|
||||
assert!(state.ai.busy, "有待确认时 UI 必须禁用输入");
|
||||
assert_eq!(
|
||||
state.ai.confirm.as_ref().map(|request| request.id.as_str()),
|
||||
Some("req-1")
|
||||
);
|
||||
|
||||
// 别处已裁决(重放/超时):僵尸弹窗必须收掉
|
||||
state.apply(AppEvent::Agent(AgentEvent::ConfirmResolved {
|
||||
id: "req-1".to_string(),
|
||||
}));
|
||||
assert!(!state.ai.busy);
|
||||
assert!(state.ai.confirm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_counts_only_while_the_rail_is_out_of_sight() {
|
||||
let mut state = AppState::new();
|
||||
// 展开的常驻栏在任何页面都挂着:到达即已读
|
||||
assert!(state.agent_rail_visible());
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
assert_eq!(state.ai.unread, 0);
|
||||
|
||||
// 收成 icon 轨 = 用户看不见
|
||||
state.prefs.ai_collapsed = true;
|
||||
assert!(!state.agent_rail_visible());
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
||||
index: 0,
|
||||
delta: "长回复".to_string(),
|
||||
}));
|
||||
assert_eq!(state.ai.unread, 0, "增量属于同一条消息,不重复计数");
|
||||
state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "read_game_log".to_string(),
|
||||
summary: "读取日志".to_string(),
|
||||
}));
|
||||
assert_eq!(state.ai.unread, 1);
|
||||
|
||||
// 展开栏 = 用户在看它,未读立刻归零
|
||||
state.toggle_ai();
|
||||
assert_eq!(state.ai.unread, 0);
|
||||
|
||||
// 独立页形态永远算可见
|
||||
state.navigate(Route::Agent);
|
||||
assert!(state.agent_rail_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_messages_never_count_as_unread() {
|
||||
let mut state = AppState::new();
|
||||
state.prefs.ai_collapsed = true;
|
||||
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user("我自己说的话"),
|
||||
)));
|
||||
assert_eq!(state.ai.unread, 0);
|
||||
}
|
||||
|
||||
/// 待确认请求样例(High 级写操作)。
|
||||
fn confirm_request(id: &str) -> crate::dto::ConfirmRequest {
|
||||
crate::dto::ConfirmRequest {
|
||||
id: id.to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "把下载并发 64 → 16".to_string(),
|
||||
details: serde_json::json!({ "path": "download.concurrency" }),
|
||||
sensitivity: crate::dto::Sensitivity::High,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_nav_flips_the_pref() {
|
||||
let mut state = AppState::new();
|
||||
|
||||
Reference in new issue
Block a user