From a8d4cce3e26218fbedf412825c6f44d54fc2d805 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 20:40:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(presenter):=20M103=20AI=20=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E6=A0=8F=E8=A7=86=E5=9B=BE=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- crates/presenter/src/agent.rs | 256 +++++++++++++++++++++ crates/presenter/src/agent/mock.rs | 165 ++++++++++++++ crates/presenter/src/agent/rail.rs | 308 +++++++++++++++++++++++++ crates/presenter/src/agent/tests.rs | 341 ++++++++++++++++++++++++++++ crates/presenter/src/dto/agent.rs | 86 ++++++- crates/presenter/src/dto/mod.rs | 2 +- crates/presenter/src/lib.rs | 2 + crates/presenter/src/state.rs | 127 +++-------- crates/presenter/src/state_tests.rs | 107 ++++++++- 9 files changed, 1295 insertions(+), 99 deletions(-) create mode 100644 crates/presenter/src/agent.rs create mode 100644 crates/presenter/src/agent/mock.rs create mode 100644 crates/presenter/src/agent/rail.rs create mode 100644 crates/presenter/src/agent/tests.rs diff --git a/crates/presenter/src/agent.rs b/crates/presenter/src/agent.rs new file mode 100644 index 0000000..3231e0c --- /dev/null +++ b/crates/presenter/src/agent.rs @@ -0,0 +1,256 @@ +//! 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::state::AppState; +use rail::scrub_text; + +pub mod mock; +pub mod rail; + +pub use rail::AgentRail; + +#[cfg(test)] +mod tests; + +/// 裁决记录上限(只留最近若干条;权威审计不在这里)。 +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, String>; + + /// 用户对写操作做了决定(`accept = false` 表示拒绝)。 + fn confirm(&mut self, request_id: &str, accept: bool) -> Vec; +} + +/// AI 助手的 presenter 门面:持有一份 [`AppState`] 与一个后端端口。 +/// +/// 事件仍走 [`AppState::apply`](唯一状态变更入口)——[`Presenter::apply`] 只是转发, +/// 不在这一层另做投影。 +pub struct Presenter { + state: AppState, + transport: Box, + audit: Vec, +} + +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) -> Self { + Self::with_state(AppState::new(), transport) + } + + /// 接管一份已有状态(UI 壳先建 `AppState` 再交给 presenter 时用)。 + #[must_use] + pub fn with_state(state: AppState, transport: Box) -> Self { + Self { + state, + transport, + audit: Vec::new(), + } + } + + /// 只读状态(UI 画其它页面用)。 + #[must_use] + pub fn state(&self) -> &AppState { + &self.state + } + + /// 可写状态(导航、偏好、其它页面事件;agent 相关动作请走本类的方法)。 + pub fn state_mut(&mut self) -> &mut AppState { + &mut self.state + } + + /// 喂一条事件(纯转发给 [`AppState::apply`])。 + pub fn apply(&mut self, event: AppEvent) { + self.state.apply(event); + } + + /// 当前会话的视图模型(唯一读入口;两形态共享同一份)。 + #[must_use] + pub fn agent_rail(&self) -> &AgentRail { + &self.state.ai + } + + /// 提交一句话。 + /// + /// 拒绝的三种情形都不改状态(草稿保留,用户能接着改): + /// 空文本、上一轮还在跑、有写操作待确认。 + /// + /// 成功时清空草稿并顺序投影后端返回的事件分片。用户气泡**由事件产生** + /// (不在本地乐观回显,见 `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 与导出。 + let text = scrub_text(trimmed); + let events = self + .transport + .submit(&text, &self.state.ai) + .map_err(|error| scrub_text(&error))?; + self.state.ai.draft.clear(); + self.state.ai.error = None; + for event in events { + self.state.apply(event); + } + 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); + } +} diff --git a/crates/presenter/src/agent/mock.rs b/crates/presenter/src/agent/mock.rs new file mode 100644 index 0000000..8a78d5b --- /dev/null +++ b/crates/presenter/src/agent/mock.rs @@ -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, +} + +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, 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 { + 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) +} diff --git a/crates/presenter/src/agent/rail.rs b/crates/presenter/src/agent/rail.rs new file mode 100644 index 0000000..06af545 --- /dev/null +++ b/crates/presenter/src/agent/rail.rs @@ -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 不持有数据根):`` 掩码是 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, + /// 栏不可见期间攒下的未读条数(`agent_mark_read` 清零) + pub unread: usize, + /// 是否该禁用输入(一轮回复进行中,或有写操作待确认) + pub busy: bool, + /// 输入框草稿(切形态不丢) + pub draft: String, + /// 待确认的写操作(`Some` = 弹确认框;非 YOLO 下 `busy` 必为 true) + pub confirm: Option, + /// 是否有流式消息在跑(Slint 壳读它切换「发送/停止」;新壳用 `busy` 即可) + pub streaming: bool, + /// YOLO 开关(跳过确认,审计照记) + pub yolo: bool, + /// 端点标签(栏顶显示) + pub endpoint_label: String, + /// 最近一次错误(横幅;下一次提交或 ingest 到新消息时清掉) + pub error: Option, +} + +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 { + 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) +} diff --git a/crates/presenter/src/agent/tests.rs b/crates/presenter/src/agent/tests.rs new file mode 100644 index 0000000..9de30a1 --- /dev/null +++ b/crates/presenter/src/agent/tests.rs @@ -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(""), + "应当被脱敏成占位符:{text}" + ); + // 凭据键名的值整段打码(不只是靠内容规则) + let details = &rail.confirm.as_ref().expect("确认请求仍在").details; + assert_eq!(details["access_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, String> { + Err("本地模型还没加载".to_string()) + } + + fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec { + 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 +} diff --git a/crates/presenter/src/dto/agent.rs b/crates/presenter/src/dto/agent.rs index 979afe4..3dd6e28 100644 --- a/crates/presenter/src/dto/agent.rs +++ b/crates/presenter/src/dto/agent.rs @@ -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) -> 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, + }, } diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 4bd5e6a..1d58ce4 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -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::{ diff --git a/crates/presenter/src/lib.rs b/crates/presenter/src/lib.rs index d9e96b1..937ee7a 100644 --- a/crates/presenter/src/lib.rs +++ b/crates/presenter/src/lib.rs @@ -25,6 +25,7 @@ //! | [`dto`] | [`dto::Backend`] / [`dto::AppCommand`] / [`dto::AppEvent`] / 各 `*View` 快照 | 派发指令、消费事件、把快照字段映射成视图模型 | //! | [`host`] | [`host::CoreHost`](实现 [`dto::Backend`])、[`host::HostEnv`] | 进程启动时构造一次,之后只当 `Box` 用 | //! | [`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; diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index c928460..a84135a 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -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, - /// 是否有流式消息在跑 - 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, /// 选中的日志会话 pub selected_session: Option, - /// 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(); + } } } diff --git a/crates/presenter/src/state_tests.rs b/crates/presenter/src/state_tests.rs index 6001569..7d589fb 100644 --- a/crates/presenter/src/state_tests.rs +++ b/crates/presenter/src/state_tests.rs @@ -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,107 @@ 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() { + 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(); From 06461df91bda8e440c6714c062d74a62cebedab1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 22:57:07 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(presenter):=20M103=20r1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=84=B1=E6=95=8F=E9=97=B8=E9=97=A8=E8=A1=A5?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=EF=BC=8CP2=20=E4=BA=94=E6=9D=A1=E5=B8=A6?= =?UTF-8?q?=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 置忙、静默后端不留假忙、工具收尾不覆盖历史 --- crates/presenter/src/agent.rs | 55 ++++- crates/presenter/src/agent/rail.rs | 110 +++++++-- crates/presenter/src/agent/tests.rs | 133 +++++------ crates/presenter/src/agent/tests_red_line.rs | 230 +++++++++++++++++++ crates/presenter/src/state_tests.rs | 3 + 5 files changed, 435 insertions(+), 96 deletions(-) create mode 100644 crates/presenter/src/agent/tests_red_line.rs diff --git a/crates/presenter/src/agent.rs b/crates/presenter/src/agent.rs index 3231e0c..514de91 100644 --- a/crates/presenter/src/agent.rs +++ b/crates/presenter/src/agent.rs @@ -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(()) } diff --git a/crates/presenter/src/agent/rail.rs b/crates/presenter/src/agent/rail.rs index 06af545..a927a43 100644 --- a/crates/presenter/src/agent/rail.rs +++ b/crates/presenter/src/agent/rail.rs @@ -26,7 +26,14 @@ //! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` / //! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是 //! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,rail 上也不会出现原文。 -//! 复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」规则),不另写一套正则。 +//! +//! 规则来源(刻意不另写一套正则): +//! - 内容侧:复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」+ ≥64 不透明串); +//! - 上下文侧:照 `unlucky_agent::redact` 补两条——`Bearer <短令牌>` 掩码、 +//! `{"path": 敏感键}` 时 `value`/`old`/`new` 一并掩码(`set_config` 的 details 正是它)。 +//! +//! 键名名单与 `unlucky_agent::redact::is_sensitive_key` **当前逐字相同**;差异在上下文规则 +//! 上(agent 侧多一条 path 规则,presenter 已补齐,见上)。漂移风险记在 M103 报告里。 //! //! # 3. 刻意不做的事 //! @@ -63,27 +70,39 @@ pub fn is_sensitive_key(key: &str) -> bool { .any(|needle| lower.contains(needle)) } -/// 文本脱敏:JWT 形状(带点 token)/ ≥64 不透明串由 core 的规则打码。 +/// 文本脱敏:内容规则三件套。 /// -/// 不登记主目录(rail 不持有数据根):`` 掩码是 host 侧的职责。 +/// 1. JWT 形状(带点 token)/ ≥64 不透明串 —— 复用 core 的 `Redactor`(M40 上提的那份); +/// 2. `Bearer` 后面的短令牌 —— core 没有这条,照 `unlucky_agent::redact::mask_bearer` 补; +/// 3. 主目录不登记:rail 不持有数据根,`` 掩码是 host 侧的职责。 #[must_use] pub fn scrub_text(text: &str) -> String { - Redactor::new().scrub(text).into_owned() + mask_bearer(&Redactor::new().scrub(text)) } -/// 结构化细节脱敏:先按键名掩码,再按内容掩码(两道都要,见模块文档 §2)。 +/// 结构化细节脱敏:按**键名**、按**值随 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 = if is_sensitive_key(key) { + 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(), masked); + out.insert(key.clone(), child); } Value::Object(out) } @@ -93,6 +112,38 @@ pub fn scrub_details(value: &Value) -> Value { } } +/// `Bearer` 之后的分隔符(令牌到此为止)。 +const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';']; + +/// `Bearer ` → `Bearer `。 +/// +/// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`) +/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 补上。幂等:已经是 +/// `` 的再跑一遍结果不变。 +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 { @@ -144,7 +195,22 @@ impl AgentRail { for message in &mut self.messages { message.streaming = false; } - self.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(); } @@ -182,8 +248,15 @@ impl AgentRail { AgentEvent::MessageDelta { index, delta } => { // 只追加正文,不动流式标记:迟到的尾增量(`Failed` 之后)不该把 // 「一轮在跑」重新点亮,否则输入框会一直禁用。 - if let Some(message) = self.messages.get_mut(*index) { - message.text.push_str(&scrub_text(delta)); + 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 @@ -193,8 +266,7 @@ impl AgentRail { message.streaming = false; } // 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄 - self.streaming = self.messages.iter().any(|message| message.streaming); - self.sync_busy(); + self.settle_streaming(); false } AgentEvent::ToolCallStarted { name, summary } => { @@ -217,18 +289,13 @@ impl AgentRail { 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)); - let target = pending.or_else(|| { - self.messages - .iter() - .rposition(|message| tool_named(message, &name)) - }); - if let Some(index) = target { + if let Some(index) = pending { let message = &mut self.messages[index]; message.streaming = false; message.text.clone_from(&summary); @@ -237,8 +304,7 @@ impl AgentRail { tool.summary = summary; } } - self.streaming = self.messages.iter().any(|message| message.streaming); - self.sync_busy(); + self.settle_streaming(); false } AgentEvent::Failed { message } => { diff --git a/crates/presenter/src/agent/tests.rs b/crates/presenter/src/agent/tests.rs index 9de30a1..6dccdaf 100644 --- a/crates/presenter/src/agent/tests.rs +++ b/crates/presenter/src/agent/tests.rs @@ -1,18 +1,15 @@ //! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+ -//! 红线(凭据形状的串不得进 rail)+ YOLO 语义 + 提交前置条件。 +//! 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}; -/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。 -const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; - fn presenter(script: Script) -> Presenter { Presenter::new(Box::new(MockAgentTransport::new(script))) } @@ -172,65 +169,68 @@ fn pending_confirm_forces_busy_without_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.confirm.is_some()); + assert!(!rail.streaming, "此刻没有流式消息在跑"); 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, - }, - })); +fn begin_turn_marks_busy_before_any_event_arrives() { + // 提交成功到第一个事件之间异步后端有空窗:这段时间必须已经算忙, + // 否则用户能再点一次发送(两条 user 消息、两轮回复)。 + let mut rail = AgentRail::new(); + rail.begin_turn(); + assert!(rail.busy && rail.streaming); - let rail = presenter.agent_rail(); - let text = rail_text(rail); - assert!( - !text.contains("eyJhbGci"), - "rail 上不得出现 JWT 原文:{text}" - ); - assert!( - text.contains(""), - "应当被脱敏成占位符:{text}" - ); - // 凭据键名的值整段打码(不只是靠内容规则) - let details = &rail.confirm.as_ref().expect("确认请求仍在").details; - assert_eq!(details["access_token"], ""); - assert!(!details["note"] - .as_str() - .unwrap_or_default() - .contains("eyJhbGci")); + // 空事件流也不会把输入框永久锁死:收敛时按消息重算 + rail.settle_streaming(); + assert!(!rail.busy && !rail.streaming); } #[test] -fn scrub_text_is_idempotent_on_already_redacted_text() { - let once = scrub_text(JWT); - assert_eq!(scrub_text(&once), once); +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); } // ─────────────────────── 提交前置条件 ─────────────────────── @@ -320,22 +320,15 @@ impl AgentTransport for BrokenTransport { } } -/// 把 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); - } +/// 收下输入但一个事件都不回的后端(异步推流的极端形态:这一拍还没到)。 +struct SilentTransport; + +impl AgentTransport for SilentTransport { + fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result, String> { + Ok(Vec::new()) } - 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()); + + fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec { + Vec::new() } - out } diff --git a/crates/presenter/src/agent/tests_red_line.rs b/crates/presenter/src/agent/tests_red_line.rs new file mode 100644 index 0000000..89c2a7f --- /dev/null +++ b/crates/presenter/src/agent/tests_red_line.rs @@ -0,0 +1,230 @@ +//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`]。 +//! +//! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**: +//! 每条入口各自注入,任何一条把脱敏摘掉都必须变红(r1 复审发现三条入口是假绿,故单列一文件, +//! 改动这里时请照 r1 的做法逐入口造变异自证)。 +//! +//! 注入的凭据形状两类: +//! - JWT(`header.payload.signature`,靠 core `Redactor` 的「带点 token」规则拦); +//! - 非 JWT 形状(`{"password":"hunter2"}`、短 `Bearer abcdef123456`,靠键名与 Bearer 规则拦)。 + +use super::mock::{MockAgentTransport, Script}; +use super::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))) +} + +#[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(""), + "应当被脱敏成占位符:{text}" + ); + // 凭据键名的值整段打码(不只是靠内容规则) + let details = &rail.confirm.as_ref().expect("确认请求仍在").details; + assert_eq!(details["access_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!(""); + // 键名命中:对象 / 嵌套 / 数组各一 + 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 "), "{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); +} + +/// 把 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 +} diff --git a/crates/presenter/src/state_tests.rs b/crates/presenter/src/state_tests.rs index 7d589fb..e1a4358 100644 --- a/crates/presenter/src/state_tests.rs +++ b/crates/presenter/src/state_tests.rs @@ -270,6 +270,9 @@ fn failed_event_settles_streaming_and_keeps_the_evidence() { #[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"), From a2b1a0690164c3a67a54aa262d9d5ba553230f3c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 00:16:39 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(presenter):=20M103=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=87=BA=E7=AB=99=E8=84=B1=E6=95=8F=E8=A1=A5?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=EF=BC=8C=E5=8F=A6=E4=B8=89=E5=A4=84=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E4=B8=8E=20Bearer=20=E5=8F=B3=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1(守卫缺失,产品代码是对的): - 新增 RecordingTransport + outbound_prompt_is_scrubbed_before_it_reaches_the_backend: 断言真正发给后端的那份 prompt 里没有 JWT(rail 侧也脱敏,「界面干净」证明不了出站安全) - 变异:删掉 agent_submit 的出站 scrub ⇒ 该测试红(输出见完成报告) 另三处同类站点(r2 P2-1): - confirm_tool_name_is_scrubbed(ConfirmRequest.tool) - tool_message_from_the_backend_has_its_tool_view_scrubbed(MessageAppended 的 ToolCallView 分支) - outbound_error_text_is_scrubbed(Err 文案,provider 401 常回显 key) - 三条各自变异后都变红 其他: - bearer_right_boundary_is_pinned(tests.rs):钉住 mask_bearer 的停字符集,收窄成 [' '] 即红 - 文档订正:rail.rs 模块 §2 改成「两道闸门(出站/显示)各守一侧」+ 与 agent 侧的实际关系 (名单逐字相同、path 语义相同、mask_bearer 是行为不完全等价的自写迁移;重复实现是欠账, 不读成「已无差异」也不读成「已经漂移」) - agent_set_draft 与 rail_text 各写明「draft 不在断言覆盖范围」及原因 - tests_red_line.rs 4 行 405 → 380;各文件均 ≤400 --- crates/presenter/src/agent.rs | 12 +- crates/presenter/src/agent/rail.rs | 39 +++-- crates/presenter/src/agent/tests.rs | 28 ++++ crates/presenter/src/agent/tests_red_line.rs | 160 ++++++++++++++++++- 4 files changed, 222 insertions(+), 17 deletions(-) diff --git a/crates/presenter/src/agent.rs b/crates/presenter/src/agent.rs index 514de91..ef0c3cd 100644 --- a/crates/presenter/src/agent.rs +++ b/crates/presenter/src/agent.rs @@ -191,6 +191,10 @@ impl Presenter { } /// 输入框草稿(用户打字时同步进来,切形态不丢)。 + /// + /// 草稿**是** rail 上唯一能合法躺着凭据原文的字段:它是「还没发出去的字」,写进来时不过 + /// 脱敏(UI 要能原样显示用户正在打的字)。安全性靠另一条:`agent_submit` 脱敏的是**入参** + /// 而不是草稿,所以原文既不会进会话历史,也不会被发给后端。 pub fn agent_set_draft(&mut self, text: String) { self.state.ai.draft = text; } @@ -226,12 +230,16 @@ impl Presenter { if self.state.ai.busy { return Err("上一轮回复还没结束".to_string()); } - // 用户输入也过脱敏:他可能顺手把 token 粘进对话框,那段文本会进会话历史、 - // 也会进后续 prompt 与导出。 + // 用户输入也过脱敏:他可能顺手把 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; diff --git a/crates/presenter/src/agent/rail.rs b/crates/presenter/src/agent/rail.rs index a927a43..557d97a 100644 --- a/crates/presenter/src/agent/rail.rs +++ b/crates/presenter/src/agent/rail.rs @@ -20,20 +20,38 @@ //! | `Failed` | 收敛流式 + 立横幅 + 追加系统条目(错误也要能回翻) | //! | `ConfirmRequested` / `ConfirmResolved` | `rail.confirm` 置位 / 清除 | //! -//! # 2. 红线:进入 rail 的文本一律脱敏 +//! # 2. 红线:凭据不得进 rail,也不得**出站** //! -//! [`AgentRail::ingest`] 是所有 agent 文本进入 rail 的**唯一闸门**,它逐字段过 -//! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` / -//! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是 -//! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,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::is_sensitive_key` **当前逐字相同**;差异在上下文规则 -//! 上(agent 侧多一条 path 规则,presenter 已补齐,见上)。漂移风险记在 M103 报告里。 +//! 与 `unlucky_agent::redact` 的实际关系(**别读成「已无差异」,也别读成「已经漂移」**): +//! - 键名名单(8 条 needle):**逐字相同**; +//! - path 规则:**语义相同**(presenter 额外对字符串值跑 `scrub_text`,比 `redact_args` 更严); +//! - [`mask_bearer`]:**行为不完全等价的自写迁移**——停字符集 presenter 8 个(含 `;`, +//! 不含 `/` `\`)vs agent `segment_end` 9 个(含 `/` `\`,不含 `;`),占位符也不同 +//! (`` 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. 刻意不做的事 //! @@ -112,14 +130,15 @@ pub fn scrub_details(value: &Value) -> Value { } } -/// `Bearer` 之后的分隔符(令牌到此为止)。 +/// `Bearer` 之后的分隔符(令牌到此为止)。与 agent `segment_end` 的差异见模块文档 §2: +/// 多 `;`、少 `/` `\`(方向安全:多掩不漏),右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。 const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';']; /// `Bearer ` → `Bearer `。 /// /// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`) -/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 补上。幂等:已经是 -/// `` 的再跑一遍结果不变。 +/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 的意图补上(实现是自写的, +/// 见模块文档 §2 的差异说明)。幂等:已经是 `` 的再跑一遍结果不变。 fn mask_bearer(text: &str) -> String { const MARKER: &str = "Bearer "; let mut out = String::with_capacity(text.len()); diff --git a/crates/presenter/src/agent/tests.rs b/crates/presenter/src/agent/tests.rs index 6dccdaf..cc8796e 100644 --- a/crates/presenter/src/agent/tests.rs +++ b/crates/presenter/src/agent/tests.rs @@ -7,6 +7,7 @@ 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}; @@ -233,6 +234,33 @@ fn tool_call_finished_without_a_pending_call_never_rewrites_history() { 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 , retry=1" + ); + assert_eq!( + scrub_text("Bearer abc123; rest"), + "Bearer ; rest" + ); + assert_eq!( + scrub_text("\"Bearer abc123\""), + "\"Bearer \"" + ); + assert_eq!(scrub_text("Bearer abc123"), "Bearer "); + // 句点不是停字符:整段吞掉(宁可多掩,也不让短 JWT 只掩前半段) + assert_eq!(scrub_text("Bearer abc123."), "Bearer "); + assert_eq!(scrub_text("Bearer eyJ.abc.def"), "Bearer "); +} + // ─────────────────────── 提交前置条件 ─────────────────────── #[test] diff --git a/crates/presenter/src/agent/tests_red_line.rs b/crates/presenter/src/agent/tests_red_line.rs index 89c2a7f..4790ac7 100644 --- a/crates/presenter/src/agent/tests_red_line.rs +++ b/crates/presenter/src/agent/tests_red_line.rs @@ -1,18 +1,28 @@ -//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`]。 +//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`],也不得**出站**。 //! //! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**: -//! 每条入口各自注入,任何一条把脱敏摘掉都必须变红(r1 复审发现三条入口是假绿,故单列一文件, -//! 改动这里时请照 r1 的做法逐入口造变异自证)。 +//! 两道闸门(出站 [`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::Presenter; +use super::{AgentTransport, Presenter}; use crate::agent::rail::scrub_text; use crate::agent::AgentRail; -use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity}; +use crate::dto::{ + AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView, +}; /// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。 const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; @@ -209,7 +219,147 @@ fn scrub_text_is_idempotent_on_already_redacted_text() { assert_eq!(scrub_text(&bearer), bearer); } +// ───────────────── 出站闸门:发给后端的那份 payload ───────────────── + +/// 记录 `submit` 实参的后端替身:红线里最重要的是「**真正发出去的是什么**」, +/// 而不是 rail 上显示成什么(显示层自己还会再脱一次,会把出站的漏洞盖住)。 +#[derive(Clone, Default)] +struct RecordingTransport { + seen: Rc>>, +} + +impl AgentTransport for RecordingTransport { + fn submit(&mut self, text: &str, _history: &AgentRail) -> Result, 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 { + Vec::new() + } +} + +/// 一律报错的后端替身(用来守 Err 文案那道 scrub)。 +struct ErrorTransport(String); + +impl AgentTransport for ErrorTransport { + fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result, String> { + Err(self.0.clone()) + } + + fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec { + 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(""), + "出站 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(""), "{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 {