Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/custom_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,31 @@ impl CustomTool {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_timeout_is_two_minutes_in_ms() {
assert_eq!(default_timeout(), 120 * 1000);
}

#[test]
fn get_default_scopes_returns_non_empty() {
let scopes = get_default_scopes();
assert!(!scopes.is_empty(), "default OAuth scopes must not be empty");
}

#[test]
fn get_default_scopes_are_strings() {
for scope in get_default_scopes() {
assert!(!scope.is_empty(), "each scope must be a non-empty string");
}
}

#[test]
fn transport_type_default_is_stdio() {
assert!(matches!(TransportType::default(), TransportType::Stdio));
}
}
67 changes: 67 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,4 +599,71 @@ mod tests {
let schema = schemars::schema_for!(Delegate);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}

// ── truncate_description ─────────────────────────────────────────────────

#[test]
fn truncate_at_first_period() {
assert_eq!(truncate_description("Short desc. More text."), "Short desc.");
}

#[test]
fn truncate_long_string_without_period() {
let long = "a".repeat(80);
let result = truncate_description(&long);
assert_eq!(result.len(), 57);
}

#[test]
fn truncate_short_string_without_period_unchanged() {
assert_eq!(truncate_description("short"), "short");
}

// ── format_launch_success ────────────────────────────────────────────────

#[test]
fn format_launch_success_contains_agent_and_task() {
let msg = format_launch_success("my-agent", "do the thing");
assert!(msg.contains("my-agent"));
assert!(msg.contains("do the thing"));
}

// ── AgentStatus ──────────────────────────────────────────────────────────

#[test]
fn agent_status_default_is_running() {
assert!(matches!(AgentStatus::default(), AgentStatus::Running));
}

// ── Delegate deserialization ─────────────────────────────────────────────

#[test]
fn deserialize_launch() {
let v = serde_json::json!({
"operation": "launch",
"task": "write a hello world program"
});
let d = serde_json::from_value::<Delegate>(v).unwrap();
assert!(matches!(d.operation, Operation::Launch));
assert_eq!(d.task.as_deref(), Some("write a hello world program"));
}

#[test]
fn deserialize_status_all() {
let v = serde_json::json!({ "operation": "status" });
let d = serde_json::from_value::<Delegate>(v).unwrap();
assert!(matches!(d.operation, Operation::Status));
assert!(d.agent.is_none());
}

#[test]
fn resolve_agent_name_fallback_chain() {
// explicit > configured_default > DEFAULT_AGENT_NAME
fn resolve<'a>(explicit: Option<&'a str>, configured: Option<&'a str>) -> &'a str {
explicit.or(configured).unwrap_or(DEFAULT_AGENT_NAME)
}
assert_eq!(resolve(Some("explicit"), Some("configured")), "explicit");
assert_eq!(resolve(None, Some("configured")), "configured");
assert_eq!(resolve(None, None), DEFAULT_AGENT_NAME);
}
}
26 changes: 26 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/introspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,29 @@ impl Introspect {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deserialize_with_query() {
let v = serde_json::json!({ "query": "how do I use /compact?" });
let i = serde_json::from_value::<Introspect>(v).unwrap();
assert_eq!(i.query, Some("how do I use /compact?".to_string()));
}

#[test]
fn deserialize_without_query() {
let v = serde_json::json!({});
let i = serde_json::from_value::<Introspect>(v).unwrap();
assert_eq!(i.query, None);
}

#[tokio::test]
async fn validate_always_succeeds() {
let i = Introspect { query: None };
let os = crate::os::Os::new().await.unwrap();
assert!(i.validate(&os).await.is_ok());
}
}
43 changes: 43 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/knowledge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,46 @@ impl Knowledge {
output.trim_end().to_string() // Remove trailing newline
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deserialize_add() {
let v = serde_json::json!({
"command": "add",
"name": "my-kb",
"value": "/some/path"
});
let k = serde_json::from_value::<Knowledge>(v).unwrap();
assert!(matches!(k, Knowledge::Add(_)));
}

#[test]
fn deserialize_search() {
let v = serde_json::json!({
"command": "search",
"query": "find something"
});
let k = serde_json::from_value::<Knowledge>(v).unwrap();
assert!(matches!(k, Knowledge::Search(_)));
}

#[test]
fn deserialize_remove() {
let v = serde_json::json!({
"command": "remove",
"identifier": "my-kb"
});
let k = serde_json::from_value::<Knowledge>(v).unwrap();
assert!(matches!(k, Knowledge::Remove(_)));
}

#[test]
fn deserialize_clear() {
let v = serde_json::json!({ "command": "clear", "confirm": true });
let k = serde_json::from_value::<Knowledge>(v).unwrap();
assert!(matches!(k, Knowledge::Clear(_)));
}
}
22 changes: 22 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,4 +615,26 @@ mod tests {
)
.await;
}

