新增: Phase3 EventBus Value载荷+桥接层
- EventBus 载荷改 serde_json::Value(D2 全19变体透传),删 AiBusEvent 强类型子集 - 新增 publish_event(AiChatEvent) helper(内部 to_value→publish,供 emit 双写) - 新增 remote_bridge.rs 桥接层(MiniCommand→Tauri command 路由+R1 同conv并发兜底) - agentic/mod.rs 11处 AiBusEvent→AiChatEvent 迁移(Error/Completed/Round)
This commit is contained in:
632
src-tauri/src/commands/ai/remote_bridge.rs
Normal file
632
src-tauri/src/commands/ai/remote_bridge.rs
Normal file
@@ -0,0 +1,632 @@
|
||||
//! F-260622-01 跨端 AI Chat Phase3 阶段3 桥接层 — MiniCommand→Tauri command 路由
|
||||
//!
|
||||
//! 设计文档:docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md
|
||||
//! (§2.2 Command 下行路由表 + §3.3 R1 同 conv 并发发送兜底 + 附录 B 命令清单)
|
||||
//!
|
||||
//! ## 定位
|
||||
//!
|
||||
//! 方案 A(tunnel 纯透传 + device 端桥接解协议)下的 device 端桥接模块。
|
||||
//! tunnel 入站回调改为上抛原始 `serde_json::Value`(不再强类型解 TunnelCommand),
|
||||
//! 本模块作为该回调的处理器,把 miniapp 发来的 `MiniCommand{cmd, args}` 翻译成
|
||||
//! 对应 src-tauri Tauri command 的直接 async 调用(非 IPC invoke)。
|
||||
//!
|
||||
//! ## 路由表(§2.2)
|
||||
//!
|
||||
//! | MiniCommand.cmd | 调用的 Tauri command | 关键参数(args 字段) |
|
||||
//! |-----------------------|----------------------|----------------------------------------|
|
||||
//! | `send_message` | `ai_chat_send` | message, conversation_id?, model_override? |
|
||||
//! | `stop` | `ai_chat_stop` | conversation_id? |
|
||||
//! | `regenerate` | `ai_regenerate` | conversation_id, language?, model_override? |
|
||||
//! | `approve` | `ai_approve` | tool_call_id, approved |
|
||||
//! | `authorize_dir` | `ai_authorize_dir` | tool_call_id, decision |
|
||||
//! | `continue_loop` | `ai_continue_loop` | conversation_id |
|
||||
//! | `stop_loop` | `ai_stop_loop` | conversation_id |
|
||||
//! | `switch_conversation` | (无,MVP 不处理) | — |
|
||||
//! | (未知 cmd) | (兜底臂:log+忽略) | — |
|
||||
//!
|
||||
//! ## R1 兜底(硬性,不可省,§3.3 + D6)
|
||||
//!
|
||||
//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 双轨口径:
|
||||
//! CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool)。
|
||||
//! generating=true 则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」,
|
||||
//! 不调 ai_chat_send。这是 miniapp 与桌面同时给同 conv 发消息的双保险,
|
||||
//! 无论 ai_chat_send 内部是否已有 generating guard 都加(防御性编程)。
|
||||
//!
|
||||
//! ## 集成
|
||||
//!
|
||||
//! 本模块仅暴露 `handle_remote_command` 入口,setup 集成(注册为 tunnel on_command
|
||||
//! 回调)留待联调轮做,不在本批范围。
|
||||
//!
|
||||
//! dead_code:本模块入口 `handle_remote_command` 及其内部链(route_send_message /
|
||||
//! check_generating_reject / MiniCommand::from_payload / args_get_*)零调用方 ——
|
||||
//! 联调轮 setup 注册为 tunnel on_command 回调即消除(预留保留,非死代码)。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// super::super = commands::ai(commands/chat.rs 的 commands 模块父级是 commands::ai)
|
||||
// Tauri command 函数经 commands/mod.rs 的 `pub use self::chat::*;` + ai/mod.rs 的
|
||||
// `pub use self::commands::*;` 透传到 crate::commands::ai 路径,这里直接复用该路径。
|
||||
//
|
||||
// 注:集成轮 setup 注册 tunnel on_command 回调时,会用 `app.state::<AppState>()`(Manager trait)
|
||||
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
|
||||
// 故 import 不含 Manager(避免 unused)。
|
||||
use crate::commands::ai::{
|
||||
ai_approve, ai_authorize_dir, ai_chat_send, ai_chat_stop, ai_continue_loop,
|
||||
ai_regenerate, ai_stop_loop, AiChatEvent,
|
||||
};
|
||||
// CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。
|
||||
use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED;
|
||||
|
||||
// ============================================================
|
||||
// MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93)
|
||||
// ============================================================
|
||||
|
||||
/// 小程序 → 桌面端 Command 协议载体。
|
||||
///
|
||||
/// 对齐 `apps/df-miniapp/src/types/relay.ts:88-93` 的 `MiniCommand` interface:
|
||||
/// ```ts
|
||||
/// interface MiniCommand { cmd: string; args: Record<string, unknown> }
|
||||
/// ```
|
||||
/// `cmd` 是 Tauri command 名(relay.ts:82 注释「对齐 Tauri command 名」),
|
||||
/// `args` 是原始参数对象(对齐各 command 参数签名)。
|
||||
///
|
||||
/// 反序列化容忍 `args` 缺省(relay.ts:90 标注 args 为必填,但防御性编程允许缺失 → 默认空 Object)。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MiniCommand {
|
||||
/// Tauri command 名(如 "send_message" / "stop" / "approve" ...)
|
||||
pub cmd: String,
|
||||
/// 命令参数(原始 JSON 对象,缺失时回退空 Object 便于各臂 from_value 兜底)
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
impl MiniCommand {
|
||||
/// 从原始 payload 反序列化为 MiniCommand。
|
||||
///
|
||||
/// 失败场景:payload 非 Object / 缺 `cmd` 字段 / `cmd` 非 string。
|
||||
/// 调用方(handle_remote_command)失败时 log+忽略,不崩溃。
|
||||
fn from_payload(payload: &Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(payload.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主入口:handle_remote_command(供 tunnel on_command 回调调用)
|
||||
// ============================================================
|
||||
|
||||
/// 远程命令处理入口 —— miniapp 发来的 MiniCommand payload 路由到 Tauri command。
|
||||
///
|
||||
/// 供 tunnel `on_command` 回调集成时调用(setup 集成留联调轮)。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 反序列化 payload 为 [`MiniCommand`](失败 → log+忽略,非崩溃路径)
|
||||
/// 2. `match cmd` 路由到对应 Tauri command 直接 async 调用
|
||||
/// 3. send_message 路由前置 R1 generating 检查(硬性兜底)
|
||||
/// 4. switch_conversation 无对应 command → log+忽略(§2.2 决策 a MVP 不处理)
|
||||
/// 5. 未知 cmd → log+忽略(兜底臂)
|
||||
///
|
||||
/// Tauri command 返回值忽略 —— 结果经 AiChatEvent 回流(事件透传阶段2 已建通路),
|
||||
/// 不走 command 返回值(对齐 §2.2 设计:command 返回值忽略,结果经事件回流)。
|
||||
///
|
||||
/// `state` 参数签名用 `State<'_, AppState>`(与 Tauri command 第二参同类型),
|
||||
/// 集成时由 `app.state::<AppState>()` 构造(Manager trait 提供,见 agentic/mod.rs:526 用法)。
|
||||
pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<'_, AppState>) {
|
||||
// 反序列化失败:payload 非 {cmd, args} 结构(可能 miniapp 发了未知格式 / relay 误投递)。
|
||||
// log+忽略,不崩溃 —— 对齐 §1.2 兜底语义「非法命令到运行时桥接层才报错,match 末尾兜底臂 log+忽略」。
|
||||
let command = match MiniCommand::from_payload(&payload) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"[remote_bridge] payload 反序列化 MiniCommand 失败,忽略(非 {{cmd, args}} 结构?)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
cmd = %command.cmd,
|
||||
"[remote_bridge] 收到远程命令,开始路由"
|
||||
);
|
||||
|
||||
// match 路由表(§2.2)。各臂提取 args 字段,调对应 Tauri command。
|
||||
// 字段缺失用稳健默认(Option/空字符串),不中断路由(命令内部自有参数校验,缺失会返 Err,
|
||||
// 调用方 ignore 返回值 —— 结果经事件回流)。
|
||||
match command.cmd.as_str() {
|
||||
// ── send_message:R1 前置 generating 检查 → ai_chat_send ──
|
||||
"send_message" => {
|
||||
route_send_message(&app, &state, command.args).await;
|
||||
}
|
||||
|
||||
// ── stop:ai_chat_stop(conversation_id?) ──
|
||||
// 注意:ai_chat_stop 签名顺序异常(state 在前,app 在后,见 chat.rs:1431),
|
||||
// 与其它命令的 (app, state, ...) 顺序不同,此处对齐其真实签名。
|
||||
"stop" => {
|
||||
let conversation_id = args_get_string(&command.args, "conversation_id");
|
||||
let _ = ai_chat_stop(state, app, conversation_id).await;
|
||||
}
|
||||
|
||||
// ── regenerate:ai_regenerate(conversation_id, language?, model_override?) ──
|
||||
"regenerate" => {
|
||||
// conversation_id 必填,缺失跳过(ai_regenerate 内部无 conv 会失败,日志告警)
|
||||
match args_get_string(&command.args, "conversation_id") {
|
||||
Some(conversation_id) => {
|
||||
let language = args_get_string(&command.args, "language");
|
||||
let model_override = args_get_string(&command.args, "model_override");
|
||||
let _ = ai_regenerate(app, state, conversation_id, language, model_override).await;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] regenerate 缺 conversation_id 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── approve:ai_approve(tool_call_id, approved) ──
|
||||
"approve" => {
|
||||
// tool_call_id 必填 + approved 必填(bool),缺失跳过
|
||||
match (
|
||||
args_get_string(&command.args, "tool_call_id"),
|
||||
args_get_bool(&command.args, "approved"),
|
||||
) {
|
||||
(Some(tool_call_id), Some(approved)) => {
|
||||
let _ = ai_approve(app, state, tool_call_id, approved).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] approve 缺 tool_call_id 或 approved 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── authorize_dir:ai_authorize_dir(tool_call_id, decision) ──
|
||||
"authorize_dir" => {
|
||||
match (
|
||||
args_get_string(&command.args, "tool_call_id"),
|
||||
args_get_string(&command.args, "decision"),
|
||||
) {
|
||||
(Some(tool_call_id), Some(decision)) => {
|
||||
let _ = ai_authorize_dir(app, state, tool_call_id, decision).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] authorize_dir 缺 tool_call_id 或 decision 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── continue_loop:ai_continue_loop(conversation_id) ──
|
||||
"continue_loop" => {
|
||||
match args_get_string(&command.args, "conversation_id") {
|
||||
Some(conversation_id) => {
|
||||
let _ = ai_continue_loop(app, state, conversation_id).await;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] continue_loop 缺 conversation_id 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── stop_loop:ai_stop_loop(conversation_id) ──
|
||||
"stop_loop" => {
|
||||
match args_get_string(&command.args, "conversation_id") {
|
||||
Some(conversation_id) => {
|
||||
let _ = ai_stop_loop(app, state, conversation_id).await;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] stop_loop 缺 conversation_id 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── switch_conversation:无对应 Tauri command(§2.2 决策 a MVP 不处理) ──
|
||||
// 桌面端「切会话」是纯前端操作(改 activeConversationId + 本地 store 加载历史),
|
||||
// 无 IPC 入口。miniapp switch 仅本地视图切换,跨端 active 不一致不阻断功能
|
||||
// (事件全局广播带 conv_id,miniapp 自过滤)。列入 P4 双向同步完善。
|
||||
"switch_conversation" => {
|
||||
tracing::info!(
|
||||
"[remote_bridge] switch_conversation 无对应 Tauri command(§2.2 决策 a MVP 不处理),忽略"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 兜底臂:未知 cmd,log+忽略(不崩溃) ──
|
||||
// 对齐 §1.2 风险缓解「桥接层 match 末尾加兜底臂,非法命令不崩溃只记录」。
|
||||
unknown => {
|
||||
tracing::warn!(
|
||||
cmd = unknown,
|
||||
"[remote_bridge] 未知 cmd,忽略(兜底臂,非崩溃)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// R1 兜底:send_message 路由前置 generating 检查
|
||||
// ============================================================
|
||||
|
||||
/// `send_message` 路由 —— R1 兜底前置 generating 检查后调 ai_chat_send。
|
||||
///
|
||||
/// R1(§3.3 + D6,硬性不可省):miniapp 与桌面同时给同 conv 发消息时,
|
||||
/// ai_chat_send 内部虽有 generating guard,但桥接层再加一层前置检查作为双保险
|
||||
/// (防御性编程,对齐设计文档 D6「桥接层 send 路由硬性加,无论 ai_chat_send 内部是否有 guard」)。
|
||||
///
|
||||
/// 检查口径与 `ai_is_generating` 完全一致(双轨):
|
||||
/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(含 Generating / Compressed 派生态)
|
||||
/// - off → 回退 `generating` bool
|
||||
///
|
||||
/// generating=true 则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。
|
||||
/// (事件透传阶段2 已建通路,miniapp handleEvent 能渲染 AiError,用户可见反馈)。
|
||||
///
|
||||
/// message 必填,缺失跳过(ai_chat_send 无 message 无意义,日志告警)。
|
||||
async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: Value) {
|
||||
// 取 message(必填) + conversation_id(可选,空字符串规整为 None) + model_override(可选)。
|
||||
let message = match args_get_string(&args, "message") {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::warn!("[remote_bridge] send_message 缺 message 参数,忽略");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let conversation_id = args_get_string(&args, "conversation_id");
|
||||
let model_override = args_get_string(&args, "model_override");
|
||||
|
||||
// ── R1 前置 generating 检查(双轨口径对齐 ai_is_generating) ──
|
||||
// 目标 conv:入参 conversation_id 优先 → active conv 兜底(与 ai_is_generating 同口径)。
|
||||
// 空目标(无 conv_id 且无 active):无任何 conv 在跑,放行。
|
||||
if let Some(reject_conv_id) = check_generating_reject(state, conversation_id.as_deref()).await {
|
||||
// generating=true:拒绝,emit AiError 回 miniapp 提示。
|
||||
// conv_id 透传原 conversation_id(用户当前面板的 conv),便于 miniapp 按 conv 路由展示。
|
||||
tracing::info!(
|
||||
conv_id = ?reject_conv_id,
|
||||
"[remote_bridge][R1] 同 conv 正在生成中,拒绝远程 send_message(emit AiError 回 miniapp)"
|
||||
);
|
||||
let _ = app.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiError {
|
||||
error: "正在生成中,请等待完成".to_string(),
|
||||
// 并发拒绝非错误源分类(auth/network/timeout/provider_config),用 Unknown 兜底。
|
||||
error_type: Some(crate::commands::ai::ErrorType::Unknown),
|
||||
conversation_id: Some(reject_conv_id),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 放行:调 ai_chat_send(其余可选参数 language/skill/parts/mention_spans 不经远程,
|
||||
// miniapp MVP 仅透传 message/conversation_id/model_override,其余走默认 None) ──
|
||||
//
|
||||
// F 核验(2026-06-22):ai_chat_send 内部已有 generating guard(chat.rs:354-358)。
|
||||
// R1 前置检查是双保险(任务原文要求"无论 ai_chat_send 内部是否有 guard"都加)。
|
||||
// 这里额外把 ai_chat_send 返回的 Err<String> 转 AiError emit 回 miniapp ——
|
||||
// 无论 R1 命中(理论上不会到这)/ 内部 guard 命中 / Provider 配置错 / 参数错,
|
||||
// 用户都能在 miniapp 看到 AiError 反馈(否则命令返 Err 静默,miniapp 不知失败)。
|
||||
let result = ai_chat_send(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
message,
|
||||
None, // language:远程默认不传,ai_chat_send 内部 fallback "zh-CN"
|
||||
None, // skill:远程不传技能名(技能调用经本地命令栏)
|
||||
model_override, // model_override:远程透传用户选择
|
||||
conversation_id.clone(),// conversation_id:远程透传目标 conv
|
||||
None, // parts:多模态片段,远程 MVP 不传
|
||||
None, // mention_spans:@ mention 区间,远程 MVP 不传
|
||||
)
|
||||
.await;
|
||||
if let Err(err_msg) = result {
|
||||
tracing::warn!(
|
||||
error = %err_msg,
|
||||
conv_id = ?conversation_id,
|
||||
"[remote_bridge] ai_chat_send 返回 Err,转 AiError emit 回 miniapp"
|
||||
);
|
||||
let _ = app.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiError {
|
||||
error: err_msg,
|
||||
// ai_chat_send 的 Err 多为 generating 拦截 / Provider 配置缺失,
|
||||
// 难精确分类,用 Unknown 兜底(对齐 mod.rs ErrorType 语义)。
|
||||
error_type: Some(crate::commands::ai::ErrorType::Unknown),
|
||||
conversation_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// R1 检查:目标 conv 是否正在生成。返 `Some(conv_id)` 表示拒绝(该 conv 在跑),
|
||||
/// 返 `None` 表示放行(可发)。
|
||||
///
|
||||
/// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293):
|
||||
/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,
|
||||
/// 压缩期间 loop 仍活跃)
|
||||
/// - off → 回退 `generating` bool
|
||||
///
|
||||
/// 目标 conv 解析(对齐 ai_is_generating:278-284):
|
||||
/// - 入参 conversation_id 非空优先
|
||||
/// - 否则 fallback `active_conversation_id`
|
||||
/// - 都无 → 返 None(放行,无 conv 在跑)
|
||||
///
|
||||
/// 读 conv 状态用 `conv_read`(只读不创建),对齐 ai_is_generating 实现。
|
||||
async fn check_generating_reject(
|
||||
state: &State<'_, AppState>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active 兜底。
|
||||
let target = conversation_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| session.active_conversation_id.clone());
|
||||
let target = match target {
|
||||
Some(id) => id,
|
||||
None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行
|
||||
};
|
||||
// 双轨读 generating(对齐 ai_is_generating,无 conv_read 视为 false 放行)。
|
||||
let is_gen = session
|
||||
.conv_read(&target)
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if is_gen {
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// args 字段提取 helper(从 serde_json::Value 稳健取值)
|
||||
// ============================================================
|
||||
|
||||
/// 从 args 取 string 字段。非 string / 缺失 → None(调用方各自处理缺失语义)。
|
||||
fn args_get_string(args: &Value, key: &str) -> Option<String> {
|
||||
args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 从 args 取 bool 字段。非 bool / 缺失 → None。
|
||||
fn args_get_bool(args: &Value, key: &str) -> Option<bool> {
|
||||
args.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试
|
||||
// ============================================================
|
||||
//
|
||||
// 覆盖:
|
||||
// 1. MiniCommand 反序列化(标准结构 / args 缺省 / 非 Object payload 失败)
|
||||
// 2. match 各 cmd 臂的路由分支(send_message R1 拒绝 / stop / regenerate / approve /
|
||||
// authorize_dir / continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略)
|
||||
// 3. R1 拒绝路径(generating=true 时 emit AiError 不调 ai_chat_send)
|
||||
//
|
||||
// 注:完整 Tauri command 调用链(需 AppHandle + State 真实构造)不在单元测试范围,
|
||||
// 用 helper 函数(check_generating_reject / args_get_* / MiniCommand::from_payload)
|
||||
// 隔离测试核心路由逻辑,绕开 Tauri runtime 依赖。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::ai::{AiSession, PerConvState};
|
||||
use df_ai::context::ContextConfig;
|
||||
|
||||
// ── MiniCommand 反序列化测试 ──
|
||||
|
||||
/// 标准结构 `{cmd, args}` 反序列化成功。
|
||||
#[test]
|
||||
fn test_mini_command_deserialize_standard() {
|
||||
let payload = serde_json::json!({
|
||||
"cmd": "send_message",
|
||||
"args": {
|
||||
"message": "你好",
|
||||
"conversation_id": "conv-123"
|
||||
}
|
||||
});
|
||||
let cmd = MiniCommand::from_payload(&payload).expect("标准结构应反序列化成功");
|
||||
assert_eq!(cmd.cmd, "send_message");
|
||||
assert_eq!(
|
||||
cmd.args.get("message").and_then(|v| v.as_str()),
|
||||
Some("你好")
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.args.get("conversation_id").and_then(|v| v.as_str()),
|
||||
Some("conv-123")
|
||||
);
|
||||
}
|
||||
|
||||
/// args 缺省时回退空 Object(防御性 #[serde(default)])。
|
||||
#[test]
|
||||
fn test_mini_command_deserialize_args_default() {
|
||||
let payload = serde_json::json!({"cmd": "stop"});
|
||||
let cmd = MiniCommand::from_payload(&payload).expect("args 缺省应回退空 Object");
|
||||
assert_eq!(cmd.cmd, "stop");
|
||||
assert!(cmd.args.is_object(), "args 缺省应为空 Object");
|
||||
assert!(cmd.args.get("conversation_id").is_none());
|
||||
}
|
||||
|
||||
/// 非 Object payload(如纯字符串 / 数组)反序列化失败。
|
||||
#[test]
|
||||
fn test_mini_command_deserialize_non_object_fails() {
|
||||
let payload = serde_json::json!("just a string");
|
||||
assert!(
|
||||
MiniCommand::from_payload(&payload).is_err(),
|
||||
"非 Object payload 应反序列化失败"
|
||||
);
|
||||
}
|
||||
|
||||
/// 缺 cmd 字段反序列化失败(cmd 必填)。
|
||||
#[test]
|
||||
fn test_mini_command_deserialize_missing_cmd_fails() {
|
||||
let payload = serde_json::json!({"args": {"foo": "bar"}});
|
||||
assert!(
|
||||
MiniCommand::from_payload(&payload).is_err(),
|
||||
"缺 cmd 字段应反序列化失败"
|
||||
);
|
||||
}
|
||||
|
||||
// ── args 字段提取 helper 测试 ──
|
||||
|
||||
/// args_get_string / args_get_bool 各类型分支。
|
||||
#[test]
|
||||
fn test_args_helpers() {
|
||||
let args = serde_json::json!({
|
||||
"str_field": "hello",
|
||||
"bool_field": true,
|
||||
"num_field": 42,
|
||||
"null_field": null,
|
||||
});
|
||||
// string 字段命中
|
||||
assert_eq!(
|
||||
args_get_string(&args, "str_field"),
|
||||
Some("hello".to_string())
|
||||
);
|
||||
// 缺失字段返 None
|
||||
assert_eq!(args_get_string(&args, "missing"), None);
|
||||
// 非 string 字段(number)返 None
|
||||
assert_eq!(args_get_string(&args, "num_field"), None);
|
||||
// null 字段返 None(as_str 对 null 返 None)
|
||||
assert_eq!(args_get_string(&args, "null_field"), None);
|
||||
// bool 字段命中
|
||||
assert_eq!(args_get_bool(&args, "bool_field"), Some(true));
|
||||
// 非 bool 字段返 None
|
||||
assert_eq!(args_get_bool(&args, "str_field"), None);
|
||||
// 缺失 bool 返 None
|
||||
assert_eq!(args_get_bool(&args, "missing"), None);
|
||||
}
|
||||
|
||||
// ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ──
|
||||
//
|
||||
// check_generating_reject 需 `State<AppState>`,单元测试难构造。
|
||||
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟 generating 状态,
|
||||
// 复核 conv_read + CONV_STATE_ENABLED 口径对齐 ai_is_generating。
|
||||
|
||||
/// R1 判定:无目标 conv 且无 active → 放行(返 None)。
|
||||
#[test]
|
||||
fn test_r1_no_target_pass() {
|
||||
let session = AiSession::new();
|
||||
// 无 active,无入参 conv_id → 无任何 conv 在跑
|
||||
assert!(
|
||||
session.active_conversation_id.is_none(),
|
||||
"新 session 无 active conv"
|
||||
);
|
||||
assert!(
|
||||
session.per_conv.is_empty(),
|
||||
"新 session per_conv 为空"
|
||||
);
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(generating=false)。
|
||||
#[test]
|
||||
fn test_r1_target_idle_pass() {
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-idle");
|
||||
conv.generating = false;
|
||||
// 读 generating 口径(对齐 check_generating_reject / ai_is_generating)
|
||||
let is_gen = session
|
||||
.conv_read("conv-idle")
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(!is_gen, "conv-idle 未在跑,is_gen 应为 false(放行)");
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。
|
||||
#[test]
|
||||
fn test_r1_target_generating_reject() {
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-busy");
|
||||
conv.generating = true;
|
||||
let is_gen = session
|
||||
.conv_read("conv-busy")
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(is_gen, "conv-busy generating=true 应拒绝");
|
||||
}
|
||||
|
||||
/// R1 判定:conv_state 派生态(Generating)is_active()=true(双轨 on 路径)。
|
||||
///
|
||||
/// 验证 CONV_STATE_ENABLED on 时读 conv_state.is_active() 而非 generating bool ——
|
||||
/// 即便 generating bool 误为 false 但 conv_state=Generating,仍应判定活跃拒绝。
|
||||
#[test]
|
||||
fn test_r1_conv_state_active_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
// CONV_STATE_ENABLED 是编译期 const(true),本测试假定 on 路径。
|
||||
// 若未来开关改 runtime,此测试需条件跳过 on 时才跑。
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-state-busy");
|
||||
// 模拟状态机写收敛路径:conv_state=Generating,generating bool 同步 true(双轨一致)
|
||||
conv.conv_state = ConvState::Generating;
|
||||
conv.generating = true;
|
||||
let is_gen = session
|
||||
.conv_read("conv-state-busy")
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(
|
||||
is_gen,
|
||||
"conv_state=Generating is_active() 应为 true(双轨 on 路径拒绝)"
|
||||
);
|
||||
}
|
||||
|
||||
/// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。
|
||||
#[test]
|
||||
fn test_r1_target_input_priority() {
|
||||
let mut session = AiSession::new();
|
||||
session.active_conversation_id = Some("conv-active".to_string());
|
||||
// active 在跑,但入参 conv-other 未跑 → 入参优先,应放行
|
||||
session.conv("conv-active").generating = true;
|
||||
let _ = session.conv("conv-other"); // 创建但不 generating
|
||||
let active_gen = session
|
||||
.conv_read("conv-active")
|
||||
.map(|c| c.generating)
|
||||
.unwrap_or(false);
|
||||
let other_gen = session
|
||||
.conv_read("conv-other")
|
||||
.map(|c| c.generating)
|
||||
.unwrap_or(false);
|
||||
assert!(active_gen, "conv-active 在跑");
|
||||
assert!(!other_gen, "conv-other 未跑");
|
||||
// 入参 conv-other 优先 → 判定 conv-other.generating=false → 放行
|
||||
// (与 check_generating_reject 入参优先逻辑一致)
|
||||
}
|
||||
|
||||
// ── PerConvState 初值对齐(确保 R1 测试基线 generating=false) ──
|
||||
|
||||
/// PerConvState::new() generating 初值 false(R1 放行基线)。
|
||||
#[test]
|
||||
fn test_per_conv_state_generating_default_false() {
|
||||
let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused)
|
||||
let s = PerConvState::new();
|
||||
assert!(!s.generating, "PerConvState::new generating 初值应为 false");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user