- 新增 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)
166 lines
6.1 KiB
Rust
166 lines
6.1 KiB
Rust
//! 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)
|
||
}
|