#[tokio::test]
async fn test_sanitize_path_absolute_unchanged() {
let os = Os::new().await.unwrap();
let actual = sanitize_path_tool_arg(&os, "/absolute/path");
assert_eq!(actual, os.fs.chroot_path("/absolute/path"));
}

#[tokio::test]
async fn test_sanitize_path_empty_string() {
let os = Os::new().await.unwrap();
// Empty string should not panic — result is implementation-defined but stable
let _ = sanitize_path_tool_arg(&os, "");
}

#[tokio::test]
async fn test_sanitize_path_tilde_only_expands_at_start() {
let os = Os::new().await.unwrap();
// Tilde in the middle should not expand
let actual = sanitize_path_tool_arg(&os, "/foo/~/bar");
assert_eq!(actual, os.fs.chroot_path("/foo/~/bar"));
}
}
33 changes: 33 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/thinking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,36 @@ impl Thinking {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deserialize_thinking() {
let v = serde_json::json!({ "thought": "let me reason through this" });
let t = serde_json::from_value::<Thinking>(v).unwrap();
assert_eq!(t.thought, "let me reason through this");
}

#[tokio::test]
async fn invoke_returns_empty_output() {
let t = Thinking { thought: "some thought".to_string() };
let result = t.invoke(std::io::sink()).await.unwrap();
assert!(matches!(result.output, OutputKind::Text(ref s) if s.is_empty()));
}

#[tokio::test]
async fn validate_accepts_empty_thought() {
let mut t = Thinking { thought: String::new() };
let os = crate::os::Os::new().await.unwrap();
assert!(t.validate(&os).await.is_ok());
}

#[tokio::test]
async fn validate_accepts_non_empty_thought() {
let mut t = Thinking { thought: "complex reasoning".to_string() };
let os = crate::os::Os::new().await.unwrap();
assert!(t.validate(&os).await.is_ok());
}
}
88 changes: 88 additions & 0 deletions crates/chat-cli/src/cli/chat/tools/todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,91 @@ where
let mut seen = HashSet::with_capacity(vec.len());
vec.iter().any(|item| !seen.insert(item))
}

#[cfg(test)]
mod tests {
use super::*;

// ── has_duplicates ───────────────────────────────────────────────────────

#[test]
fn has_duplicates_empty() {
assert!(!has_duplicates::<usize>(&[]));
}

#[test]
fn has_duplicates_unique() {
assert!(!has_duplicates(&[1, 2, 3]));
}

#[test]
fn has_duplicates_with_duplicate() {
assert!(has_duplicates(&[1, 2, 1]));
}

// ── generate_new_todo_id ─────────────────────────────────────────────────

#[test]
fn generate_new_todo_id_is_unique() {
// IDs are millisecond timestamps — sleep to guarantee different values
let a = generate_new_todo_id();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = generate_new_todo_id();
assert_ne!(a, b, "IDs generated at different times must be unique");
}

#[test]
fn generate_new_todo_id_format() {
let id = generate_new_todo_id();
assert!(!id.is_empty());
assert!(id.chars().all(|c| c.is_ascii_digit()), "ID must be numeric: {id}");
}

// ── TodoList deserialization ─────────────────────────────────────────────

#[test]
fn deserialize_create() {
let v = serde_json::json!({
"command": "create",
"todo_list_description": "test todo",
"tasks": ["task 1", "task 2"]
});
let tl = serde_json::from_value::<TodoList>(v).unwrap();
assert!(matches!(tl, TodoList::Create { .. }));
}

#[test]
fn deserialize_complete() {
let v = serde_json::json!({
"command": "complete",
"current_id": "abc123",
"completed_indices": [0],
"context_update": "done"
});
let tl = serde_json::from_value::<TodoList>(v).unwrap();
assert!(matches!(tl, TodoList::Complete { .. }));
}

#[test]
fn deserialize_add() {
let v = serde_json::json!({
"command": "add",
"current_id": "abc123",
"new_tasks": ["new task"],
"insert_indices": [0]
});
let tl = serde_json::from_value::<TodoList>(v).unwrap();
assert!(matches!(tl, TodoList::Add { .. }));
}

#[test]
fn deserialize_remove() {
let v = serde_json::json!({
"command": "remove",
"current_id": "abc123",
"remove_indices": [1]
});
let tl = serde_json::from_value::<TodoList>(v).unwrap();
assert!(matches!(tl, TodoList::Remove { .. }));
}
}