Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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"
);
}