Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "nomi-protocol"
|
||||
description = "JSON stream protocol (events, commands, approval manager) for Nomi host integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
@@ -0,0 +1,248 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Commands sent from the client to the agent (Client -> Agent)
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProtocolCommand {
|
||||
Message {
|
||||
msg_id: String,
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
files: Vec<String>,
|
||||
},
|
||||
Stop,
|
||||
ToolApprove {
|
||||
call_id: String,
|
||||
#[serde(default)]
|
||||
scope: ApprovalScope,
|
||||
},
|
||||
ToolDeny {
|
||||
call_id: String,
|
||||
#[serde(default)]
|
||||
reason: String,
|
||||
},
|
||||
InitHistory {
|
||||
text: String,
|
||||
},
|
||||
SetMode {
|
||||
mode: SessionMode,
|
||||
},
|
||||
SetConfig {
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
thinking: Option<String>,
|
||||
#[serde(default)]
|
||||
thinking_budget: Option<u32>,
|
||||
#[serde(default)]
|
||||
effort: Option<String>,
|
||||
#[serde(default)]
|
||||
compaction: Option<String>,
|
||||
},
|
||||
AddMcpServer {
|
||||
name: String,
|
||||
transport: String,
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: Option<HashMap<String, String>>,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApprovalScope {
|
||||
#[default]
|
||||
Once,
|
||||
Always,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionMode {
|
||||
Default,
|
||||
AutoEdit,
|
||||
Yolo,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn set_config_debug_format() {
|
||||
let cmd = ProtocolCommand::SetConfig {
|
||||
model: Some("test-model".into()),
|
||||
thinking: None,
|
||||
thinking_budget: None,
|
||||
effort: None,
|
||||
compaction: None,
|
||||
};
|
||||
let dbg = format!("{cmd:?}");
|
||||
assert!(dbg.contains("SetConfig"));
|
||||
assert!(dbg.contains("test-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_equality() {
|
||||
let a = ProtocolCommand::SetConfig {
|
||||
model: Some("m".into()),
|
||||
thinking: None,
|
||||
thinking_budget: None,
|
||||
effort: None,
|
||||
compaction: None,
|
||||
};
|
||||
let b = ProtocolCommand::SetConfig {
|
||||
model: Some("m".into()),
|
||||
thinking: None,
|
||||
thinking_budget: None,
|
||||
effort: None,
|
||||
compaction: None,
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
|
||||
let c = ProtocolCommand::SetConfig {
|
||||
model: None,
|
||||
thinking: None,
|
||||
thinking_budget: None,
|
||||
effort: None,
|
||||
compaction: None,
|
||||
};
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_with_all_fields_equality() {
|
||||
let a = ProtocolCommand::SetConfig {
|
||||
model: Some("m".into()),
|
||||
thinking: Some("enabled".into()),
|
||||
thinking_budget: Some(8000),
|
||||
effort: Some("high".into()),
|
||||
compaction: None,
|
||||
};
|
||||
let b = ProtocolCommand::SetConfig {
|
||||
model: Some("m".into()),
|
||||
thinking: Some("enabled".into()),
|
||||
thinking_budget: Some(8000),
|
||||
effort: Some("high".into()),
|
||||
compaction: None,
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_all_none_fields() {
|
||||
let cmd = ProtocolCommand::SetConfig {
|
||||
model: None,
|
||||
thinking: None,
|
||||
thinking_budget: None,
|
||||
effort: None,
|
||||
compaction: None,
|
||||
};
|
||||
let dbg = format!("{cmd:?}");
|
||||
assert!(dbg.contains("SetConfig"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_with_compaction() {
|
||||
let json = r#"{"type":"set_config","compaction":"full"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { compaction, .. } => {
|
||||
assert_eq!(compaction.unwrap(), "full");
|
||||
}
|
||||
_ => panic!("expected SetConfig"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_compaction_none_by_default() {
|
||||
let json = r#"{"type":"set_config","model":"test"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { compaction, .. } => {
|
||||
assert!(compaction.is_none());
|
||||
}
|
||||
_ => panic!("expected SetConfig"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mcp_server_stdio_deserialize() {
|
||||
let json = r#"{
|
||||
"type": "add_mcp_server",
|
||||
"name": "team-tools",
|
||||
"transport": "stdio",
|
||||
"command": "node",
|
||||
"args": ["bridge.js", "--port", "9000"],
|
||||
"env": {"TOKEN": "abc123"}
|
||||
}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::AddMcpServer {
|
||||
name,
|
||||
transport,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
url,
|
||||
headers,
|
||||
} => {
|
||||
assert_eq!(name, "team-tools");
|
||||
assert_eq!(transport, "stdio");
|
||||
assert_eq!(command.unwrap(), "node");
|
||||
assert_eq!(args.unwrap(), vec!["bridge.js", "--port", "9000"]);
|
||||
assert_eq!(env.unwrap().get("TOKEN").unwrap(), "abc123");
|
||||
assert!(url.is_none());
|
||||
assert!(headers.is_none());
|
||||
}
|
||||
_ => panic!("expected AddMcpServer"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_deserialize() {
|
||||
let json = r#"{"type":"ping"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cmd, ProtocolCommand::Ping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mcp_server_sse_deserialize() {
|
||||
let json = r#"{
|
||||
"type": "add_mcp_server",
|
||||
"name": "remote-tools",
|
||||
"transport": "sse",
|
||||
"url": "http://localhost:8080/sse",
|
||||
"headers": {"Authorization": "Bearer tok"}
|
||||
}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::AddMcpServer {
|
||||
name,
|
||||
transport,
|
||||
command,
|
||||
url,
|
||||
headers,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "remote-tools");
|
||||
assert_eq!(transport, "sse");
|
||||
assert!(command.is_none());
|
||||
assert_eq!(url.unwrap(), "http://localhost:8080/sse");
|
||||
assert_eq!(headers.unwrap().get("Authorization").unwrap(), "Bearer tok");
|
||||
}
|
||||
_ => panic!("expected AddMcpServer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Events emitted by the agent to the client (Agent -> Client)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProtocolEvent {
|
||||
Ready {
|
||||
version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
capabilities: Capabilities,
|
||||
},
|
||||
StreamStart {
|
||||
msg_id: String,
|
||||
},
|
||||
TextDelta {
|
||||
text: String,
|
||||
msg_id: String,
|
||||
},
|
||||
Thinking {
|
||||
text: String,
|
||||
msg_id: String,
|
||||
},
|
||||
ToolRequest {
|
||||
msg_id: String,
|
||||
call_id: String,
|
||||
tool: ToolInfo,
|
||||
},
|
||||
ToolRunning {
|
||||
msg_id: String,
|
||||
call_id: String,
|
||||
tool_name: String,
|
||||
},
|
||||
ToolResult {
|
||||
msg_id: String,
|
||||
call_id: String,
|
||||
tool_name: String,
|
||||
status: ToolStatus,
|
||||
output: String,
|
||||
output_type: OutputType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
metadata: Option<Value>,
|
||||
},
|
||||
ToolCancelled {
|
||||
msg_id: String,
|
||||
call_id: String,
|
||||
reason: String,
|
||||
},
|
||||
StreamEnd {
|
||||
msg_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<Usage>,
|
||||
},
|
||||
Error {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
msg_id: Option<String>,
|
||||
error: ErrorInfo,
|
||||
},
|
||||
Info {
|
||||
msg_id: String,
|
||||
message: String,
|
||||
},
|
||||
ConfigChanged {
|
||||
capabilities: Capabilities,
|
||||
},
|
||||
McpReady {
|
||||
name: String,
|
||||
tools: Vec<String>,
|
||||
},
|
||||
Pong,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Capabilities {
|
||||
pub tool_approval: bool,
|
||||
pub thinking: bool,
|
||||
pub effort: bool,
|
||||
pub effort_levels: Vec<String>,
|
||||
pub modes: Vec<String>,
|
||||
pub current_mode: String,
|
||||
pub mcp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ToolInfo {
|
||||
pub name: String,
|
||||
pub category: ToolCategory,
|
||||
pub args: Value,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCategory {
|
||||
Info,
|
||||
Edit,
|
||||
Exec,
|
||||
Mcp,
|
||||
/// Irreversible action (submit / payment / delete / send). Highest approval
|
||||
/// severity: never silently auto-approved by AutoEdit mode; used by the
|
||||
/// browser-use action facade for fail-closed approval gating.
|
||||
Irreversible,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Info => write!(f, "info"),
|
||||
Self::Edit => write!(f, "edit"),
|
||||
Self::Exec => write!(f, "exec"),
|
||||
Self::Mcp => write!(f, "mcp"),
|
||||
Self::Irreversible => write!(f, "irreversible"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolStatus {
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputType {
|
||||
Text,
|
||||
Diff,
|
||||
Image,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_tokens: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorInfo {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_ready_event_serialization() {
|
||||
let event = ProtocolEvent::Ready {
|
||||
version: "0.1.0".to_string(),
|
||||
session_id: Some("abc123".to_string()),
|
||||
capabilities: Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: true,
|
||||
effort: false,
|
||||
effort_levels: vec![],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: false,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "ready");
|
||||
assert_eq!(json["version"], "0.1.0");
|
||||
assert_eq!(json["session_id"], "abc123");
|
||||
assert_eq!(json["capabilities"]["tool_approval"], true);
|
||||
|
||||
// session_id omitted when None
|
||||
let event_no_sid = ProtocolEvent::Ready {
|
||||
version: "0.1.0".to_string(),
|
||||
session_id: None,
|
||||
capabilities: Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: true,
|
||||
effort: false,
|
||||
effort_levels: vec![],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: false,
|
||||
},
|
||||
};
|
||||
let json2 = serde_json::to_value(&event_no_sid).unwrap();
|
||||
assert!(json2.get("session_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_delta_event_serialization() {
|
||||
let event = ProtocolEvent::TextDelta {
|
||||
text: "hello".to_string(),
|
||||
msg_id: "m1".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "text_delta");
|
||||
assert_eq!(json["text"], "hello");
|
||||
assert_eq!(json["msg_id"], "m1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_request_event_serialization() {
|
||||
let event = ProtocolEvent::ToolRequest {
|
||||
msg_id: "m1".to_string(),
|
||||
call_id: "c1".to_string(),
|
||||
tool: ToolInfo {
|
||||
name: "Bash".to_string(),
|
||||
category: ToolCategory::Exec,
|
||||
args: json!({"command": "ls"}),
|
||||
description: "Execute: ls".to_string(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "tool_request");
|
||||
assert_eq!(json["tool"]["category"], "exec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_event_serialization() {
|
||||
let event = ProtocolEvent::ToolResult {
|
||||
msg_id: "m1".to_string(),
|
||||
call_id: "c1".to_string(),
|
||||
tool_name: "Read".to_string(),
|
||||
status: ToolStatus::Success,
|
||||
output: "file content".to_string(),
|
||||
output_type: OutputType::Text,
|
||||
metadata: None,
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "tool_result");
|
||||
assert_eq!(json["status"], "success");
|
||||
assert!(json.get("metadata").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_event_serialization() {
|
||||
let event = ProtocolEvent::Error {
|
||||
msg_id: None,
|
||||
error: ErrorInfo {
|
||||
code: "rate_limit".to_string(),
|
||||
message: "Too many requests".to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "error");
|
||||
assert!(json.get("msg_id").is_none());
|
||||
assert_eq!(json["error"]["retryable"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_end_with_usage() {
|
||||
let event = ProtocolEvent::StreamEnd {
|
||||
msg_id: "m1".to_string(),
|
||||
usage: Some(Usage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_tokens: Some(20),
|
||||
cache_write_tokens: None,
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "stream_end");
|
||||
assert_eq!(json["usage"]["input_tokens"], 100);
|
||||
assert!(json["usage"].get("cache_write_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_category_display() {
|
||||
assert_eq!(ToolCategory::Info.to_string(), "info");
|
||||
assert_eq!(ToolCategory::Edit.to_string(), "edit");
|
||||
assert_eq!(ToolCategory::Exec.to_string(), "exec");
|
||||
assert_eq!(ToolCategory::Mcp.to_string(), "mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_irreversible_serde_roundtrip() {
|
||||
// The enum only derives Serialize (matching the existing contract), so
|
||||
// we assert the serialized form rather than a deserialize round-trip.
|
||||
let c = ToolCategory::Irreversible;
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
assert_eq!(s, "\"irreversible\""); // serde snake_case, same convention as Info/Edit/Exec/Mcp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_irreversible_display() {
|
||||
assert_eq!(ToolCategory::Irreversible.to_string(), "irreversible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ready_event_with_expanded_capabilities() {
|
||||
let event = ProtocolEvent::Ready {
|
||||
version: "0.2.0".to_string(),
|
||||
session_id: Some("abc".to_string()),
|
||||
capabilities: Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: true,
|
||||
effort: true,
|
||||
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: false,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["capabilities"]["thinking"], true);
|
||||
assert_eq!(json["capabilities"]["effort"], true);
|
||||
assert_eq!(json["capabilities"]["effort_levels"][0], "low");
|
||||
assert_eq!(json["capabilities"]["modes"][2], "yolo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_ready_event_serialization() {
|
||||
let event = ProtocolEvent::McpReady {
|
||||
name: "team-tools".to_string(),
|
||||
tools: vec!["team_send_message".into(), "team_task_create".into()],
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "mcp_ready");
|
||||
assert_eq!(json["name"], "team-tools");
|
||||
assert_eq!(json["tools"][0], "team_send_message");
|
||||
assert_eq!(json["tools"][1], "team_task_create");
|
||||
assert_eq!(json["tools"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pong_event_serialization() {
|
||||
let event = ProtocolEvent::Pong;
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "pong");
|
||||
assert_eq!(json.as_object().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_changed_event_serialization() {
|
||||
let event = ProtocolEvent::ConfigChanged {
|
||||
capabilities: Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: false,
|
||||
effort: true,
|
||||
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: true,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(json["type"], "config_changed");
|
||||
assert_eq!(json["capabilities"]["thinking"], false);
|
||||
assert_eq!(json["capabilities"]["effort"], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// JSON stream protocol for host ↔ agent communication.
|
||||
// Contains: events (agent→host), commands (host→agent), approval manager.
|
||||
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod reader;
|
||||
pub mod writer;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::commands::{ApprovalScope, SessionMode};
|
||||
use crate::events::ToolCategory;
|
||||
|
||||
/// Result of a tool approval request
|
||||
pub enum ToolApprovalResult {
|
||||
Approved,
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
struct PendingApproval {
|
||||
tx: oneshot::Sender<ToolApprovalResult>,
|
||||
category: String,
|
||||
}
|
||||
|
||||
/// Manages pending tool approval requests using oneshot channels.
|
||||
///
|
||||
/// Each pending request also stores its tool category so a client approval with
|
||||
/// `ApprovalScope::Always` can persist auto-approval for future requests in the
|
||||
/// same category.
|
||||
///
|
||||
/// Also holds the current `SessionMode` which determines which tool categories
|
||||
/// are auto-approved based on the active approval policy.
|
||||
pub struct ToolApprovalManager {
|
||||
pending: Mutex<HashMap<String, PendingApproval>>,
|
||||
auto_approved: Mutex<HashSet<String>>,
|
||||
session_mode: Mutex<SessionMode>,
|
||||
}
|
||||
|
||||
impl ToolApprovalManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
auto_approved: Mutex::new(HashSet::new()),
|
||||
session_mode: Mutex::new(SessionMode::Default),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_approval(
|
||||
&self,
|
||||
call_id: &str,
|
||||
category: &ToolCategory,
|
||||
) -> oneshot::Receiver<ToolApprovalResult> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if let Ok(mut pending) = self.pending.lock() {
|
||||
pending.insert(
|
||||
call_id.to_string(),
|
||||
PendingApproval {
|
||||
tx,
|
||||
category: category.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn approve(&self, call_id: &str, scope: ApprovalScope) {
|
||||
let pending = self
|
||||
.pending
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut pending| pending.remove(call_id));
|
||||
|
||||
if let Some(pending) = pending {
|
||||
if matches!(scope, ApprovalScope::Always) {
|
||||
self.add_auto_approve(&pending.category);
|
||||
}
|
||||
let _ = pending.tx.send(ToolApprovalResult::Approved);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self, call_id: &str, result: ToolApprovalResult) {
|
||||
if let Some(pending) = self
|
||||
.pending
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut pending| pending.remove(call_id))
|
||||
{
|
||||
let _ = pending.tx.send(result);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_auto_approved(&self, category: &str) -> bool {
|
||||
// Check session mode first
|
||||
let mode_approved = self
|
||||
.session_mode
|
||||
.lock()
|
||||
.map(|mode| match *mode {
|
||||
SessionMode::Yolo => true,
|
||||
SessionMode::AutoEdit => category == "info" || category == "edit",
|
||||
SessionMode::Default => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if mode_approved {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to per-category "always" approvals
|
||||
self.auto_approved
|
||||
.lock()
|
||||
.map(|auto| auto.contains(category))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set the session approval mode. Takes effect immediately.
|
||||
pub fn set_mode(&self, mode: SessionMode) {
|
||||
if let Ok(mut current) = self.session_mode.lock() {
|
||||
*current = mode;
|
||||
}
|
||||
}
|
||||
|
||||
/// **P3-X1: does the *current* session mode bypass orchestration approval entirely?**
|
||||
///
|
||||
/// This is the LIVE, runtime-flippable analogue of `config.tools.auto_approve`: it reads
|
||||
/// the current `session_mode` (mutated by [`Self::set_mode`], which takes effect
|
||||
/// immediately) and answers whether *every* tool category — including
|
||||
/// [`ToolCategory::Irreversible`] — is auto-approved.
|
||||
///
|
||||
/// **Mapping (the F1-sec redline direction, authoritative here so it lives in one place):**
|
||||
/// - [`SessionMode::Yolo`] → `true` (bypasses approval for all categories, including
|
||||
/// irreversible — this is exactly when the browser facade's independent fail-closed
|
||||
/// redline gate must arm);
|
||||
/// - [`SessionMode::AutoEdit`] → **`false`** — auto-edit auto-approves only `info`/`edit`,
|
||||
/// **never** `exec`/`mcp`/irreversible, so the orchestration approval gate still fires for
|
||||
/// an irreversible web action; it does NOT bypass approval and must NOT arm the redline gate;
|
||||
/// - [`SessionMode::Default`] → `false`.
|
||||
///
|
||||
/// This mirrors the `Yolo => true` arm of [`Self::is_auto_approved`] (which is `true` for
|
||||
/// every category iff yolo), keeping "bypasses approval" === "yolo" in a single definition.
|
||||
/// Per-category user "always" approvals are intentionally NOT consulted: a manual
|
||||
/// `add_auto_approve("exec")` is a scoped grant, not a wholesale approval bypass, so it must
|
||||
/// not arm the facade redline gate against irreversible actions.
|
||||
pub fn session_bypasses_approval(&self) -> bool {
|
||||
self.session_mode
|
||||
.lock()
|
||||
.map(|mode| matches!(*mode, SessionMode::Yolo))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return the current session mode as a string for capability reporting.
|
||||
pub fn current_mode(&self) -> String {
|
||||
self.session_mode
|
||||
.lock()
|
||||
.map(|mode| match *mode {
|
||||
SessionMode::Default => "default",
|
||||
SessionMode::AutoEdit => "auto_edit",
|
||||
SessionMode::Yolo => "yolo",
|
||||
})
|
||||
.unwrap_or("default")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn drop_pending(&self, call_id: &str) {
|
||||
if let Ok(mut pending) = self.pending.lock() {
|
||||
pending.remove(call_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_auto_approve(&self, category: &str) {
|
||||
if let Ok(mut auto) = self.auto_approved.lock() {
|
||||
auto.insert(category.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolApprovalManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- SessionMode: default mode ---
|
||||
|
||||
#[test]
|
||||
fn default_mode_does_not_auto_approve_any_category() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
assert!(!mgr.is_auto_approved("info"));
|
||||
assert!(!mgr.is_auto_approved("edit"));
|
||||
assert!(!mgr.is_auto_approved("exec"));
|
||||
assert!(!mgr.is_auto_approved("mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mode_current_mode_string() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
assert_eq!(mgr.current_mode(), "default");
|
||||
}
|
||||
|
||||
// --- SessionMode: auto_edit mode ---
|
||||
|
||||
#[test]
|
||||
fn auto_edit_mode_approves_info_and_edit() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
mgr.set_mode(SessionMode::AutoEdit);
|
||||
assert!(mgr.is_auto_approved("info"));
|
||||
assert!(mgr.is_auto_approved("edit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_edit_mode_requires_approval_for_exec_and_mcp() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
mgr.set_mode(SessionMode::AutoEdit);
|
||||
assert!(!mgr.is_auto_approved("exec"));
|
||||
assert!(!mgr.is_auto_approved("mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_edit_mode_current_mode_string() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
mgr.set_mode(SessionMode::AutoEdit);
|
||||
assert_eq!(mgr.current_mode(), "auto_edit");
|
||||
}
|
||||
|
||||
// --- SessionMode: yolo mode ---
|
||||
|
||||
#[test]
|
||||
fn yolo_mode_approves_all_categories() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
mgr.set_mode(SessionMode::Yolo);
|
||||
assert!(mgr.is_auto_approved("info"));
|
||||
assert!(mgr.is_auto_approved("edit"));
|
||||
assert!(mgr.is_auto_approved("exec"));
|
||||
assert!(mgr.is_auto_approved("mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yolo_mode_current_mode_string() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
mgr.set_mode(SessionMode::Yolo);
|
||||
assert_eq!(mgr.current_mode(), "yolo");
|
||||
}
|
||||
|
||||
// --- Mode switching ---
|
||||
|
||||
#[test]
|
||||
fn switching_mode_changes_approval_behavior() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
|
||||
// Start in default
|
||||
assert!(!mgr.is_auto_approved("edit"));
|
||||
|
||||
// Switch to auto_edit
|
||||
mgr.set_mode(SessionMode::AutoEdit);
|
||||
assert!(mgr.is_auto_approved("edit"));
|
||||
assert!(!mgr.is_auto_approved("exec"));
|
||||
|
||||
// Switch to yolo
|
||||
mgr.set_mode(SessionMode::Yolo);
|
||||
assert!(mgr.is_auto_approved("exec"));
|
||||
|
||||
// Switch back to default
|
||||
mgr.set_mode(SessionMode::Default);
|
||||
assert!(!mgr.is_auto_approved("edit"));
|
||||
assert!(!mgr.is_auto_approved("exec"));
|
||||
}
|
||||
|
||||
// --- Mode + user "always" approval coexistence ---
|
||||
|
||||
#[test]
|
||||
fn user_always_approval_persists_across_mode_changes() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
|
||||
// User manually approves "exec" category with "always"
|
||||
mgr.add_auto_approve("exec");
|
||||
assert!(mgr.is_auto_approved("exec"));
|
||||
|
||||
// Switch to auto_edit: exec still approved via user "always"
|
||||
mgr.set_mode(SessionMode::AutoEdit);
|
||||
assert!(mgr.is_auto_approved("exec"));
|
||||
assert!(mgr.is_auto_approved("info")); // from mode
|
||||
|
||||
// Switch back to default: exec still approved via user "always"
|
||||
mgr.set_mode(SessionMode::Default);
|
||||
assert!(mgr.is_auto_approved("exec"));
|
||||
assert!(!mgr.is_auto_approved("info")); // mode no longer provides this
|
||||
}
|
||||
|
||||
// --- P3-X1: session_bypasses_approval (the LIVE redline-arming query) ---
|
||||
|
||||
#[test]
|
||||
fn session_bypasses_approval_only_yolo_bypasses() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
// Default: never bypasses.
|
||||
assert!(!mgr.session_bypasses_approval());
|
||||
|
||||
// AutoEdit auto-approves info/edit only — it does NOT bypass approval (irreversible
|
||||
// still gated). The facade redline gate must NOT arm here.
|
||||
mgr.set_mode(SessionMode::AutoEdit);
|
||||
assert!(!mgr.session_bypasses_approval());
|
||||
|
||||
// Yolo bypasses approval for every category, including irreversible → arms the gate.
|
||||
mgr.set_mode(SessionMode::Yolo);
|
||||
assert!(mgr.session_bypasses_approval());
|
||||
|
||||
// Flipping back to default un-arms it (LIVE — set_mode takes effect immediately).
|
||||
mgr.set_mode(SessionMode::Default);
|
||||
assert!(!mgr.session_bypasses_approval());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_bypasses_approval_ignores_per_category_always_grants() {
|
||||
let mgr = ToolApprovalManager::new();
|
||||
// A scoped "always exec" grant is NOT a wholesale approval bypass — it must not arm the
|
||||
// facade redline gate against irreversible actions (only yolo does).
|
||||
mgr.add_auto_approve("exec");
|
||||
assert!(mgr.is_auto_approved("exec"));
|
||||
assert!(!mgr.session_bypasses_approval());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::commands::ProtocolCommand;
|
||||
|
||||
/// Reads JSON Lines from stdin in a background task.
|
||||
/// Returns a channel receiver for parsed commands.
|
||||
pub fn spawn_stdin_reader() -> mpsc::UnboundedReceiver<ProtocolCommand> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break, // EOF - client closed stdin
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<ProtocolCommand>(trimmed) {
|
||||
Ok(cmd) => {
|
||||
if tx.send(cmd).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(target: "nomi_protocol", error = %e, "invalid protocol command");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(target: "nomi_protocol", error = %e, "stdin read error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::io::{self, BufWriter, Stdout, Write};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::events::ProtocolEvent;
|
||||
|
||||
/// Trait for emitting protocol events to a host.
|
||||
///
|
||||
/// The default implementation (`ProtocolWriter`) writes JSON Lines to stdout.
|
||||
/// Backend integrations provide alternative implementations that bridge events
|
||||
/// to their own event systems.
|
||||
pub trait ProtocolEmitter: Send + Sync {
|
||||
fn emit(&self, event: &ProtocolEvent) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// Thread-safe JSON Lines writer to stdout
|
||||
pub struct ProtocolWriter {
|
||||
writer: Mutex<BufWriter<Stdout>>,
|
||||
}
|
||||
|
||||
impl Default for ProtocolWriter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolWriter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
writer: Mutex::new(BufWriter::new(io::stdout())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolEmitter for ProtocolWriter {
|
||||
fn emit(&self, event: &ProtocolEvent) -> io::Result<()> {
|
||||
let mut w = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("protocol writer lock poisoned"))?;
|
||||
serde_json::to_writer(&mut *w, event)
|
||||
.map_err(|e| io::Error::other(format!("failed to serialize protocol event: {}", e)))?;
|
||||
writeln!(&mut *w)?;
|
||||
w.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::{Capabilities, ProtocolEvent};
|
||||
|
||||
#[test]
|
||||
fn test_writer_construction() {
|
||||
let _writer = ProtocolWriter::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_writer_emit_does_not_panic() {
|
||||
let writer = ProtocolWriter::new();
|
||||
let event = ProtocolEvent::Ready {
|
||||
version: "0.1.0".to_string(),
|
||||
session_id: None,
|
||||
capabilities: Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: false,
|
||||
effort: false,
|
||||
effort_levels: vec![],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: false,
|
||||
},
|
||||
};
|
||||
let _ = writer.emit(&event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use nomi_protocol::commands::ApprovalScope;
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_protocol::{ToolApprovalManager, ToolApprovalResult};
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case(ApprovalScope::Once, ToolCategory::Exec, "exec", false)]
|
||||
#[case(ApprovalScope::Always, ToolCategory::Edit, "edit", true)]
|
||||
#[tokio::test]
|
||||
async fn approve_resolves_request_and_updates_auto_approval(
|
||||
#[case] scope: ApprovalScope,
|
||||
#[case] category: ToolCategory,
|
||||
#[case] category_name: &str,
|
||||
#[case] should_auto_approve: bool,
|
||||
) {
|
||||
let manager = ToolApprovalManager::new();
|
||||
let rx = manager.request_approval("call-1", &category);
|
||||
|
||||
manager.approve("call-1", scope);
|
||||
|
||||
let result = rx.await.expect("approval result should arrive");
|
||||
assert!(matches!(result, ToolApprovalResult::Approved));
|
||||
assert_eq!(manager.is_auto_approved(category_name), should_auto_approve);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_preserves_denial_reason() {
|
||||
let manager = ToolApprovalManager::new();
|
||||
let rx = manager.request_approval("call-2", &ToolCategory::Exec);
|
||||
|
||||
manager.resolve(
|
||||
"call-2",
|
||||
ToolApprovalResult::Denied {
|
||||
reason: "policy violation".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let result = rx.await.expect("denial result should arrive");
|
||||
assert!(matches!(
|
||||
result,
|
||||
ToolApprovalResult::Denied { reason } if reason == "policy violation"
|
||||
));
|
||||
assert!(!manager.is_auto_approved("exec"));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use nomi_protocol::events::{Capabilities, ProtocolEvent};
|
||||
|
||||
#[test]
|
||||
fn capabilities_serialize_with_all_fields() {
|
||||
let caps = Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: true,
|
||||
effort: false,
|
||||
effort_levels: vec![],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: true,
|
||||
};
|
||||
let event = ProtocolEvent::Ready {
|
||||
version: "0.2.0".into(),
|
||||
session_id: None,
|
||||
capabilities: caps,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["type"], "ready");
|
||||
assert_eq!(parsed["capabilities"]["thinking"], true);
|
||||
assert_eq!(parsed["capabilities"]["effort"], false);
|
||||
assert!(
|
||||
parsed["capabilities"]["effort_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(parsed["capabilities"]["modes"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(parsed["capabilities"]["current_mode"], "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_changed_event_serializes_correctly() {
|
||||
let caps = Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: false,
|
||||
effort: true,
|
||||
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: false,
|
||||
};
|
||||
let event = ProtocolEvent::ConfigChanged { capabilities: caps };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["type"], "config_changed");
|
||||
assert_eq!(parsed["capabilities"]["effort_levels"][1], "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_with_effort_levels_roundtrip() {
|
||||
let caps = Capabilities {
|
||||
tool_approval: true,
|
||||
thinking: false,
|
||||
effort: true,
|
||||
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
|
||||
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
|
||||
current_mode: "default".into(),
|
||||
mcp: true,
|
||||
};
|
||||
let event = ProtocolEvent::Ready {
|
||||
version: "0.2.0".into(),
|
||||
session_id: Some("test-session".into()),
|
||||
capabilities: caps,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["capabilities"]["effort"], true);
|
||||
assert_eq!(
|
||||
parsed["capabilities"]["effort_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(parsed["capabilities"]["effort_levels"][0], "low");
|
||||
assert_eq!(parsed["capabilities"]["effort_levels"][2], "high");
|
||||
assert_eq!(parsed["session_id"], "test-session");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use nomi_protocol::commands::{ApprovalScope, ProtocolCommand, SessionMode};
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
r#"{"type":"message","msg_id":"m1","content":"Hello"}"#,
|
||||
ProtocolCommand::Message {
|
||||
msg_id: "m1".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
files: vec![],
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"type":"message","msg_id":"m2","content":"Read this","files":["/tmp/a.rs"]}"#,
|
||||
ProtocolCommand::Message {
|
||||
msg_id: "m2".to_string(),
|
||||
content: "Read this".to_string(),
|
||||
files: vec!["/tmp/a.rs".to_string()],
|
||||
}
|
||||
)]
|
||||
#[case(r#"{"type":"stop"}"#, ProtocolCommand::Stop)]
|
||||
#[case(
|
||||
r#"{"type":"init_history","text":"history"}"#,
|
||||
ProtocolCommand::InitHistory {
|
||||
text: "history".to_string(),
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"type":"set_mode","mode":"default"}"#,
|
||||
ProtocolCommand::SetMode {
|
||||
mode: SessionMode::Default,
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"type":"set_mode","mode":"auto_edit"}"#,
|
||||
ProtocolCommand::SetMode {
|
||||
mode: SessionMode::AutoEdit,
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"type":"set_mode","mode":"yolo"}"#,
|
||||
ProtocolCommand::SetMode {
|
||||
mode: SessionMode::Yolo,
|
||||
}
|
||||
)]
|
||||
fn deserializes_protocol_commands(#[case] json: &str, #[case] expected: ProtocolCommand) {
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).expect("command should deserialize");
|
||||
assert_eq!(cmd, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(r#"{"type":"tool_approve","call_id":"c1"}"#, ApprovalScope::Once)]
|
||||
#[case(
|
||||
r#"{"type":"tool_approve","call_id":"c1","scope":"always"}"#,
|
||||
ApprovalScope::Always
|
||||
)]
|
||||
fn deserializes_tool_approve_scope(#[case] json: &str, #[case] expected_scope: ApprovalScope) {
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).expect("tool approve should deserialize");
|
||||
|
||||
match cmd {
|
||||
ProtocolCommand::ToolApprove { call_id, scope } => {
|
||||
assert_eq!(call_id, "c1");
|
||||
assert_eq!(scope, expected_scope);
|
||||
}
|
||||
other => panic!("expected ToolApprove, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(r#"{"type":"tool_deny","call_id":"c1"}"#, "")]
|
||||
#[case(
|
||||
r#"{"type":"tool_deny","call_id":"c1","reason":"not allowed"}"#,
|
||||
"not allowed"
|
||||
)]
|
||||
fn deserializes_tool_deny_reason(#[case] json: &str, #[case] expected_reason: &str) {
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).expect("tool deny should deserialize");
|
||||
|
||||
match cmd {
|
||||
ProtocolCommand::ToolDeny { call_id, reason } => {
|
||||
assert_eq!(call_id, "c1");
|
||||
assert_eq!(reason, expected_reason);
|
||||
}
|
||||
other => panic!("expected ToolDeny, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use nomi_protocol::commands::ProtocolCommand;
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_with_model() {
|
||||
let json = r#"{"type":"set_config","model":"claude-sonnet-4-5-20250514"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { model, .. } => {
|
||||
assert_eq!(model.as_deref(), Some("claude-sonnet-4-5-20250514"));
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_empty() {
|
||||
let json = r#"{"type":"set_config"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { model, .. } => {
|
||||
assert!(model.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_null_model() {
|
||||
let json = r#"{"type":"set_config","model":null}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { model, .. } => {
|
||||
assert!(model.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_unknown_fields_ignored() {
|
||||
let json = r#"{"type":"set_config","model":"x","future_field":true,"nested":{"a":1}}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { model, .. } => {
|
||||
assert_eq!(model.as_deref(), Some("x"));
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_commands_still_parse() {
|
||||
// AC-7: Verify SetConfig addition doesn't break existing variants
|
||||
let message = r#"{"type":"message","msg_id":"m1","content":"hello"}"#;
|
||||
assert!(serde_json::from_str::<ProtocolCommand>(message).is_ok());
|
||||
|
||||
let stop = r#"{"type":"stop"}"#;
|
||||
assert!(serde_json::from_str::<ProtocolCommand>(stop).is_ok());
|
||||
|
||||
let approve = r#"{"type":"tool_approve","call_id":"c1"}"#;
|
||||
assert!(serde_json::from_str::<ProtocolCommand>(approve).is_ok());
|
||||
|
||||
let deny = r#"{"type":"tool_deny","call_id":"c1"}"#;
|
||||
assert!(serde_json::from_str::<ProtocolCommand>(deny).is_ok());
|
||||
|
||||
let init = r#"{"type":"init_history","text":"ctx"}"#;
|
||||
assert!(serde_json::from_str::<ProtocolCommand>(init).is_ok());
|
||||
|
||||
let mode = r#"{"type":"set_mode","mode":"yolo"}"#;
|
||||
assert!(serde_json::from_str::<ProtocolCommand>(mode).is_ok());
|
||||
}
|
||||
|
||||
// --- Cycle 2: Effort parsing tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_with_effort() {
|
||||
let json = r#"{"type":"set_config","effort":"high"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { effort, model, .. } => {
|
||||
assert_eq!(effort.as_deref(), Some("high"));
|
||||
assert!(model.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_with_null_effort() {
|
||||
let json = r#"{"type":"set_config","effort":null}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { effort, .. } => {
|
||||
assert!(effort.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cycle 2: Thinking parsing tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_with_thinking_enabled_and_budget() {
|
||||
let json = r#"{"type":"set_config","thinking":"enabled","thinking_budget":16000}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig {
|
||||
thinking,
|
||||
thinking_budget,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking.as_deref(), Some("enabled"));
|
||||
assert_eq!(thinking_budget, Some(16000));
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_with_thinking_disabled() {
|
||||
let json = r#"{"type":"set_config","thinking":"disabled"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig {
|
||||
thinking,
|
||||
thinking_budget,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking.as_deref(), Some("disabled"));
|
||||
assert!(thinking_budget.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_with_null_thinking() {
|
||||
let json = r#"{"type":"set_config","thinking":null}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig { thinking, .. } => {
|
||||
assert!(thinking.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_thinking_enabled_no_budget() {
|
||||
let json = r#"{"type":"set_config","thinking":"enabled"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig {
|
||||
thinking,
|
||||
thinking_budget,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking.as_deref(), Some("enabled"));
|
||||
assert!(thinking_budget.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cycle 2: Combined fields test ---
|
||||
|
||||
#[test]
|
||||
fn parse_set_config_all_fields() {
|
||||
let json = r#"{"type":"set_config","model":"m","effort":"low","thinking":"disabled"}"#;
|
||||
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
|
||||
match cmd {
|
||||
ProtocolCommand::SetConfig {
|
||||
model,
|
||||
effort,
|
||||
thinking,
|
||||
thinking_budget,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(model.as_deref(), Some("m"));
|
||||
assert_eq!(effort.as_deref(), Some("low"));
|
||||
assert_eq!(thinking.as_deref(), Some("disabled"));
|
||||
assert!(thinking_budget.is_none());
|
||||
}
|
||||
other => panic!("expected SetConfig, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user