use crate::brain::provider::Provider;
use crate::brain::provider::opencode_cli::OpenCodeCliProvider;
use crate::brain::provider::types::*;
use crate::brain::tools::bash::BashTool;
use crate::brain::tools::glob::GlobTool;
use crate::brain::tools::grep::GrepTool;
use crate::brain::tools::read::ReadTool;
use crate::brain::tools::write::WriteTool;
use crate::brain::tools::{Tool, ToolExecutionContext};
use crate::config::{Config, ProviderConfig};
use uuid::Uuid;
fn try_provider() -> Option<OpenCodeCliProvider> {
OpenCodeCliProvider::new().ok()
}
fn is_upstream_refusal(text: impl AsRef<str>) -> bool {
let lower = text.as_ref().to_lowercase();
lower.contains("sorry")
|| lower.contains("cannot assist")
|| lower.contains("can't assist")
|| lower.contains("can\u{2019}t assist")
|| lower.contains("can\u{2019}t reveal")
|| lower.contains("can\u{2019}t recall")
|| lower.contains("can\u{2019}t help")
|| lower.contains("i'm not able")
|| lower.contains("i\u{2019}m not able")
|| lower.contains("unable to assist")
|| lower.contains("unable to help")
|| lower.contains("unable to comply")
|| lower.contains("not able to assist")
|| lower.contains("refuse")
}
fn simple_request(model: &str, prompt: &str) -> LLMRequest {
LLMRequest::new(
model,
vec![Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: prompt.to_string(),
}],
}],
)
}
fn extract_text(response: &LLMResponse) -> String {
response
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
#[test]
fn opencode_provider_name_is_opencode() {
if OpenCodeCliProvider::new().is_err() {
eprintln!("opencode not installed, skipping");
return;
}
let provider = OpenCodeCliProvider::new().unwrap();
assert_eq!(provider.name(), "opencode");
}
#[test]
fn opencode_provider_default_model() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let provider = OpenCodeCliProvider::new().unwrap();
assert_eq!(provider.default_model(), "opencode/gpt-5-nano");
}
#[test]
fn opencode_provider_with_custom_model() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let provider = OpenCodeCliProvider::new()
.unwrap()
.with_default_model("opencode/big-pickle".to_string());
assert_eq!(provider.default_model(), "opencode/big-pickle");
}
#[test]
fn opencode_provider_supported_models_not_empty() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let provider = OpenCodeCliProvider::new().unwrap();
let models = provider.supported_models();
assert!(!models.is_empty(), "supported_models should not be empty");
assert!(
models.contains(&"opencode/gpt-5-nano".to_string()),
"should contain gpt-5-nano"
);
}
#[test]
fn opencode_provider_does_not_support_tools() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let provider = OpenCodeCliProvider::new().unwrap();
assert!(
!provider.supports_tools(),
"OpenCode CLI should not support native tools — OpenCrabs handles them"
);
}
#[test]
fn opencode_provider_supports_vision() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let provider = OpenCodeCliProvider::new().unwrap();
assert!(!provider.supports_vision()); }
#[test]
fn opencode_provider_has_context_window() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let provider = OpenCodeCliProvider::new().unwrap();
let cw = provider.context_window("opencode/gpt-5-nano");
assert!(cw.is_some());
assert!(cw.unwrap() > 0);
}
#[test]
fn opencode_config_resolves_when_enabled() {
let mut config = Config::default();
config.providers.opencode_cli = Some(ProviderConfig {
enabled: true,
default_model: Some("opencode/big-pickle".to_string()),
..Default::default()
});
let (name, model) = crate::config::resolve_provider_from_config(&config);
assert_eq!(name, "OpenCode CLI");
assert_eq!(model, "opencode/big-pickle");
}
#[test]
fn opencode_config_not_resolved_when_disabled() {
let mut config = Config::default();
config.providers.opencode_cli = Some(ProviderConfig {
enabled: false,
default_model: Some("opencode/big-pickle".to_string()),
..Default::default()
});
let (name, _) = crate::config::resolve_provider_from_config(&config);
assert_ne!(name, "OpenCode CLI");
}
#[test]
fn opencode_provider_sync_in_onboarding_array() {
use crate::tui::onboarding::PROVIDERS;
let names: Vec<&str> = PROVIDERS.iter().map(|p| p.name).collect();
assert!(
names.contains(&"OpenCode CLI"),
"PROVIDERS must contain OpenCode CLI. Got: {:?}",
names
);
}
#[tokio::test]
async fn factory_creates_opencode_by_name() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let mut config = Config::default();
config.providers.opencode_cli = Some(ProviderConfig {
enabled: true,
default_model: Some("opencode/gpt-5-nano".to_string()),
..Default::default()
});
let provider =
crate::brain::provider::factory::create_provider_by_name(&config, "opencode-cli").await;
assert!(provider.is_ok(), "Should create opencode provider by name");
assert_eq!(provider.unwrap().name(), "opencode");
}
#[tokio::test]
async fn factory_creates_opencode_by_alt_names() {
if OpenCodeCliProvider::new().is_err() {
return;
}
let mut config = Config::default();
config.providers.opencode_cli = Some(ProviderConfig {
enabled: true,
default_model: Some("opencode/gpt-5-nano".to_string()),
..Default::default()
});
for name in ["opencode-cli", "opencode_cli"] {
let provider =
crate::brain::provider::factory::create_provider_by_name(&config, name).await;
assert!(
provider.is_ok(),
"Should create opencode provider via name '{}'",
name
);
}
}
#[tokio::test]
async fn e2e_opencode_simple_completion() {
use tokio::time::{Duration, timeout};
let Some(provider) = try_provider() else {
return;
};
let request = simple_request("opencode/gpt-5-nano", "Reply with exactly: HELLO_OPENCRABS");
let result = timeout(Duration::from_secs(30), provider.complete(request)).await;
if result.is_err() {
eprintln!("e2e_opencode_simple_completion timed out after 30s, skipping");
return;
}
let response = result.unwrap();
assert!(
response.is_ok(),
"completion should succeed: {:?}",
response.err()
);
let response = response.unwrap();
assert!(!response.content.is_empty(), "response should have content");
let text = extract_text(&response);
if is_upstream_refusal(&text) {
eprintln!(
"e2e_opencode_simple_completion: upstream refusal, skipping: {}",
text
);
return;
}
assert!(
text.contains("HELLO_OPENCRABS"),
"response should contain HELLO_OPENCRABS, got: {}",
text
);
}
#[tokio::test]
async fn e2e_opencode_streaming() {
use futures::StreamExt;
use tokio::time::{Duration, timeout};
let provider = {
let Some(p) = try_provider() else { return };
p
};
let request = simple_request("opencode/gpt-5-nano", "Say hello in one word.");
let stream = provider.stream(request).await;
assert!(stream.is_ok(), "stream should start: {:?}", stream.err());
let mut stream = stream.unwrap();
let mut got_start = false;
let mut got_text = false;
let mut got_stop = false;
let result = timeout(Duration::from_secs(30), async {
while let Some(event) = stream.next().await {
let event = event.expect("stream event should not be error");
match event {
StreamEvent::MessageStart { .. } => got_start = true,
StreamEvent::ContentBlockDelta {
delta: ContentDelta::TextDelta { .. },
..
} => got_text = true,
StreamEvent::MessageStop => {
got_stop = true;
break;
}
_ => {}
}
}
})
.await;
if result.is_err() {
eprintln!("e2e_opencode_streaming timed out after 30s, skipping");
return;
}
assert!(got_start, "should have received MessageStart");
assert!(got_text, "should have received at least one text delta");
if !got_stop {
eprintln!("e2e_opencode_streaming: no MessageStop received, upstream may have refused");
return;
}
assert!(got_stop, "should have received MessageStop");
}
#[tokio::test]
async fn e2e_opencode_with_bash_tool() {
use tokio::time::{Duration, timeout};
let provider = {
let Some(p) = try_provider() else { return };
p
};
let mut request = simple_request(
"opencode/gpt-5-nano",
"What is 2 + 2? Reply with just the number.",
);
request.system = Some("You are a helpful assistant. Reply concisely.".to_string());
let result = timeout(Duration::from_secs(30), provider.complete(request)).await;
if result.is_err() {
eprintln!("e2e_opencode_with_bash_tool timed out after 30s, skipping");
return;
}
let response = result.unwrap().expect("completion should work");
let llm_text = extract_text(&response);
if is_upstream_refusal(&llm_text) {
eprintln!(
"e2e_opencode_with_bash_tool: upstream refusal, skipping: {}",
llm_text
);
return;
}
assert!(
llm_text.contains('4'),
"LLM should answer 4, got: {}",
llm_text
);
let bash = BashTool;
let ctx = ToolExecutionContext::new(Uuid::new_v4()).with_auto_approve(true);
let result = bash
.execute(serde_json::json!({ "command": "echo $((2 + 2))" }), &ctx)
.await
.expect("bash tool should execute");
assert!(result.success);
assert!(result.output.trim().contains('4'));
}
#[tokio::test]
async fn e2e_opencode_with_write_and_read_tools() {
let tmp_dir = tempfile::tempdir().expect("create temp dir");
let test_file = tmp_dir.path().join("opencode_test.txt");
let provider = {
let Some(p) = try_provider() else { return };
p
};
let request = simple_request(
"opencode/gpt-5-nano",
"Write exactly this text and nothing else: OpenCrabs integration test OK",
);
use tokio::time::{Duration, timeout};
let result = timeout(Duration::from_secs(30), provider.complete(request)).await;
if result.is_err() {
eprintln!("e2e_opencode_with_write_and_read_tools timed out after 30s, skipping");
return;
}
let response = result.unwrap().expect("completion works");
let generated_text = extract_text(&response);
let write_tool = WriteTool;
let ctx = ToolExecutionContext::new(Uuid::new_v4())
.with_working_directory(tmp_dir.path().to_path_buf())
.with_auto_approve(true);
let write_result = write_tool
.execute(
serde_json::json!({
"path": test_file.to_string_lossy(),
"content": generated_text.trim()
}),
&ctx,
)
.await
.expect("write tool should work");
assert!(
write_result.success,
"write should succeed: {}",
write_result.output
);
let read_tool = ReadTool;
let read_result = read_tool
.execute(
serde_json::json!({ "path": test_file.to_string_lossy() }),
&ctx,
)
.await
.expect("read tool should work");
assert!(read_result.success, "read should succeed");
let read_output = &read_result.output;
if is_upstream_refusal(read_output) {
eprintln!(
"e2e_opencode_with_write_and_read_tools: upstream refusal, skipping: {}",
read_output
);
return;
}
assert!(
read_result.output.contains("OpenCrabs"),
"read output should contain generated text: {}",
read_result.output
);
}
#[tokio::test]
async fn e2e_opencode_with_glob_and_grep_tools() {
let tmp_dir = tempfile::tempdir().expect("create temp dir");
let ctx = ToolExecutionContext::new(Uuid::new_v4())
.with_working_directory(tmp_dir.path().to_path_buf())
.with_auto_approve(true);
let write_tool = WriteTool;
for (name, content) in [
("alpha.txt", "hello world from opencrabs"),
("beta.rs", "fn main() { println!(\"opencrabs\"); }"),
("gamma.log", "no match here"),
] {
let path = tmp_dir.path().join(name);
write_tool
.execute(
serde_json::json!({
"path": path.to_string_lossy(),
"content": content,
}),
&ctx,
)
.await
.expect("write should work");
}
let glob_tool = GlobTool;
let glob_result = glob_tool
.execute(
serde_json::json!({
"pattern": "*.txt",
"path": tmp_dir.path().to_string_lossy()
}),
&ctx,
)
.await
.expect("glob should work");
assert!(glob_result.success);
assert!(
glob_result.output.contains("alpha.txt"),
"glob should find alpha.txt: {}",
glob_result.output
);
let grep_tool = GrepTool;
let grep_result = grep_tool
.execute(
serde_json::json!({
"pattern": "opencrabs",
"path": tmp_dir.path().to_string_lossy()
}),
&ctx,
)
.await
.expect("grep should work");
assert!(grep_result.success);
assert!(
grep_result.output.contains("alpha.txt"),
"grep should find alpha.txt"
);
assert!(
grep_result.output.contains("beta.rs"),
"grep should find beta.rs"
);
use tokio::time::{Duration, timeout};
let provider = {
let Some(p) = try_provider() else { return };
p
};
let request = simple_request(
"opencode/gpt-5-nano",
&format!(
"The grep results are:\n{}\nHow many files contain 'opencrabs'? Reply with just the number.",
grep_result.output
),
);
let result = timeout(Duration::from_secs(30), provider.complete(request)).await;
if result.is_err() {
eprintln!("e2e_opencode_with_glob_and_grep_tools timed out after 30s, skipping");
return;
}
let response = result.unwrap().expect("completion works");
let answer = extract_text(&response);
if is_upstream_refusal(&answer) {
eprintln!(
"e2e_opencode_with_glob_and_grep_tools: upstream refusal, skipping: {}",
answer
);
return;
}
assert!(answer.contains('2'), "should answer 2, got: {}", answer);
}
#[tokio::test]
async fn e2e_opencode_multi_turn() {
use tokio::time::{Duration, timeout};
let provider = {
let Some(p) = try_provider() else { return };
p
};
let mut request1 = simple_request(
"opencode/gpt-5-nano",
"Remember this: the secret code is CRAB42. Just acknowledge.",
);
request1.system = Some("You are a helpful assistant with good memory.".to_string());
let result1 = timeout(Duration::from_secs(30), provider.complete(request1)).await;
if result1.is_err() {
eprintln!("e2e_opencode_multi_turn turn 1 timed out after 30s, skipping");
return;
}
let resp1 = result1.unwrap().expect("turn 1 should work");
let request2 = LLMRequest {
messages: vec![
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "Remember this: the secret code is CRAB42. Just acknowledge.".to_string(),
}],
},
Message {
role: Role::Assistant,
content: resp1.content,
},
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "What was the secret code? Reply with just the code.".to_string(),
}],
},
],
system: Some("You are a helpful assistant with good memory.".to_string()),
..LLMRequest::new("opencode/gpt-5-nano", vec![])
};
let result2 = timeout(Duration::from_secs(30), provider.complete(request2)).await;
if result2.is_err() {
eprintln!("e2e_opencode_multi_turn turn 2 timed out after 30s, skipping");
return;
}
let resp2 = result2.unwrap().expect("turn 2 should work");
let text = extract_text(&resp2);
if is_upstream_refusal(&text) {
eprintln!(
"e2e_opencode_multi_turn: upstream refusal, skipping: {}",
text
);
return;
}
assert!(
text.contains("CRAB42"),
"multi-turn should recall the code, got: {}",
text
);
}
#[tokio::test]
async fn memory_search_tool_returns_result_for_query() {
use crate::brain::tools::memory_search::MemorySearchTool;
let tool = MemorySearchTool;
let ctx = ToolExecutionContext::new(Uuid::new_v4());
let result = tool
.execute(
serde_json::json!({ "query": "opencode provider test" }),
&ctx,
)
.await
.expect("memory search should not panic");
assert!(result.success || result.output.contains("No") || result.output.contains("no"));
}
#[test]
fn memory_search_tool_requires_query() {
use crate::brain::tools::memory_search::MemorySearchTool;
let schema = MemorySearchTool.input_schema();
let required = schema.get("required").and_then(|v| v.as_array());
assert!(
required.is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some("query"))),
"memory_search should require 'query' parameter"
);
}
#[test]
fn tool_registry_has_core_tools() {
use crate::brain::tools::registry::ToolRegistry;
use std::sync::Arc;
let registry = ToolRegistry::new();
registry.register(Arc::new(BashTool));
registry.register(Arc::new(ReadTool));
registry.register(Arc::new(WriteTool));
registry.register(Arc::new(GlobTool));
registry.register(Arc::new(GrepTool));
for expected in ["bash", "read_file", "write_file", "glob", "grep"] {
assert!(
registry.get(expected).is_some(),
"registry should contain '{}' tool",
expected,
);
}
}