fix(presenter): M103 r1 —— 脱敏闸门补守卫,P2 五条带走

P1(守卫缺失,产品代码本来是对的):
- 新增 agent/tests_red_line.rs:MessageAppended(绕过 agent_submit)、
  ToolCallFinished(name+summary)、非 JWT 形状的 details(键名 / 嵌套 / 数组 /
  值随 path 敏感)各一条;变异 A/H/C2 复跑确认变红
- tests.rs 拆出红线组(547 → 334 行,单文件 ≤400)

P2:
- scrub_details 补「值随 path 敏感」规则(对齐 agent::redact::redact_args)
- scrub_text 补短 Bearer 掩码(对齐 agent::redact::mask_bearer)
- pending_confirm_forces_busy_without_yolo 先 finish_stream 再断言(不再空过),
  变异 F 现在两条测试同时变红
- MessageDelta 越界下标 debug_assert;ToolCallFinished 没有 pending 时忽略,
  不再改写已收敛的历史条目
- Presenter::state_mut() 收窄成 #[cfg(test)],换成 navigate/back/toggle_ai/
  prefs_mut/agent_set_yolo/agent_set_draft 等窄入口
- agent_submit 成功后立刻 begin_turn() 置忙,事件投影完 settle_streaming() 重算,
  异步后端空窗不会再让用户点两次发送
- 补测:begin_turn 置忙、静默后端不留假忙、工具收尾不覆盖历史
This commit is contained in:
AskaEth committed 2026-11-04 00:21:19 +08:00
1 parent a8d4cce3e2
commit 06461df91b
5 files changed
+435 -96

No files matched your search

+51 -4
View File
@@ -37,16 +37,21 @@
//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。
use crate::dto::{AppEvent, Sensitivity};
use crate::prefs::UiPrefs;
use crate::router::Route;
use crate::state::AppState;
use rail::scrub_text;
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;
@@ -147,11 +152,49 @@ impl Presenter {
&self.state
}
/// 可写状态(导航、偏好、其它页面事件;agent 相关动作请走本类的方法)。
/// 可写状态——**只在测试构建里存在**。
///
/// 非测试代码没有这个后门:`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;
}
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
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);
@@ -168,8 +211,10 @@ impl Presenter {
/// 拒绝的三种情形都不改状态(草稿保留,用户能接着改):
/// 空文本、上一轮还在跑、有写操作待确认。
///
/// 成功时清空草稿并顺序投影后端返回的事件分片。用户气泡**由事件产生**
/// (不在本地乐观回显,见 `lib.rs` §3)。
/// 成功时清空草稿、立刻把 `busy` 置真(**然后**才投影后端返回的事件分片),最后按消息
/// 重算一次 `streaming`——异步后端在「提交成功 → 第一个事件到达」之间的空窗因此不会
/// 让用户再点一次发送(那会产生两条 user 消息、两轮回复)。
/// 用户气泡**由事件产生**(不在本地乐观回显,见 `lib.rs` §3)。
pub fn agent_submit(&mut self, text: String) -> Result<(), String> {
let trimmed = text.trim();
if trimmed.is_empty() {
@@ -190,9 +235,11 @@ impl Presenter {
.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(())
}