Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// Acceptance tests for context compression (all three compaction levels).
|
||||
//
|
||||
// TC-A2-01 and TC-A2-03 are purely local (no LLM call).
|
||||
// TC-A2-02 makes a real LLM call and is skipped when OPENAI_API_KEY is absent.
|
||||
|
||||
use nomi_agent::compact::auto::{BOUNDARY_PREFIX, autocompact, should_autocompact};
|
||||
use nomi_agent::compact::emergency::is_at_emergency_limit;
|
||||
use nomi_agent::compact::micro::{CLEARED_TOOL_RESULT, microcompact};
|
||||
use nomi_agent::compact::state::CompactState;
|
||||
use nomi_config::compact::CompactConfig;
|
||||
use nomi_types::message::{ContentBlock, Message, Role};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::helpers;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn tool_use_block(id: &str, name: &str) -> ContentBlock {
|
||||
ContentBlock::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
input: json!({}),
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_result_block(id: &str, content: &str) -> ContentBlock {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: id.to_string(),
|
||||
content: content.to_string(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── TC-A2-01: Microcompact clears old tool results (LOCAL) ─────────────────
|
||||
|
||||
/// Construct a message history with more than `micro_keep_recent * 2`
|
||||
/// compactable tool results (each with a matching ToolUse block), run
|
||||
/// microcompact, and verify that old results are cleared while the most
|
||||
/// recent `micro_keep_recent` are preserved.
|
||||
#[test]
|
||||
fn microcompact_clears_old_tool_results() {
|
||||
let keep_recent: usize = 3;
|
||||
// We need MORE than keep_recent * 2 = 6 compactable results, so use 8.
|
||||
let total_results: usize = 8;
|
||||
|
||||
let config = CompactConfig {
|
||||
micro_keep_recent: keep_recent,
|
||||
compactable_tools: vec!["Read".to_string()],
|
||||
..CompactConfig::default()
|
||||
};
|
||||
|
||||
// Build messages: alternating ToolUse (assistant) and ToolResult (user)
|
||||
let mut messages: Vec<Message> = Vec::with_capacity(total_results * 2);
|
||||
for i in 0..total_results {
|
||||
let id = format!("tool_{i}");
|
||||
messages.push(Message::new(
|
||||
Role::Assistant,
|
||||
vec![tool_use_block(&id, "Read")],
|
||||
));
|
||||
messages.push(Message::new(
|
||||
Role::User,
|
||||
vec![tool_result_block(&id, &format!("content of file {i}"))],
|
||||
));
|
||||
}
|
||||
|
||||
let result = microcompact(&mut messages, &config);
|
||||
|
||||
// Verify cleared count is positive
|
||||
assert!(
|
||||
result.cleared_count > 0,
|
||||
"microcompact should clear at least one tool result, got cleared_count=0"
|
||||
);
|
||||
|
||||
// Exactly total_results - keep_recent should be cleared
|
||||
let expected_cleared = total_results - keep_recent;
|
||||
assert_eq!(
|
||||
result.cleared_count, expected_cleared,
|
||||
"expected {expected_cleared} cleared, got {}",
|
||||
result.cleared_count
|
||||
);
|
||||
|
||||
// Verify old results (first `expected_cleared`) are replaced with placeholder
|
||||
for i in 0..expected_cleared {
|
||||
let user_msg_idx = i * 2 + 1; // user messages are at odd indices
|
||||
match &messages[user_msg_idx].content[0] {
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
assert_eq!(
|
||||
content, CLEARED_TOOL_RESULT,
|
||||
"tool result at index {i} should be cleared"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ToolResult at index {user_msg_idx}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Verify most recent `keep_recent` results are preserved
|
||||
for i in expected_cleared..total_results {
|
||||
let user_msg_idx = i * 2 + 1;
|
||||
match &messages[user_msg_idx].content[0] {
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
let expected = format!("content of file {i}");
|
||||
assert_eq!(
|
||||
content, &expected,
|
||||
"tool result at index {i} should be preserved with original content"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ToolResult at index {user_msg_idx}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TC-A2-02: Autocompact triggers LLM summary (REAL API CALL) ────────────
|
||||
|
||||
/// Set a very low autocompact threshold, verify should_autocompact triggers,
|
||||
/// then call autocompact with a real LLM provider and verify the result
|
||||
/// contains the boundary prefix marker.
|
||||
#[tokio::test]
|
||||
async fn autocompact_triggers_llm_summary() {
|
||||
let api_key = match helpers::openai_api_key() {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
eprintln!("[acceptance] OPENAI_API_KEY not set — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Use gpt-4.1-mini which supports up to 32768 output tokens.
|
||||
// The autocompact function requests COMPACT_MAX_OUTPUT_TOKENS (20000),
|
||||
// which exceeds gpt-4o-mini's 16384 limit.
|
||||
let config = {
|
||||
let base = helpers::openai_config(&api_key);
|
||||
nomi_config::config::Config {
|
||||
model: "gpt-4.1-mini".to_string(),
|
||||
..base
|
||||
}
|
||||
};
|
||||
|
||||
let compact_config = CompactConfig {
|
||||
context_window: 1000,
|
||||
output_reserve: 100,
|
||||
autocompact_buffer: 100,
|
||||
// threshold = 1000 - 100 - 100 = 800
|
||||
..CompactConfig::default()
|
||||
};
|
||||
|
||||
// Verify should_autocompact detects the threshold is exceeded
|
||||
assert!(
|
||||
should_autocompact(900, &compact_config),
|
||||
"900 tokens should exceed the threshold of 800"
|
||||
);
|
||||
assert!(
|
||||
!should_autocompact(700, &compact_config),
|
||||
"700 tokens should be below the threshold of 800"
|
||||
);
|
||||
|
||||
// Build a simple conversation
|
||||
let messages = vec![
|
||||
Message::new(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
),
|
||||
Message::new(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hi there!".to_string(),
|
||||
}],
|
||||
),
|
||||
Message::new(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "What is 2+2?".to_string(),
|
||||
}],
|
||||
),
|
||||
Message::new(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: "4".to_string(),
|
||||
}],
|
||||
),
|
||||
];
|
||||
|
||||
// Create a real provider and run autocompact
|
||||
let provider = nomi_providers::create_provider(&config);
|
||||
|
||||
let state = CompactState {
|
||||
last_input_tokens: 900, // above the threshold of 800
|
||||
..CompactState::default()
|
||||
};
|
||||
// autocompact takes &mut state for recording success/failure
|
||||
let mut state = state;
|
||||
|
||||
let result = autocompact(
|
||||
provider.as_ref(),
|
||||
&messages,
|
||||
&config.model,
|
||||
&compact_config,
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let compact_result = result.expect("autocompact should succeed with a real LLM");
|
||||
|
||||
// Verify the result messages contain the boundary prefix
|
||||
let has_boundary = compact_result.messages.iter().any(|msg| {
|
||||
msg.content.iter().any(|block| {
|
||||
if let ContentBlock::Text { text } = block {
|
||||
text.starts_with(BOUNDARY_PREFIX)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
has_boundary,
|
||||
"autocompact result should contain a message with the boundary prefix"
|
||||
);
|
||||
|
||||
// Verify metadata
|
||||
assert_eq!(compact_result.messages_summarized, messages.len());
|
||||
assert_eq!(compact_result.pre_compact_tokens, 900);
|
||||
}
|
||||
|
||||
// ── TC-A2-03: Emergency truncation detection (LOCAL) ───────────────────────
|
||||
|
||||
/// Verify that is_at_emergency_limit correctly detects when the token count
|
||||
/// is within the emergency buffer of the context window, and that it works
|
||||
/// even when compact is disabled.
|
||||
#[test]
|
||||
fn emergency_truncation_detection() {
|
||||
let config = CompactConfig {
|
||||
context_window: 1000,
|
||||
emergency_buffer: 100,
|
||||
// limit = 1000 - 100 = 900
|
||||
..CompactConfig::default()
|
||||
};
|
||||
|
||||
// 950 >= 900 → true (at emergency limit)
|
||||
assert!(
|
||||
is_at_emergency_limit(950, &config),
|
||||
"950 tokens should be at the emergency limit (threshold = 900)"
|
||||
);
|
||||
|
||||
// 800 < 900 → false (below emergency limit)
|
||||
assert!(
|
||||
!is_at_emergency_limit(800, &config),
|
||||
"800 tokens should be below the emergency limit (threshold = 900)"
|
||||
);
|
||||
|
||||
// Verify emergency check works even when config.enabled = false
|
||||
let disabled_config = CompactConfig {
|
||||
context_window: 1000,
|
||||
emergency_buffer: 100,
|
||||
enabled: false,
|
||||
..CompactConfig::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
is_at_emergency_limit(950, &disabled_config),
|
||||
"emergency limit should apply even when compact is disabled"
|
||||
);
|
||||
assert!(
|
||||
!is_at_emergency_limit(800, &disabled_config),
|
||||
"below-limit should still return false when compact is disabled"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// TC-AX-01: Multi-feature collaboration test (LOCAL, no LLM).
|
||||
//
|
||||
// Exercises memory + compression + file cache + tool description all at once.
|
||||
|
||||
use nomi_agent::compact::micro::{CLEARED_TOOL_RESULT, microcompact};
|
||||
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
|
||||
use nomi_config::compact::CompactConfig;
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_tools::file_cache::FileStateCache;
|
||||
use nomi_tools::read::ReadTool;
|
||||
use nomi_types::message::{ContentBlock, Message, Role};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[tokio::test]
|
||||
async fn tc_ax_01_multi_feature_collaboration() {
|
||||
// ── Step 1: Setup memory directory with MEMORY.md and an entry ──
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
std::fs::write(
|
||||
mem_dir.join("MEMORY.md"),
|
||||
"- [Preferences](prefs.md) \u{2014} user prefers dark theme\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
mem_dir.join("prefs.md"),
|
||||
"The user prefers dark theme and compact layout.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// ── Step 2: Build system prompt with memory ──
|
||||
let system_prompt = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
Some(&mem_dir),
|
||||
false, // plan_mode_active = false
|
||||
false,
|
||||
false, // browser_enabled
|
||||
);
|
||||
|
||||
// Assert: system prompt contains memory content
|
||||
assert!(
|
||||
system_prompt.contains("auto memory"),
|
||||
"system prompt should contain memory system display name"
|
||||
);
|
||||
assert!(
|
||||
system_prompt.contains("prefs.md"),
|
||||
"system prompt should reference the memory entry file"
|
||||
);
|
||||
|
||||
// Assert: system prompt contains tool guidance
|
||||
assert!(
|
||||
system_prompt.contains("# Using your tools"),
|
||||
"system prompt should contain tool usage guidance heading"
|
||||
);
|
||||
|
||||
// ── Step 3: ReadTool dedup via FileStateCache ──
|
||||
let cache_config = FileCacheConfig {
|
||||
max_entries: 100,
|
||||
max_size_bytes: 25 * 1024 * 1024,
|
||||
enabled: true,
|
||||
};
|
||||
let cache = Arc::new(RwLock::new(FileStateCache::new(&cache_config)));
|
||||
let read_tool = ReadTool::new(Some(Arc::clone(&cache)), None);
|
||||
|
||||
let test_file = tmp.path().join("test_read.txt");
|
||||
std::fs::write(&test_file, "line one\nline two\nline three\n").unwrap();
|
||||
|
||||
let input = json!({ "file_path": test_file.to_str().unwrap() });
|
||||
|
||||
// First read: full content
|
||||
let r1 = read_tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error, "first read should succeed");
|
||||
assert!(
|
||||
r1.content.contains("line one"),
|
||||
"first read should return file content"
|
||||
);
|
||||
|
||||
// Second read: dedup stub (file unchanged)
|
||||
let r2 = read_tool.execute(input).await;
|
||||
assert!(!r2.is_error, "second read should succeed");
|
||||
assert!(
|
||||
r2.content.contains("unchanged since last read"),
|
||||
"second read should return dedup stub, got: {}",
|
||||
r2.content
|
||||
);
|
||||
|
||||
// ── Step 4: Microcompact clears old tool results ──
|
||||
let mut messages = Vec::new();
|
||||
for i in 0..8 {
|
||||
let id = format!("t{i}");
|
||||
messages.push(Message::new(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: "Read".to_string(),
|
||||
input: json!({}),
|
||||
extra: None,
|
||||
}],
|
||||
));
|
||||
messages.push(Message::new(
|
||||
Role::User,
|
||||
vec![ContentBlock::ToolResult {
|
||||
tool_use_id: id,
|
||||
content: format!("file-content-{i}"),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}],
|
||||
));
|
||||
}
|
||||
|
||||
let compact_config = CompactConfig {
|
||||
micro_keep_recent: 3,
|
||||
..CompactConfig::default()
|
||||
};
|
||||
let result = microcompact(&mut messages, &compact_config);
|
||||
|
||||
// Assert: microcompact cleared some old tool results
|
||||
assert!(
|
||||
result.cleared_count > 0,
|
||||
"microcompact should clear old tool results, got cleared_count={}",
|
||||
result.cleared_count
|
||||
);
|
||||
|
||||
// Verify cleared results contain the placeholder
|
||||
let cleared_results: Vec<_> = messages
|
||||
.iter()
|
||||
.flat_map(|m| &m.content)
|
||||
.filter(|b| matches!(b, ContentBlock::ToolResult { content, .. } if content == CLEARED_TOOL_RESULT))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
cleared_results.len(),
|
||||
result.cleared_count,
|
||||
"number of cleared placeholders should match cleared_count"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Acceptance tests for file cache dedup and cross-tool integration.
|
||||
//
|
||||
// These are LOCAL tests — no LLM call required.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use nomi_config::file_cache::FileCacheConfig;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_tools::edit::EditTool;
|
||||
use nomi_tools::file_cache::FileStateCache;
|
||||
use nomi_tools::read::ReadTool;
|
||||
use nomi_tools::write::WriteTool;
|
||||
|
||||
fn make_cache() -> Arc<RwLock<FileStateCache>> {
|
||||
let config = FileCacheConfig::default();
|
||||
Arc::new(RwLock::new(FileStateCache::new(&config)))
|
||||
}
|
||||
|
||||
/// TC-A5-01: Read dedup (LOCAL, no LLM).
|
||||
///
|
||||
/// Verifies that a second read of an unchanged file returns a short dedup stub
|
||||
/// instead of re-sending the full content.
|
||||
#[tokio::test]
|
||||
async fn read_dedup_returns_stub_on_second_read() {
|
||||
let cache = make_cache();
|
||||
let read_tool = ReadTool::new(Some(cache.clone()), None);
|
||||
|
||||
// Create a temporary file with known content.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("dedup_test.txt");
|
||||
std::fs::write(&file_path, "line one\nline two\nline three\n").unwrap();
|
||||
let path_str = file_path.to_str().unwrap();
|
||||
|
||||
let input = json!({ "file_path": path_str });
|
||||
|
||||
// First read: should return full line-numbered content.
|
||||
let r1 = read_tool.execute(input.clone()).await;
|
||||
assert!(!r1.is_error, "first read should succeed: {}", r1.content);
|
||||
assert!(
|
||||
r1.content.contains("1\tline one"),
|
||||
"first read should contain line-numbered content, got: {}",
|
||||
r1.content
|
||||
);
|
||||
assert!(
|
||||
r1.content.contains("2\tline two"),
|
||||
"first read should contain line 2"
|
||||
);
|
||||
assert!(
|
||||
r1.content.contains("3\tline three"),
|
||||
"first read should contain line 3"
|
||||
);
|
||||
|
||||
// Second read WITHOUT modifying the file: should return dedup stub.
|
||||
let r2 = read_tool.execute(input).await;
|
||||
assert!(!r2.is_error, "second read should succeed: {}", r2.content);
|
||||
assert!(
|
||||
r2.content.contains("unchanged since last read"),
|
||||
"second read should return dedup stub, got: {}",
|
||||
r2.content
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-A5-02: Write -> Edit chain (LOCAL, no LLM).
|
||||
///
|
||||
/// Verifies that WriteTool populates the cache so EditTool can immediately
|
||||
/// edit the file without a separate Read call (no "must Read first" error).
|
||||
#[tokio::test]
|
||||
async fn write_then_edit_chain_succeeds() {
|
||||
let cache = make_cache();
|
||||
let write_tool = WriteTool::new(Some(cache.clone()));
|
||||
let edit_tool = EditTool::new(Some(cache.clone()));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("write_edit_chain.txt");
|
||||
let path_str = file_path.to_str().unwrap();
|
||||
|
||||
// Write a file via WriteTool.
|
||||
let write_result = write_tool
|
||||
.execute(json!({
|
||||
"file_path": path_str,
|
||||
"content": "hello world\n"
|
||||
}))
|
||||
.await;
|
||||
assert!(
|
||||
!write_result.is_error,
|
||||
"write should succeed: {}",
|
||||
write_result.content
|
||||
);
|
||||
|
||||
// Immediately edit via EditTool — should NOT get "must Read first" error.
|
||||
let edit_result = edit_tool
|
||||
.execute(json!({
|
||||
"file_path": path_str,
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye"
|
||||
}))
|
||||
.await;
|
||||
assert!(
|
||||
!edit_result.is_error,
|
||||
"edit after write should succeed without 'must Read first' error: {}",
|
||||
edit_result.content
|
||||
);
|
||||
|
||||
// Verify file content on disk.
|
||||
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(
|
||||
content, "goodbye world\n",
|
||||
"file content should reflect the edit"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Shared helpers for acceptance tests: provider detection and config builders.
|
||||
|
||||
use nomi_config::compat::ProviderCompat;
|
||||
use nomi_config::config::{BedrockConfig, Config, ProviderType, SessionConfig, ToolsConfig};
|
||||
use nomi_config::hooks::HooksConfig;
|
||||
use nomi_mcp::config::McpConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the OpenAI API key if set and non-empty.
|
||||
pub fn openai_api_key() -> Option<String> {
|
||||
std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
/// Returns true when AWS Bedrock is configured for use.
|
||||
pub fn bedrock_configured() -> bool {
|
||||
let has_profile = std::env::var("AWS_PROFILE")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.is_some();
|
||||
let bedrock_flag = std::env::var("CLAUDE_CODE_USE_BEDROCK")
|
||||
.ok()
|
||||
.filter(|v| v == "1")
|
||||
.is_some();
|
||||
has_profile && bedrock_flag
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skip macros
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Skips the current test if OPENAI_API_KEY is not set.
|
||||
/// Usage: `skip_if_no_openai!();` at the start of a test function.
|
||||
macro_rules! skip_if_no_openai {
|
||||
() => {
|
||||
#[allow(unused_variables)]
|
||||
let openai_api_key = match $crate::helpers::openai_api_key() {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
eprintln!("[acceptance] OPENAI_API_KEY not set — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// Skips the current test if Bedrock is not configured.
|
||||
/// Usage: `skip_if_no_bedrock!();` at the start of a test function.
|
||||
macro_rules! skip_if_no_bedrock {
|
||||
() => {
|
||||
if !$crate::helpers::bedrock_configured() {
|
||||
eprintln!("[acceptance] Bedrock not configured — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a Config for the OpenAI provider (gpt-4o-mini, cheap for tests).
|
||||
pub fn openai_config(api_key: &str) -> Config {
|
||||
Config {
|
||||
provider: ProviderType::OpenAI,
|
||||
provider_label: "openai".to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
base_url: "https://api.openai.com".to_string(),
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
max_tokens: 256,
|
||||
max_turns: Some(3),
|
||||
system_prompt: Some("You are a helpful assistant. Be concise.".to_string()),
|
||||
thinking: None,
|
||||
prompt_caching: false,
|
||||
compat: ProviderCompat::openai_defaults(),
|
||||
tools: ToolsConfig {
|
||||
auto_approve: true,
|
||||
allow_list: vec![],
|
||||
..ToolsConfig::default()
|
||||
},
|
||||
session: SessionConfig {
|
||||
enabled: false,
|
||||
directory: "/tmp/nomi-acceptance".to_string(),
|
||||
max_sessions: 1,
|
||||
},
|
||||
compact: nomi_config::compact::CompactConfig::default(),
|
||||
plan: nomi_config::plan::PlanConfig::default(),
|
||||
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
|
||||
hooks: HooksConfig::default(),
|
||||
bedrock: None,
|
||||
vertex: None,
|
||||
mcp: McpConfig::default(),
|
||||
logging: nomi_config::logging::LoggingConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Config for the AWS Bedrock provider (Claude Haiku).
|
||||
pub fn bedrock_config() -> Config {
|
||||
Config {
|
||||
provider: ProviderType::Bedrock,
|
||||
provider_label: "bedrock".to_string(),
|
||||
api_key: String::new(), // Bedrock uses AWS credentials, not API key
|
||||
base_url: String::new(),
|
||||
model: "us.anthropic.claude-haiku-4-20250514-v1:0".to_string(),
|
||||
max_tokens: 256,
|
||||
max_turns: Some(3),
|
||||
system_prompt: Some("You are a helpful assistant. Be concise.".to_string()),
|
||||
thinking: None,
|
||||
prompt_caching: false,
|
||||
compat: ProviderCompat::anthropic_defaults(),
|
||||
tools: ToolsConfig {
|
||||
auto_approve: true,
|
||||
allow_list: vec![],
|
||||
..ToolsConfig::default()
|
||||
},
|
||||
session: SessionConfig {
|
||||
enabled: false,
|
||||
directory: "/tmp/nomi-acceptance".to_string(),
|
||||
max_sessions: 1,
|
||||
},
|
||||
compact: nomi_config::compact::CompactConfig::default(),
|
||||
plan: nomi_config::plan::PlanConfig::default(),
|
||||
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
|
||||
hooks: HooksConfig::default(),
|
||||
bedrock: Some(BedrockConfig::default()),
|
||||
vertex: None,
|
||||
mcp: McpConfig::default(),
|
||||
logging: nomi_config::logging::LoggingConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Acceptance tests for the memory system end-to-end.
|
||||
//
|
||||
// These tests verify that the memory system's file I/O, index management,
|
||||
// and prompt building work together correctly. No LLM API calls are needed.
|
||||
|
||||
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
|
||||
use nomi_memory::index::{append_index_entry, remove_index_entry};
|
||||
use nomi_memory::paths::ENTRYPOINT_NAME;
|
||||
use nomi_memory::store::{delete_memory, write_memory};
|
||||
use nomi_memory::types::{MemoryEntry, MemoryType};
|
||||
|
||||
/// TC-A1-01: Memory injection into system prompt.
|
||||
///
|
||||
/// Verifies that when a memory directory exists with an index file and a
|
||||
/// memory entry, `build_system_prompt()` produces output containing both
|
||||
/// the compact behavioral instructions and the MEMORY.md index content.
|
||||
#[test]
|
||||
fn memory_injection_into_system_prompt() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
// Write MEMORY.md index with one entry
|
||||
let index_path = mem_dir.join(ENTRYPOINT_NAME);
|
||||
std::fs::write(
|
||||
&index_path,
|
||||
"# Memory Index\n\n- [User role](user_role.md) \u{2014} senior Rust engineer\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Write a corresponding memory entry file
|
||||
std::fs::write(
|
||||
mem_dir.join("user_role.md"),
|
||||
"---\nname: user_role\ndescription: senior Rust engineer\ntype: user\n---\n\nThe user is a senior Rust engineer.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
Some(&mem_dir),
|
||||
false,
|
||||
false,
|
||||
false, // browser_enabled
|
||||
);
|
||||
|
||||
// Behavioral instructions must be present
|
||||
assert!(
|
||||
prompt.contains("auto memory"),
|
||||
"system prompt should contain the memory display name"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Memory types:"),
|
||||
"system prompt should contain the compact memory type summary"
|
||||
);
|
||||
|
||||
// MEMORY.md content must be injected
|
||||
assert!(
|
||||
prompt.contains("user_role.md"),
|
||||
"system prompt should contain the MEMORY.md index filename reference"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("senior Rust engineer"),
|
||||
"system prompt should contain the MEMORY.md index summary"
|
||||
);
|
||||
}
|
||||
|
||||
/// TC-A1-02: Memory full lifecycle (create, index, verify, delete, verify gone).
|
||||
///
|
||||
/// Exercises the complete lifecycle of a memory entry through the public API:
|
||||
/// 1. write_memory() -> create the file
|
||||
/// 2. append_index_entry() -> add to MEMORY.md
|
||||
/// 3. build_system_prompt() -> verify the content appears
|
||||
/// 4. delete_memory() -> remove the file
|
||||
/// 5. remove_index_entry() -> clean the index
|
||||
/// 6. build_system_prompt() -> verify the content is gone
|
||||
#[test]
|
||||
fn memory_full_lifecycle() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let index_path = mem_dir.join(ENTRYPOINT_NAME);
|
||||
|
||||
// -- Phase 1: Create memory entry via the store API -----------------------
|
||||
|
||||
let entry = MemoryEntry::build(
|
||||
"project status",
|
||||
"current sprint goals",
|
||||
MemoryType::Project,
|
||||
"We are migrating the auth service to the new provider.",
|
||||
);
|
||||
|
||||
let entry_path = write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(entry_path.exists(), "memory file should be created on disk");
|
||||
|
||||
let entry_filename = entry_path.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
|
||||
// -- Phase 2: Add the entry to the MEMORY.md index ------------------------
|
||||
|
||||
append_index_entry(
|
||||
&index_path,
|
||||
"Project status",
|
||||
&entry_filename,
|
||||
"current sprint goals",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let index_content = std::fs::read_to_string(&index_path).unwrap();
|
||||
assert!(
|
||||
index_content.contains(&entry_filename),
|
||||
"MEMORY.md should reference the new entry file"
|
||||
);
|
||||
|
||||
// -- Phase 3: Verify system prompt includes the memory content ------------
|
||||
|
||||
let prompt_with_memory = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
Some(&mem_dir),
|
||||
false,
|
||||
false,
|
||||
false, // browser_enabled
|
||||
);
|
||||
|
||||
assert!(
|
||||
prompt_with_memory.contains("auto memory"),
|
||||
"prompt should contain behavioral instructions"
|
||||
);
|
||||
assert!(
|
||||
prompt_with_memory.contains("Memory types:"),
|
||||
"prompt should contain compact memory type summary"
|
||||
);
|
||||
assert!(
|
||||
prompt_with_memory.contains(&entry_filename),
|
||||
"prompt should contain the memory entry filename from the index"
|
||||
);
|
||||
assert!(
|
||||
prompt_with_memory.contains("current sprint goals"),
|
||||
"prompt should contain the index summary"
|
||||
);
|
||||
|
||||
// -- Phase 4: Delete the memory file --------------------------------------
|
||||
|
||||
delete_memory(&entry_path).unwrap();
|
||||
assert!(
|
||||
!entry_path.exists(),
|
||||
"memory file should be removed from disk"
|
||||
);
|
||||
|
||||
// -- Phase 5: Remove the entry from the MEMORY.md index -------------------
|
||||
|
||||
remove_index_entry(&index_path, &entry_filename).unwrap();
|
||||
|
||||
let index_after = std::fs::read_to_string(&index_path).unwrap();
|
||||
assert!(
|
||||
!index_after.contains(&entry_filename),
|
||||
"MEMORY.md should no longer reference the deleted entry"
|
||||
);
|
||||
|
||||
// -- Phase 6: Verify the content is gone from the system prompt -----------
|
||||
|
||||
let prompt_after_delete = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
Some(&mem_dir),
|
||||
false,
|
||||
false,
|
||||
false, // browser_enabled
|
||||
);
|
||||
|
||||
assert!(
|
||||
!prompt_after_delete.contains(&entry_filename),
|
||||
"prompt should no longer contain the deleted entry filename"
|
||||
);
|
||||
assert!(
|
||||
!prompt_after_delete.contains("current sprint goals"),
|
||||
"prompt should no longer contain the deleted entry summary"
|
||||
);
|
||||
// With everything removed, the index is empty — the prompt should show the empty state
|
||||
assert!(
|
||||
prompt_after_delete.contains("currently empty"),
|
||||
"prompt should show empty memory state after all entries are removed"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#![allow(dead_code, unused_macros, unused_imports)]
|
||||
/// Acceptance tests for evolution features (Phase 6).
|
||||
///
|
||||
/// These tests validate end-to-end behavior of each evolution feature
|
||||
/// against real LLM providers. They are skipped when provider credentials
|
||||
/// are absent, making them safe to run in any environment.
|
||||
///
|
||||
/// Required env vars (at least one):
|
||||
/// OPENAI_API_KEY — runs OpenAI provider tests
|
||||
/// AWS_PROFILE + CLAUDE_CODE_USE_BEDROCK=1 — runs Bedrock provider tests
|
||||
///
|
||||
/// Run manually:
|
||||
/// OPENAI_API_KEY=sk-... cargo nextest run -p nomi-agent --profile e2e --test acceptance
|
||||
#[macro_use]
|
||||
mod helpers;
|
||||
mod compact_test;
|
||||
mod cross_feature_test;
|
||||
mod file_cache_test;
|
||||
mod memory_test;
|
||||
mod plan_mode_test;
|
||||
mod tool_desc_test;
|
||||
@@ -0,0 +1,226 @@
|
||||
// Acceptance tests for Plan Mode tool filtering and prompt injection (Task 6.4).
|
||||
//
|
||||
// These tests are LOCAL (no LLM required) and verify that:
|
||||
// - Tool registry filtering produces the correct tool sets for normal vs plan mode
|
||||
// - System prompt correctly includes/excludes plan mode instructions
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
|
||||
use nomi_agent::plan::tools::{EnterPlanModeTool, ExitPlanModeTool};
|
||||
use nomi_protocol::events::ToolCategory;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_tools::registry::ToolRegistry;
|
||||
use nomi_types::tool::ToolResult;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: mock tool with configurable category
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CategoryMockTool {
|
||||
tool_name: String,
|
||||
cat: ToolCategory,
|
||||
}
|
||||
|
||||
impl CategoryMockTool {
|
||||
fn new(name: &str, cat: ToolCategory) -> Self {
|
||||
Self {
|
||||
tool_name: name.to_string(),
|
||||
cat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CategoryMockTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({"type": "object"})
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
self.cat
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _input: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, _input: Value) -> ToolResult {
|
||||
ToolResult {
|
||||
content: String::new(),
|
||||
is_error: false,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-A3-01: Plan Mode tool filtering (LOCAL, no LLM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_a3_01_plan_mode_tool_filtering() {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let mut registry = ToolRegistry::new();
|
||||
|
||||
// Info category tools
|
||||
registry.register(Box::new(CategoryMockTool::new("Read", ToolCategory::Info)));
|
||||
registry.register(Box::new(CategoryMockTool::new("Grep", ToolCategory::Info)));
|
||||
registry.register(Box::new(EnterPlanModeTool::new(Arc::clone(&flag))));
|
||||
registry.register(Box::new(ExitPlanModeTool::new(Arc::clone(&flag))));
|
||||
|
||||
// Edit category tool
|
||||
registry.register(Box::new(CategoryMockTool::new("Write", ToolCategory::Edit)));
|
||||
|
||||
// Exec category tool
|
||||
registry.register(Box::new(CategoryMockTool::new("Bash", ToolCategory::Exec)));
|
||||
|
||||
// --- Normal mode: all tools except ExitPlanMode ---
|
||||
let normal_defs = registry.to_tool_defs_filtered(|t| t.name() != "ExitPlanMode");
|
||||
let normal_names: Vec<&str> = normal_defs.iter().map(|d| d.name.as_str()).collect();
|
||||
|
||||
assert!(
|
||||
!normal_names.contains(&"ExitPlanMode"),
|
||||
"ExitPlanMode should be excluded in normal mode"
|
||||
);
|
||||
assert!(
|
||||
normal_names.contains(&"Read"),
|
||||
"Read should be present in normal mode"
|
||||
);
|
||||
assert!(
|
||||
normal_names.contains(&"Grep"),
|
||||
"Grep should be present in normal mode"
|
||||
);
|
||||
assert!(
|
||||
normal_names.contains(&"Write"),
|
||||
"Write should be present in normal mode"
|
||||
);
|
||||
assert!(
|
||||
normal_names.contains(&"Bash"),
|
||||
"Bash should be present in normal mode"
|
||||
);
|
||||
assert!(
|
||||
normal_names.contains(&"EnterPlanMode"),
|
||||
"EnterPlanMode should be present in normal mode"
|
||||
);
|
||||
|
||||
// --- Plan mode: only Info tools, excluding EnterPlanMode ---
|
||||
let plan_defs = registry.to_tool_defs_filtered(|t| {
|
||||
t.category() == ToolCategory::Info && t.name() != "EnterPlanMode"
|
||||
});
|
||||
let plan_names: Vec<&str> = plan_defs.iter().map(|d| d.name.as_str()).collect();
|
||||
|
||||
// Info tools should be present
|
||||
assert!(
|
||||
plan_names.contains(&"Read"),
|
||||
"Read (Info) should be available in plan mode"
|
||||
);
|
||||
assert!(
|
||||
plan_names.contains(&"Grep"),
|
||||
"Grep (Info) should be available in plan mode"
|
||||
);
|
||||
assert!(
|
||||
plan_names.contains(&"ExitPlanMode"),
|
||||
"ExitPlanMode (Info) should be available in plan mode"
|
||||
);
|
||||
|
||||
// EnterPlanMode should be excluded
|
||||
assert!(
|
||||
!plan_names.contains(&"EnterPlanMode"),
|
||||
"EnterPlanMode should be excluded in plan mode"
|
||||
);
|
||||
|
||||
// Edit and Exec tools should be excluded
|
||||
assert!(
|
||||
!plan_names.contains(&"Write"),
|
||||
"Write (Edit) should be excluded in plan mode"
|
||||
);
|
||||
assert!(
|
||||
!plan_names.contains(&"Bash"),
|
||||
"Bash (Exec) should be excluded in plan mode"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-A3-02: Plan Mode system prompt injection (LOCAL, no LLM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_a3_02_plan_mode_system_prompt_injection() {
|
||||
// --- Plan mode active: prompt should contain plan mode instructions ---
|
||||
let active_prompt = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(
|
||||
active_prompt.contains("# Plan Mode"),
|
||||
"active prompt should contain plan mode heading"
|
||||
);
|
||||
assert!(
|
||||
active_prompt.contains("Understand"),
|
||||
"active prompt should mention Phase 1: Understand"
|
||||
);
|
||||
assert!(
|
||||
active_prompt.contains("Design"),
|
||||
"active prompt should mention Phase 2: Design"
|
||||
);
|
||||
assert!(
|
||||
active_prompt.contains("Write the plan"),
|
||||
"active prompt should mention Phase 3: Write the plan"
|
||||
);
|
||||
assert!(
|
||||
active_prompt.contains("Submit for review"),
|
||||
"active prompt should mention Phase 4: Submit for review"
|
||||
);
|
||||
assert!(
|
||||
active_prompt.contains("Forbidden"),
|
||||
"active prompt should mention forbidden actions"
|
||||
);
|
||||
assert!(
|
||||
active_prompt.contains("ExitPlanMode"),
|
||||
"active prompt should reference ExitPlanMode tool"
|
||||
);
|
||||
|
||||
// --- Plan mode inactive: prompt should NOT contain plan mode instructions ---
|
||||
let inactive_prompt = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!inactive_prompt.contains("# Plan Mode"),
|
||||
"inactive prompt should NOT contain plan mode heading"
|
||||
);
|
||||
assert!(
|
||||
!inactive_prompt.contains("Forbidden actions"),
|
||||
"inactive prompt should NOT contain forbidden actions section"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Acceptance test for tool usage guidance in the system prompt (TC-A4-01).
|
||||
//
|
||||
// This is a LOCAL test — no LLM call required.
|
||||
|
||||
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
|
||||
|
||||
/// TC-A4-01: System prompt contains tool guidance.
|
||||
///
|
||||
/// Calls `build_system_prompt` with minimal arguments and verifies that the
|
||||
/// returned prompt includes the tool-usage guidance section with all expected
|
||||
/// content: heading, Bash prohibition mappings, parallel call guidance,
|
||||
/// Edit-over-Write preference, and Read-before-Edit rule.
|
||||
#[test]
|
||||
fn system_prompt_contains_tool_guidance() {
|
||||
let prompt = build_system_prompt(
|
||||
&mut SystemPromptCache::new(),
|
||||
None,
|
||||
"/tmp",
|
||||
"test-model",
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// 1. Heading
|
||||
assert!(
|
||||
prompt.contains("# Using your tools"),
|
||||
"system prompt must contain the '# Using your tools' heading"
|
||||
);
|
||||
|
||||
// 2. Bash prohibition mappings — dedicated tool replacements
|
||||
assert!(
|
||||
prompt.contains("Glob"),
|
||||
"should mention Glob as replacement for find/ls"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Grep"),
|
||||
"should mention Grep as replacement for grep/rg"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Read"),
|
||||
"should mention Read as replacement for cat/head/tail"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Edit"),
|
||||
"should mention Edit as replacement for sed/awk"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Write"),
|
||||
"should mention Write as replacement for echo redirection"
|
||||
);
|
||||
|
||||
// 3. Parallel call guidance
|
||||
assert!(
|
||||
prompt.contains("parallel"),
|
||||
"should contain parallel call guidance"
|
||||
);
|
||||
|
||||
// 4. Edit-over-Write preference
|
||||
assert!(
|
||||
prompt.contains("Prefer Edit over Write"),
|
||||
"should contain Edit-over-Write preference"
|
||||
);
|
||||
|
||||
// 5. Read-before-Edit rule
|
||||
assert!(
|
||||
prompt.contains("Read a file before editing"),
|
||||
"should contain Read-before-Edit rule"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user