use super::*;
use crate::core::results::StreamEventKind;
use serde_json::json;
#[test]
fn test_llm_new_default_config() {
let llm = LLM::new(
Some("openai:gpt-4o"),
None,
None,
None,
Some("test-key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
assert_eq!(llm.provider(), "openai");
assert!(llm.fallback_models().is_empty());
}
#[test]
fn test_llm_new_with_provider_prefix() {
let llm = LLM::new(
Some("anthropic:claude-3-5-sonnet"),
None,
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(llm.model(), "claude-3-5-sonnet");
assert_eq!(llm.provider(), "anthropic");
}
#[test]
fn test_llm_new_with_explicit_provider() {
let llm = LLM::new(
Some("gpt-4o"),
Some("openai"),
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
assert_eq!(llm.provider(), "openai");
}
#[test]
fn test_llm_new_defaults_to_gpt4o_mini() {
let llm = LLM::new(
None,
None,
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(llm.provider(), "openai");
assert_eq!(llm.model(), "gpt-4o-mini");
}
#[test]
fn test_llm_new_rejects_invalid_verbose() {
let result = LLM::new(
Some("openai:gpt-4o"),
None,
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
Some(5),
None,
);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("verbose"));
}
#[test]
fn test_llm_new_rejects_provider_prefix_with_explicit_provider() {
let result = LLM::new(
Some("openai:gpt-4o"),
Some("anthropic"),
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
);
assert!(result.is_err());
}
#[test]
fn test_llm_new_with_fallback_models() {
let llm = LLM::new(
Some("openai:gpt-4o"),
None,
Some(vec!["openai:gpt-4o-mini".to_string()]),
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(llm.fallback_models(), &["openai:gpt-4o-mini"]);
}
#[test]
fn test_llm_display() {
let llm = LLM::new(
Some("openai:gpt-4o"),
None,
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
let display = format!("{}", llm);
assert_eq!(display, "LLM(openai:gpt-4o)");
}
#[test]
fn test_llm_debug() {
let llm = LLM::new(
Some("openai:gpt-4o"),
None,
None,
None,
Some("key".to_string()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
let debug = format!("{:?}", llm);
assert!(debug.contains("LLM"));
assert!(debug.contains("openai"));
}
#[test]
fn test_api_format_as_str() {
assert_eq!(ApiFormat::Auto.as_str(), "auto");
assert_eq!(ApiFormat::Completion.as_str(), "completion");
assert_eq!(ApiFormat::Responses.as_str(), "responses");
assert_eq!(ApiFormat::Messages.as_str(), "messages");
}
#[test]
fn test_api_format_equality() {
assert_eq!(ApiFormat::Auto, ApiFormat::Auto);
assert_eq!(ApiFormat::Completion, ApiFormat::Completion);
assert_ne!(ApiFormat::Completion, ApiFormat::Responses);
}
#[test]
fn test_embed_input_from_str() {
let input: EmbedInput = "hello".into();
matches!(input, EmbedInput::Single("hello"));
}
#[test]
fn test_embed_input_from_slice() {
let data = vec!["a".to_string(), "b".to_string()];
let input: EmbedInput = data.as_slice().into();
matches!(input, EmbedInput::Multiple(_));
}
#[test]
fn test_build_messages_with_prompt_only() {
let msgs = build_messages(Some("hello"), None, None, None);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[0]["content"], "hello");
}
#[test]
fn test_build_messages_with_system_and_prompt() {
let msgs = build_messages(Some("hello"), None, Some("you are helpful"), None);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[0]["content"], "you are helpful");
assert_eq!(msgs[1]["role"], "user");
}
#[test]
fn test_build_messages_with_existing_messages() {
let existing = vec![json!({"role": "assistant", "content": "hi"})];
let msgs = build_messages(Some("follow up"), None, None, Some(&existing));
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "assistant");
assert_eq!(msgs[1]["role"], "user");
}
#[test]
fn test_build_messages_empty() {
let msgs = build_messages(None, None, None, None);
assert!(msgs.is_empty());
}
#[test]
fn test_build_full_context_from_entries_normalizes_responses_tool_calls() {
let entries = vec![
TapeEntry::tool_call(
vec![json!({
"type": "function_call",
"call_id": "call_123",
"name": "tape_info",
"arguments": "{}"
})],
json!({}),
),
TapeEntry::tool_result(
vec![json!({
"call_id": "call_123",
"output": {"count": 1}
})],
json!({}),
),
];
let messages = build_full_context_from_entries(&entries);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_123");
assert_eq!(
messages[0]["tool_calls"][0]["function"]["name"],
"tape_info"
);
assert_eq!(messages[1]["tool_call_id"], "call_123");
}
#[test]
fn test_build_full_context_from_entries_preserves_tool_call_content() {
let entries = vec![TapeEntry::tool_call_with_content(
vec![json!({
"type": "function",
"id": "call_123",
"function": {
"name": "tape_info",
"arguments": "{}"
}
})],
Some("Checking tape state".to_owned()),
json!({}),
)];
let messages = build_full_context_from_entries(&entries);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["content"], "Checking tape state");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_123");
}
#[tokio::test]
async fn test_prepare_messages_with_tape_persists_initial_prompt_and_system_prompt() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let initial_round_msgs = build_messages(Some("hello"), None, Some("system"), None);
llm.persist_initial_messages("test-tape", &initial_round_msgs)
.await
.unwrap();
let messages = llm
._prepare_messages(Some("test-tape"), None, &initial_round_msgs)
.await
.unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "system");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"], "hello");
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape("test-tape"))
.await
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].kind, "system");
assert_eq!(entries[1].kind, "message");
}
#[tokio::test]
async fn test_persist_initial_messages_skips_duplicate_system_prompt() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let tape = "test-dedup";
let msgs1 = build_messages(Some("hello"), None, Some("system prompt"), None);
llm.persist_initial_messages(tape, &msgs1).await.unwrap();
let msgs2 = build_messages(Some("world"), None, Some("system prompt"), None);
llm.persist_initial_messages(tape, &msgs2).await.unwrap();
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape(tape))
.await
.unwrap();
let system_count = entries.iter().filter(|e| e.kind == "system").count();
assert_eq!(system_count, 1, "system prompt should only appear once");
let message_count = entries.iter().filter(|e| e.kind == "message").count();
assert_eq!(message_count, 2, "both user messages should be persisted");
}
#[tokio::test]
async fn test_persist_initial_messages_writes_changed_system_prompt() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let tape = "test-changed";
let msgs1 = build_messages(Some("hello"), None, Some("prompt v1"), None);
llm.persist_initial_messages(tape, &msgs1).await.unwrap();
let msgs2 = build_messages(Some("world"), None, Some("prompt v2"), None);
llm.persist_initial_messages(tape, &msgs2).await.unwrap();
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape(tape))
.await
.unwrap();
let system_count = entries.iter().filter(|e| e.kind == "system").count();
assert_eq!(system_count, 2, "changed system prompt should be written");
}
#[tokio::test]
async fn test_persist_initial_messages_no_system_in_msgs() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let tape = "test-no-system";
let msgs = build_messages(Some("hello"), None, None, None);
llm.persist_initial_messages(tape, &msgs).await.unwrap();
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape(tape))
.await
.unwrap();
assert_eq!(entries.iter().filter(|e| e.kind == "system").count(), 0);
assert_eq!(entries.iter().filter(|e| e.kind == "message").count(), 1);
}
#[tokio::test]
async fn test_persist_initial_messages_three_calls_same_prompt() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let tape = "test-triple";
for i in 0..3 {
let msgs = build_messages(Some(&format!("msg {i}")), None, Some("stable system"), None);
llm.persist_initial_messages(tape, &msgs).await.unwrap();
}
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape(tape))
.await
.unwrap();
assert_eq!(
entries.iter().filter(|e| e.kind == "system").count(),
1,
"system prompt should appear exactly once across 3 calls"
);
assert_eq!(
entries.iter().filter(|e| e.kind == "message").count(),
3,
"all 3 user messages should be persisted"
);
}
#[tokio::test]
async fn test_persist_initial_messages_change_then_revert() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let tape = "test-revert";
let msgs_v1 = build_messages(Some("a"), None, Some("v1"), None);
llm.persist_initial_messages(tape, &msgs_v1).await.unwrap();
let msgs_v2 = build_messages(Some("b"), None, Some("v2"), None);
llm.persist_initial_messages(tape, &msgs_v2).await.unwrap();
let msgs_v1_again = build_messages(Some("c"), None, Some("v1"), None);
llm.persist_initial_messages(tape, &msgs_v1_again)
.await
.unwrap();
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape(tape))
.await
.unwrap();
assert_eq!(
entries.iter().filter(|e| e.kind == "system").count(),
3,
"v1 -> v2 -> v1 should produce 3 system entries"
);
}
#[tokio::test]
async fn test_persist_initial_messages_empty_list() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let tape = "test-empty";
llm.persist_initial_messages(tape, &[]).await.unwrap();
let entries = llm
.async_tape
.fetch_entries(&llm.async_tape.query_tape(tape))
.await
.unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn test_build_full_context_dedup_with_legacy_duplicate_system_entries() {
let meta = json!({});
let entries = vec![
TapeEntry::system("prompt v1", meta.clone()),
TapeEntry::message(json!({"role": "user", "content": "hello"}), meta.clone()),
TapeEntry::system("prompt v1", meta.clone()), TapeEntry::message(json!({"role": "user", "content": "world"}), meta),
];
let messages = build_full_context_from_entries(&entries);
let system_msgs: Vec<_> = messages
.iter()
.filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("system"))
.collect();
assert_eq!(
system_msgs.len(),
1,
"build_full_context should keep only the last system"
);
assert_eq!(system_msgs[0]["content"], "prompt v1");
let user_msgs: Vec<_> = messages
.iter()
.filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.collect();
assert_eq!(user_msgs.len(), 2, "all user messages should be preserved");
}
#[tokio::test]
async fn test_prepare_messages_no_tape_returns_in_memory() {
let llm = LLM::builder()
.tape_store(AsyncTapeStoreAdapter::new(InMemoryTapeStore::new()))
.build()
.unwrap();
let in_memory = vec![json!({"role": "user", "content": "hello"})];
let msgs = llm._prepare_messages(None, None, &in_memory).await.unwrap();
assert_eq!(msgs, in_memory);
}
#[test]
fn test_extract_content_completion_format() {
let response = json!({
"choices": [{
"message": {
"content": "Hello there!"
}
}]
});
assert_eq!(extract_content(&response).unwrap(), "Hello there!");
}
#[test]
fn test_extract_content_responses_format() {
let response = json!({
"output": [{
"type": "message",
"content": [{"text": "Response text"}]
}]
});
assert_eq!(extract_content(&response).unwrap(), "Response text");
}
#[test]
fn test_extract_content_missing() {
let response = json!({});
assert!(extract_content(&response).is_err());
}
#[test]
fn test_extract_content_anthropic_empty_content_errors() {
let response = json!({
"role": "assistant",
"content": []
});
assert!(extract_content(&response).is_err());
}
#[test]
fn test_extract_tool_calls_completion_format() {
let response = json!({
"choices": [{
"message": {
"tool_calls": [
{"type": "function", "function": {"name": "tool1", "arguments": "{}"}}
]
}
}]
});
let calls = extract_tool_calls(&response).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["function"]["name"], "tool1");
}
#[test]
fn test_extract_tool_calls_responses_format() {
let response = json!({
"output": [
{"type": "function_call", "name": "tool1", "arguments": "{}"},
{"type": "message", "content": [{"text": "hello"}]},
]
});
let calls = extract_tool_calls(&response).unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["function"]["name"], "tool1");
}
#[test]
fn test_extract_tool_calls_empty() {
let response = json!({"choices": [{"message": {"content": "no tools"}}]});
let calls = extract_tool_calls(&response).unwrap();
assert!(calls.is_empty());
}
#[test]
fn test_default_api_base_openai() {
assert_eq!(default_api_base("openai"), "https://api.openai.com/v1");
}
#[test]
fn test_default_api_base_anthropic() {
assert_eq!(
default_api_base("anthropic"),
"https://api.anthropic.com/v1"
);
}
#[test]
fn test_default_api_base_other() {
assert_eq!(default_api_base("cohere"), "https://api.cohere.com/v1");
}
#[test]
fn test_builder_basic() {
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
assert_eq!(llm.provider(), "openai");
assert!(llm.fallback_models().is_empty());
assert!(llm.stream_filter().is_none());
}
#[test]
fn test_builder_with_provider() {
let llm = LLM::builder()
.model("gpt-4o")
.provider("openai")
.api_key("test-key")
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
assert_eq!(llm.provider(), "openai");
}
#[test]
fn test_builder_with_fallback_models() {
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.fallback_models(vec!["openai:gpt-4o-mini".to_string()])
.build()
.unwrap();
assert_eq!(llm.fallback_models(), &["openai:gpt-4o-mini"]);
}
#[test]
fn test_builder_with_api_format() {
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.api_format(ApiFormat::Responses)
.build()
.unwrap();
assert_eq!(llm.provider(), "openai");
}
#[test]
fn test_builder_with_verbose() {
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.verbose(2)
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
}
#[test]
fn test_builder_rejects_invalid_verbose() {
let result = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.verbose(5)
.build();
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("verbose"));
}
#[test]
fn test_builder_defaults_to_gpt4o_mini() {
let llm = LLM::builder().api_key("test-key").build().unwrap();
assert_eq!(llm.provider(), "openai");
assert_eq!(llm.model(), "gpt-4o-mini");
}
#[test]
fn test_builder_with_api_key_resolver() {
let resolver: APIKeyResolver = Box::new(|provider: &str| {
if provider == "openai" {
Some("resolved-key".to_string())
} else {
None
}
});
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key_resolver(resolver)
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
assert_eq!(llm.provider(), "openai");
}
#[test]
fn test_builder_api_key_resolver_fallback_to_map() {
let resolver: APIKeyResolver = Box::new(|_provider: &str| None);
let mut map = HashMap::new();
map.insert("openai".to_string(), "map-key".to_string());
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key_resolver(resolver)
.api_key_map(map)
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
}
#[test]
fn test_builder_explicit_key_overrides_resolver() {
let resolver: APIKeyResolver = Box::new(|_provider: &str| Some("resolver-key".to_string()));
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("explicit-key")
.api_key_resolver(resolver)
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
}
#[test]
fn test_builder_with_stream_filter() {
let filter: StreamEventFilter = Arc::new(|event| Some(event));
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.stream_filter(filter)
.build()
.unwrap();
assert!(llm.stream_filter().is_some());
}
#[test]
fn test_builder_with_max_retries() {
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.max_retries(5)
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
}
#[test]
fn test_builder_with_api_base() {
let llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.api_base("https://custom.api.com/v1")
.build()
.unwrap();
assert_eq!(llm.model(), "gpt-4o");
}
#[test]
fn test_builder_default() {
let _builder = LLMBuilder::default();
}
#[test]
fn test_stream_filter_drops_events() {
let filter: StreamEventFilter = Arc::new(|event| {
if event.kind == StreamEventKind::Text {
None
} else {
Some(event)
}
});
let text_event = StreamEvent::new(StreamEventKind::Text, json!({"delta": "hello"}));
let usage_event = StreamEvent::new(StreamEventKind::Usage, json!({"tokens": 42}));
assert!(filter(text_event).is_none());
assert!(filter(usage_event).is_some());
}
#[test]
fn test_stream_filter_transforms_events() {
let filter: StreamEventFilter = Arc::new(|mut event| {
if event.kind == StreamEventKind::Text {
if let Some(delta) = event.data.get("delta").and_then(|d| d.as_str()) {
event.data = json!({"delta": delta.to_uppercase()});
}
}
Some(event)
});
let event = StreamEvent::new(StreamEventKind::Text, json!({"delta": "hello"}));
let result = filter(event).unwrap();
assert_eq!(result.data["delta"], "HELLO");
}
#[test]
fn test_stream_filter_passthrough() {
let filter: StreamEventFilter = Arc::new(|event| Some(event));
let event = StreamEvent::new(StreamEventKind::Final, json!({"ok": true}));
let result = filter(event);
assert!(result.is_some());
let result = result.unwrap();
assert_eq!(result.kind, StreamEventKind::Final);
}
#[test]
fn test_with_stream_filter_set_and_clear() {
let mut llm = LLM::builder()
.model("openai:gpt-4o")
.api_key("test-key")
.build()
.unwrap();
assert!(llm.stream_filter().is_none());
let filter: StreamEventFilter = Arc::new(|event| Some(event));
llm.with_stream_filter(filter);
assert!(llm.stream_filter().is_some());
llm.clear_stream_filter();
assert!(llm.stream_filter().is_none());
}
#[test]
fn test_responses_url_default_provider() {
let base = default_api_base("openai");
let url = format!("{}/responses", base.trim_end_matches('/'));
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_responses_url_custom_base() {
let base = "https://custom.api.com/v2/";
let url = format!("{}/responses", base.trim_end_matches('/'));
assert_eq!(url, "https://custom.api.com/v2/responses");
}
#[test]
fn test_responses_url_anthropic() {
let base = default_api_base("anthropic");
let url = format!("{}/responses", base.trim_end_matches('/'));
assert_eq!(url, "https://api.anthropic.com/v1/responses");
}
#[test]
fn test_responses_body_structure() {
let input = json!("Tell me a joke");
let model = "gpt-4o";
let body = json!({
"model": model,
"input": input,
});
assert_eq!(body["model"], "gpt-4o");
assert_eq!(body["input"], "Tell me a joke");
}
#[test]
fn test_responses_body_with_array_input() {
let input = json!([
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
]);
let body = json!({
"model": "gpt-4o",
"input": input,
});
assert!(body["input"].is_array());
assert_eq!(body["input"].as_array().unwrap().len(), 2);
}
#[test]
fn test_collect_active_decisions_basic() {
let meta = json!({});
let entries = vec![
TapeEntry::decision("Use PostgreSQL", meta.clone()),
TapeEntry::decision("API-first design", meta.clone()),
];
let decisions = collect_active_decisions(&entries);
assert_eq!(decisions, vec!["Use PostgreSQL", "API-first design"]);
}
#[test]
fn test_collect_active_decisions_with_revocation() {
let meta = json!({});
let entries = vec![
TapeEntry::decision("Use PostgreSQL", meta.clone()),
TapeEntry::decision("API-first design", meta.clone()),
TapeEntry::decision_revoked("Use PostgreSQL", meta.clone()),
];
let decisions = collect_active_decisions(&entries);
assert_eq!(decisions, vec!["API-first design"]);
}
#[test]
fn test_collect_active_decisions_empty() {
let decisions = collect_active_decisions(&[]);
assert!(decisions.is_empty());
}
#[test]
fn test_collect_active_decisions_skips_empty_text() {
let meta = json!({});
let entries = vec![
TapeEntry::decision("", meta.clone()),
TapeEntry::decision("Real decision", meta.clone()),
];
let decisions = collect_active_decisions(&entries);
assert_eq!(decisions, vec!["Real decision"]);
}
#[test]
fn test_collect_active_decisions_duplicate_text() {
let meta = json!({});
let entries = vec![
TapeEntry::decision("Use PostgreSQL", meta.clone()),
TapeEntry::decision("Use PostgreSQL", meta.clone()),
];
let decisions = collect_active_decisions(&entries);
assert_eq!(decisions.len(), 2);
}
#[test]
fn test_inject_decisions_into_system_prompt() {
let mut messages = vec![
json!({"role": "system", "content": "You are helpful."}),
json!({"role": "user", "content": "Hello"}),
];
inject_decisions_into_system_prompt(
&mut messages,
&["Use PostgreSQL".to_string(), "API-first".to_string()],
);
let system_content = messages[0]["content"].as_str().unwrap();
assert!(system_content.contains("Active decisions:"));
assert!(system_content.contains("1. Use PostgreSQL"));
assert!(system_content.contains("2. API-first"));
assert!(system_content.starts_with("You are helpful."));
}
#[test]
fn test_inject_decisions_no_system_message() {
let mut messages = vec![json!({"role": "user", "content": "Hello"})];
inject_decisions_into_system_prompt(&mut messages, &["Use PostgreSQL".to_string()]);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert!(
messages[0]["content"]
.as_str()
.unwrap()
.contains("1. Use PostgreSQL")
);
}
#[test]
fn test_inject_decisions_empty_list() {
let mut messages = vec![json!({"role": "system", "content": "Original"})];
inject_decisions_into_system_prompt(&mut messages, &[]);
assert_eq!(messages[0]["content"], "Original");
}
#[test]
fn test_decisions_survive_full_context_build() {
let meta = json!({});
let entries = vec![
TapeEntry::decision("Use PostgreSQL", meta.clone()),
TapeEntry::message(json!({"role": "user", "content": "Hello"}), meta.clone()),
];
let messages = build_full_context_from_entries(&entries);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["content"], "Hello");
}
#[test]
fn test_build_messages_with_user_content_parts() {
let parts = vec![
json!({"type": "text", "text": "What animal is this?"}),
json!({"type": "image_base64", "mime_type": "image/jpeg", "data": "AQID"}),
];
let msgs = build_messages(None, Some(&parts), Some("You are helpful"), None);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[1]["role"], "user");
let content = msgs[1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image_base64");
}
#[test]
fn test_build_messages_user_content_takes_precedence_over_prompt() {
let parts = vec![json!({"type": "text", "text": "from parts"})];
let msgs = build_messages(Some("from prompt"), Some(&parts), None, None);
assert_eq!(msgs.len(), 1);
assert!(msgs[0]["content"].is_array());
assert_eq!(msgs[0]["content"][0]["text"], "from parts");
}
#[test]
fn test_e2e_image_normalized_to_anthropic() {
use crate::clients::parsing::TransportKind;
use crate::core::message_norm::normalize_messages_for_api;
let parts = vec![
json!({"type": "text", "text": "describe this photo"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "iVBOR"}),
];
let msgs = build_messages(None, Some(&parts), None, None);
let normalized = normalize_messages_for_api(msgs, TransportKind::Messages);
let content = normalized[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[1]["source"]["data"], "iVBOR");
}
#[test]
fn test_e2e_image_normalized_to_openai() {
use crate::clients::parsing::TransportKind;
use crate::core::message_norm::normalize_messages_for_api;
let parts = vec![
json!({"type": "text", "text": "describe"}),
json!({"type": "image_base64", "mime_type": "image/jpeg", "data": "/9j/4A"}),
];
let msgs = build_messages(None, Some(&parts), None, None);
let normalized = normalize_messages_for_api(msgs, TransportKind::Completion);
let content = normalized[0]["content"].as_array().unwrap();
assert_eq!(content[1]["type"], "image_url");
assert_eq!(
content[1]["image_url"]["url"],
"data:image/jpeg;base64,/9j/4A"
);
}
#[test]
fn test_build_messages_multiple_images() {
let parts = vec![
json!({"type": "text", "text": "compare"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "A"}),
json!({"type": "image_base64", "mime_type": "image/jpeg", "data": "B"}),
];
let msgs = build_messages(None, Some(&parts), None, None);
assert_eq!(msgs.len(), 1);
let content = msgs[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 3);
}
#[test]
fn test_build_messages_image_only_no_text() {
let parts = vec![json!({"type": "image_base64", "mime_type": "image/png", "data": "ONLY"})];
let msgs = build_messages(None, Some(&parts), None, None);
assert_eq!(msgs.len(), 1);
let content = msgs[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "image_base64");
}
#[test]
fn test_build_messages_user_content_with_existing_messages() {
let existing = vec![
json!({"role": "user", "content": "first message"}),
json!({"role": "assistant", "content": "ok"}),
];
let parts = vec![
json!({"type": "text", "text": "follow up with image"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "IMG"}),
];
let msgs = build_messages(None, Some(&parts), Some("system"), Some(&existing));
assert_eq!(msgs.len(), 4); assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[1]["content"], "first message");
assert_eq!(msgs[2]["content"], "ok");
assert!(msgs[3]["content"].is_array());
}
#[test]
fn test_build_messages_neither_prompt_nor_user_content() {
let msgs = build_messages(None, None, Some("system"), None);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "system");
}
#[test]
fn test_e2e_multiple_images_anthropic() {
use crate::clients::parsing::TransportKind;
use crate::core::message_norm::normalize_messages_for_api;
let parts = vec![
json!({"type": "text", "text": "compare these two"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "IMG1"}),
json!({"type": "image_base64", "mime_type": "image/jpeg", "data": "IMG2"}),
];
let msgs = build_messages(None, Some(&parts), None, None);
let normalized = normalize_messages_for_api(msgs, TransportKind::Messages);
let content = normalized[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[2]["type"], "image");
assert_eq!(content[2]["source"]["media_type"], "image/jpeg");
}
#[test]
fn test_e2e_image_with_history_and_system() {
use crate::clients::parsing::TransportKind;
use crate::core::message_norm::normalize_messages_for_api;
let history = vec![
json!({"role": "user", "content": "hello"}),
json!({"role": "assistant", "content": "hi there"}),
];
let parts = vec![
json!({"type": "text", "text": "now look at this"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "PIC"}),
];
let msgs = build_messages(None, Some(&parts), Some("you are helpful"), Some(&history));
let normalized = normalize_messages_for_api(msgs, TransportKind::Messages);
assert_eq!(normalized.len(), 4);
assert_eq!(normalized[0]["role"], "system");
assert_eq!(normalized[1]["content"], "hello");
assert_eq!(normalized[2]["content"], "hi there");
let user_content = normalized[3]["content"].as_array().unwrap();
assert_eq!(user_content[1]["type"], "image");
}