Add /project and /unproject commands for project-specific context
- Add Project struct in crates/g3-cli/src/project.rs with file loading logic - Load brief.md, contacts.yaml, status.md from project path - Load projects.md from workspace root for cross-project context - Project content appended to system message (survives compaction/dehydration) - /project <path> loads project and auto-submits prompt asking about state - /unproject clears project content and resets context - Add set_project_content(), clear_project_content(), has_project_content() to Agent - Add new_for_test_with_readme() for testing with custom README content - Add 6 unit tests for Project struct - Add 9 integration tests for project context behavior
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rustyline::Editor;
|
use rustyline::Editor;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use crossterm::style::{Color, SetForegroundColor, ResetColor};
|
||||||
|
|
||||||
use g3_core::ui_writer::UiWriter;
|
use g3_core::ui_writer::UiWriter;
|
||||||
use g3_core::Agent;
|
use g3_core::Agent;
|
||||||
@@ -11,6 +13,7 @@ use g3_core::Agent;
|
|||||||
use crate::completion::G3Helper;
|
use crate::completion::G3Helper;
|
||||||
use crate::g3_status::{G3Status, Status};
|
use crate::g3_status::{G3Status, Status};
|
||||||
use crate::simple_output::SimpleOutput;
|
use crate::simple_output::SimpleOutput;
|
||||||
|
use crate::project::Project;
|
||||||
use crate::template::process_template;
|
use crate::template::process_template;
|
||||||
use crate::task_execution::execute_task_with_retry;
|
use crate::task_execution::execute_task_with_retry;
|
||||||
|
|
||||||
@@ -18,7 +21,9 @@ use crate::task_execution::execute_task_with_retry;
|
|||||||
pub async fn handle_command<W: UiWriter>(
|
pub async fn handle_command<W: UiWriter>(
|
||||||
input: &str,
|
input: &str,
|
||||||
agent: &mut Agent<W>,
|
agent: &mut Agent<W>,
|
||||||
|
workspace_dir: &std::path::Path,
|
||||||
output: &SimpleOutput,
|
output: &SimpleOutput,
|
||||||
|
active_project: &mut Option<Project>,
|
||||||
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
|
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
|
||||||
show_prompt: bool,
|
show_prompt: bool,
|
||||||
show_code: bool,
|
show_code: bool,
|
||||||
@@ -34,6 +39,8 @@ pub async fn handle_command<W: UiWriter>(
|
|||||||
output.print(" /fragments - List dehydrated context fragments (ACD)");
|
output.print(" /fragments - List dehydrated context fragments (ACD)");
|
||||||
output.print(" /rehydrate - Restore a dehydrated fragment by ID");
|
output.print(" /rehydrate - Restore a dehydrated fragment by ID");
|
||||||
output.print(" /resume - List and switch to a previous session");
|
output.print(" /resume - List and switch to a previous session");
|
||||||
|
output.print(" /project <path> - Load a project from the given absolute path");
|
||||||
|
output.print(" /unproject - Unload the current project and reset context");
|
||||||
output.print(" /dump - Dump entire context window to file for debugging");
|
output.print(" /dump - Dump entire context window to file for debugging");
|
||||||
output.print(" /readme - Reload README.md and AGENTS.md from disk");
|
output.print(" /readme - Reload README.md and AGENTS.md from disk");
|
||||||
output.print(" /stats - Show detailed context and performance statistics");
|
output.print(" /stats - Show detailed context and performance statistics");
|
||||||
@@ -317,6 +324,77 @@ pub async fn handle_command<W: UiWriter>(
|
|||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
cmd if cmd.starts_with("/project") => {
|
||||||
|
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||||
|
if parts.len() < 2 || parts[1].trim().is_empty() {
|
||||||
|
output.print("Usage: /project <absolute-path>");
|
||||||
|
output.print("Loads project files (brief.md, contacts.yaml, status.md) from the given path.");
|
||||||
|
} else {
|
||||||
|
let project_path_str = parts[1].trim();
|
||||||
|
|
||||||
|
// Expand tilde if present
|
||||||
|
let project_path = if project_path_str.starts_with("~/") {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
home.join(&project_path_str[2..])
|
||||||
|
} else {
|
||||||
|
PathBuf::from(project_path_str)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PathBuf::from(project_path_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate path is absolute
|
||||||
|
if !project_path.is_absolute() {
|
||||||
|
output.print("❌ Project path must be absolute (e.g., /Users/name/projects/myproject)");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path exists
|
||||||
|
if !project_path.exists() {
|
||||||
|
output.print(&format!("❌ Project path does not exist: {}", project_path.display()));
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the project
|
||||||
|
match Project::load(&project_path, workspace_dir) {
|
||||||
|
Some(project) => {
|
||||||
|
// Set project content in agent's system message
|
||||||
|
if agent.set_project_content(Some(project.content.clone())) {
|
||||||
|
// Print loaded status
|
||||||
|
print!(
|
||||||
|
"{}Project loaded:{} {}\n",
|
||||||
|
SetForegroundColor(Color::Green),
|
||||||
|
ResetColor,
|
||||||
|
project.format_loaded_status()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store active project
|
||||||
|
*active_project = Some(project);
|
||||||
|
|
||||||
|
// Auto-submit the project status prompt
|
||||||
|
let prompt = "what is the current state of the project? and what is your suggested next best step?";
|
||||||
|
execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await;
|
||||||
|
} else {
|
||||||
|
output.print("❌ Failed to set project content in agent context.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
output.print("❌ No project files found (brief.md, contacts.yaml, status.md).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
"/unproject" => {
|
||||||
|
if active_project.is_some() {
|
||||||
|
agent.clear_project_content();
|
||||||
|
*active_project = None;
|
||||||
|
output.print("✅ Project unloaded. Context reset to original system message.");
|
||||||
|
} else {
|
||||||
|
output.print("No project is currently loaded.");
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
output.print(&format!(
|
output.print(&format!(
|
||||||
"❌ Unknown command: {}. Type /help for available commands.",
|
"❌ Unknown command: {}. Type /help for available commands.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use g3_core::Agent;
|
|||||||
use crate::commands::handle_command;
|
use crate::commands::handle_command;
|
||||||
use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path};
|
use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path};
|
||||||
use crate::g3_status::{G3Status, Status};
|
use crate::g3_status::{G3Status, Status};
|
||||||
|
use crate::project::Project;
|
||||||
use crate::project_files::extract_readme_heading;
|
use crate::project_files::extract_readme_heading;
|
||||||
use crate::simple_output::SimpleOutput;
|
use crate::simple_output::SimpleOutput;
|
||||||
use crate::task_execution::execute_task_with_retry;
|
use crate::task_execution::execute_task_with_retry;
|
||||||
@@ -142,6 +143,9 @@ pub async fn run_interactive<W: UiWriter>(
|
|||||||
let mut multiline_buffer = String::new();
|
let mut multiline_buffer = String::new();
|
||||||
let mut in_multiline = false;
|
let mut in_multiline = false;
|
||||||
|
|
||||||
|
// Track active project
|
||||||
|
let mut active_project: Option<Project> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Display context window progress bar before each prompt
|
// Display context window progress bar before each prompt
|
||||||
display_context_progress(&agent, &output);
|
display_context_progress(&agent, &output);
|
||||||
@@ -222,7 +226,7 @@ pub async fn run_interactive<W: UiWriter>(
|
|||||||
|
|
||||||
// Check for control commands
|
// Check for control commands
|
||||||
if input.starts_with('/') {
|
if input.starts_with('/') {
|
||||||
if handle_command(&input, &mut agent, &output, &mut rl, show_prompt, show_code).await? {
|
if handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod utils;
|
|||||||
mod g3_status;
|
mod g3_status;
|
||||||
mod template;
|
mod template;
|
||||||
mod completion;
|
mod completion;
|
||||||
|
mod project;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
194
crates/g3-cli/src/project.rs
Normal file
194
crates/g3-cli/src/project.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//! Project loading and management for the /project command.
|
||||||
|
//!
|
||||||
|
//! Projects allow loading context from a specific project directory that persists
|
||||||
|
//! in the system message and survives compaction/dehydration.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Represents an active project with its loaded content.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Project {
|
||||||
|
/// Absolute path to the project directory
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Combined content blob to append to system message
|
||||||
|
pub content: String,
|
||||||
|
/// List of files that were successfully loaded
|
||||||
|
pub loaded_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Project {
|
||||||
|
/// Load a project from the given absolute path.
|
||||||
|
///
|
||||||
|
/// Loads the following files if present (skips missing silently):
|
||||||
|
/// - brief.md
|
||||||
|
/// - contacts.yaml
|
||||||
|
/// - status.md
|
||||||
|
///
|
||||||
|
/// Also loads projects.md from the workspace root if present.
|
||||||
|
pub fn load(project_path: &Path, workspace_dir: &Path) -> Option<Self> {
|
||||||
|
let mut content_parts = Vec::new();
|
||||||
|
let mut loaded_files = Vec::new();
|
||||||
|
|
||||||
|
// Load workspace-level projects.md if present
|
||||||
|
let projects_md_path = workspace_dir.join("projects.md");
|
||||||
|
if projects_md_path.exists() {
|
||||||
|
if let Ok(projects_content) = std::fs::read_to_string(&projects_md_path) {
|
||||||
|
content_parts.push(format!(
|
||||||
|
"=== PROJECT INSTRUCTIONS ===\n{}\n=== END PROJECT INSTRUCTIONS ===",
|
||||||
|
projects_content.trim()
|
||||||
|
));
|
||||||
|
loaded_files.push("projects.md".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project-specific files
|
||||||
|
let project_files = ["brief.md", "contacts.yaml", "status.md"];
|
||||||
|
let mut project_content_parts = Vec::new();
|
||||||
|
|
||||||
|
for filename in &project_files {
|
||||||
|
let file_path = project_path.join(filename);
|
||||||
|
if file_path.exists() {
|
||||||
|
if let Ok(file_content) = std::fs::read_to_string(&file_path) {
|
||||||
|
let section_name = match *filename {
|
||||||
|
"brief.md" => "Brief",
|
||||||
|
"contacts.yaml" => "Contacts",
|
||||||
|
"status.md" => "Status",
|
||||||
|
_ => filename,
|
||||||
|
};
|
||||||
|
project_content_parts.push(format!(
|
||||||
|
"## {}\n{}",
|
||||||
|
section_name,
|
||||||
|
file_content.trim()
|
||||||
|
));
|
||||||
|
loaded_files.push(filename.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we loaded any project-specific files, add the active project header
|
||||||
|
if !project_content_parts.is_empty() {
|
||||||
|
content_parts.push(format!(
|
||||||
|
"=== ACTIVE PROJECT: {} ===\n{}",
|
||||||
|
project_path.display(),
|
||||||
|
project_content_parts.join("\n\n")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return a project if we loaded something
|
||||||
|
if loaded_files.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Project {
|
||||||
|
path: project_path.to_path_buf(),
|
||||||
|
content: content_parts.join("\n\n"),
|
||||||
|
loaded_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the loaded files status message (e.g., "✓ brief.md ✓ status.md")
|
||||||
|
pub fn format_loaded_status(&self) -> String {
|
||||||
|
self.loaded_files
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("✓ {}", f))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_loaded_status() {
|
||||||
|
let project = Project {
|
||||||
|
path: PathBuf::from("/test/project"),
|
||||||
|
content: String::new(),
|
||||||
|
loaded_files: vec!["brief.md".to_string(), "status.md".to_string()],
|
||||||
|
};
|
||||||
|
assert_eq!(project.format_loaded_status(), "✓ brief.md ✓ status.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_loaded_status_single_file() {
|
||||||
|
let project = Project {
|
||||||
|
path: PathBuf::from("/test/project"),
|
||||||
|
content: String::new(),
|
||||||
|
loaded_files: vec!["brief.md".to_string()],
|
||||||
|
};
|
||||||
|
assert_eq!(project.format_loaded_status(), "✓ brief.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_project_with_all_files() {
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let project_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create project files
|
||||||
|
fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap();
|
||||||
|
fs::write(project_dir.path().join("contacts.yaml"), "contacts: []").unwrap();
|
||||||
|
fs::write(project_dir.path().join("status.md"), "In progress").unwrap();
|
||||||
|
|
||||||
|
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(project.loaded_files.len(), 3);
|
||||||
|
assert!(project.loaded_files.contains(&"brief.md".to_string()));
|
||||||
|
assert!(project.loaded_files.contains(&"contacts.yaml".to_string()));
|
||||||
|
assert!(project.loaded_files.contains(&"status.md".to_string()));
|
||||||
|
assert!(project.content.contains("=== ACTIVE PROJECT:"));
|
||||||
|
assert!(project.content.contains("## Brief"));
|
||||||
|
assert!(project.content.contains("## Contacts"));
|
||||||
|
assert!(project.content.contains("## Status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_project_with_workspace_projects_md() {
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let project_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create workspace projects.md
|
||||||
|
fs::write(workspace.path().join("projects.md"), "Global project instructions").unwrap();
|
||||||
|
|
||||||
|
// Create one project file
|
||||||
|
fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap();
|
||||||
|
|
||||||
|
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(project.loaded_files.len(), 2);
|
||||||
|
assert!(project.loaded_files.contains(&"projects.md".to_string()));
|
||||||
|
assert!(project.loaded_files.contains(&"brief.md".to_string()));
|
||||||
|
assert!(project.content.contains("=== PROJECT INSTRUCTIONS ==="));
|
||||||
|
assert!(project.content.contains("=== END PROJECT INSTRUCTIONS ==="));
|
||||||
|
assert!(project.content.contains("=== ACTIVE PROJECT:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_project_missing_files() {
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let project_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create only one file
|
||||||
|
fs::write(project_dir.path().join("status.md"), "Status only").unwrap();
|
||||||
|
|
||||||
|
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(project.loaded_files.len(), 1);
|
||||||
|
assert!(project.loaded_files.contains(&"status.md".to_string()));
|
||||||
|
assert!(!project.content.contains("## Brief"));
|
||||||
|
assert!(project.content.contains("## Status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_project_no_files() {
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let project_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// No files created
|
||||||
|
let project = Project::load(project_dir.path(), workspace.path());
|
||||||
|
|
||||||
|
assert!(project.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,6 +238,67 @@ impl<W: UiWriter> Agent<W> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new agent for testing with README content.
|
||||||
|
/// This allows tests to verify context window structure with combined content.
|
||||||
|
pub async fn new_for_test_with_readme(
|
||||||
|
config: Config,
|
||||||
|
ui_writer: W,
|
||||||
|
providers: ProviderRegistry,
|
||||||
|
readme_content: Option<String>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
use crate::context_window::ContextWindow;
|
||||||
|
use crate::prompts::get_system_prompt_for_native;
|
||||||
|
use g3_providers::{Message, MessageRole};
|
||||||
|
|
||||||
|
let context_length = config.agent.max_context_length.unwrap_or(200_000);
|
||||||
|
let mut context_window = ContextWindow::new(context_length);
|
||||||
|
|
||||||
|
// Add system prompt
|
||||||
|
let system_prompt = get_system_prompt_for_native();
|
||||||
|
let system_message = Message::new(MessageRole::System, system_prompt);
|
||||||
|
context_window.add_message(system_message);
|
||||||
|
|
||||||
|
// Add README content if provided
|
||||||
|
if let Some(readme) = readme_content {
|
||||||
|
let readme_message = Message::new(MessageRole::System, readme);
|
||||||
|
context_window.add_message(readme_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
providers,
|
||||||
|
context_window,
|
||||||
|
auto_compact: false,
|
||||||
|
pending_90_compaction: false,
|
||||||
|
thinning_events: Vec::new(),
|
||||||
|
compaction_events: Vec::new(),
|
||||||
|
first_token_times: Vec::new(),
|
||||||
|
config,
|
||||||
|
session_id: None,
|
||||||
|
tool_call_metrics: Vec::new(),
|
||||||
|
ui_writer,
|
||||||
|
todo_content: std::sync::Arc::new(tokio::sync::RwLock::new(String::new())),
|
||||||
|
is_autonomous: false,
|
||||||
|
quiet: true,
|
||||||
|
computer_controller: None,
|
||||||
|
webdriver_session: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
webdriver_process: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
tool_call_count: 0,
|
||||||
|
tool_calls_this_turn: Vec::new(),
|
||||||
|
requirements_sha: None,
|
||||||
|
working_dir: None,
|
||||||
|
background_process_manager: std::sync::Arc::new(
|
||||||
|
background_process::BackgroundProcessManager::new(
|
||||||
|
paths::get_background_processes_dir(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pending_images: Vec::new(),
|
||||||
|
is_agent_mode: false,
|
||||||
|
agent_name: None,
|
||||||
|
auto_memory: false,
|
||||||
|
acd_enabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn new_with_mode(
|
async fn new_with_mode(
|
||||||
config: Config,
|
config: Config,
|
||||||
ui_writer: W,
|
ui_writer: W,
|
||||||
@@ -1285,6 +1346,52 @@ impl<W: UiWriter> Agent<W> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set or clear project content in the system message.
|
||||||
|
/// Project content is appended to the second system message (README/AGENTS content)
|
||||||
|
/// so it survives compaction and dehydration.
|
||||||
|
///
|
||||||
|
/// Pass `Some(content)` to set project content, `None` to clear it.
|
||||||
|
/// Returns true if the operation succeeded.
|
||||||
|
pub fn set_project_content(&mut self, content: Option<String>) -> bool {
|
||||||
|
// The second message (index 1) should be the README/AGENTS system message
|
||||||
|
if self.context_window.conversation_history.len() < 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let second_msg = &mut self.context_window.conversation_history[1];
|
||||||
|
if !matches!(second_msg.role, MessageRole::System) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing project content first
|
||||||
|
if let Some(start_idx) = second_msg.content.find("\n\n=== PROJECT INSTRUCTIONS ===") {
|
||||||
|
second_msg.content.truncate(start_idx);
|
||||||
|
} else if let Some(start_idx) = second_msg.content.find("\n\n=== ACTIVE PROJECT:") {
|
||||||
|
second_msg.content.truncate(start_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new project content if provided
|
||||||
|
if let Some(project_content) = content {
|
||||||
|
second_msg.content.push_str("\n\n");
|
||||||
|
second_msg.content.push_str(&project_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear project content from the system message.
|
||||||
|
/// This is equivalent to calling `set_project_content(None)`.
|
||||||
|
pub fn clear_project_content(&mut self) -> bool {
|
||||||
|
self.set_project_content(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if there is currently project content loaded.
|
||||||
|
pub fn has_project_content(&self) -> bool {
|
||||||
|
self.context_window.conversation_history.get(1)
|
||||||
|
.map(|m| m.content.contains("=== ACTIVE PROJECT:"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get detailed context statistics
|
/// Get detailed context statistics
|
||||||
pub fn get_stats(&self) -> String {
|
pub fn get_stats(&self) -> String {
|
||||||
use crate::stats::AgentStatsSnapshot;
|
use crate::stats::AgentStatsSnapshot;
|
||||||
|
|||||||
324
crates/g3-core/tests/project_context_test.rs
Normal file
324
crates/g3-core/tests/project_context_test.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
//! Integration tests for project context loading and ordering.
|
||||||
|
//!
|
||||||
|
//! Tests that the context window has the correct structure when projects are loaded.
|
||||||
|
|
||||||
|
use g3_core::{
|
||||||
|
ui_writer::NullUiWriter,
|
||||||
|
Agent,
|
||||||
|
};
|
||||||
|
use g3_config::Config;
|
||||||
|
use g3_providers::{mock::MockProvider, ProviderRegistry, MockResponse};
|
||||||
|
|
||||||
|
/// Helper to create a test agent with mock provider
|
||||||
|
async fn create_test_agent(readme_content: Option<String>) -> Agent<NullUiWriter> {
|
||||||
|
let config = Config::default();
|
||||||
|
let provider = MockProvider::new()
|
||||||
|
.with_response(MockResponse::text("Test response"));
|
||||||
|
|
||||||
|
let mut registry = ProviderRegistry::new();
|
||||||
|
registry.register(provider);
|
||||||
|
|
||||||
|
Agent::new_for_test_with_readme(config, NullUiWriter, registry, readme_content)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_context_window_initial_structure() {
|
||||||
|
// Create agent with README content
|
||||||
|
let readme = "📂 Working Directory: /test/workspace\n\n\
|
||||||
|
🤖 Agent Configuration (from AGENTS.md):\nTest agent config\n\n\
|
||||||
|
📚 Project README (from README.md):\n# Test Project\nA test project.".to_string();
|
||||||
|
|
||||||
|
let agent = create_test_agent(Some(readme)).await;
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
|
||||||
|
// Should have exactly 2 messages: system prompt + README
|
||||||
|
assert_eq!(context.conversation_history.len(), 2,
|
||||||
|
"Expected 2 messages (system + README), got {}", context.conversation_history.len());
|
||||||
|
|
||||||
|
// First message should be system prompt
|
||||||
|
let system_msg = &context.conversation_history[0];
|
||||||
|
assert!(system_msg.content.contains("IMPORTANT: You must call tools to achieve goals"),
|
||||||
|
"First message should be system prompt with tool instructions");
|
||||||
|
|
||||||
|
// Second message should be README content
|
||||||
|
let readme_msg = &context.conversation_history[1];
|
||||||
|
assert!(readme_msg.content.contains("📂 Working Directory:"),
|
||||||
|
"Second message should start with working directory");
|
||||||
|
assert!(readme_msg.content.contains("🤖 Agent Configuration"),
|
||||||
|
"Second message should contain AGENTS.md");
|
||||||
|
assert!(readme_msg.content.contains("📚 Project README"),
|
||||||
|
"Second message should contain README");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_context_window_order_agents_before_readme() {
|
||||||
|
let readme = "📂 Working Directory: /test\n\n\
|
||||||
|
🤖 Agent Configuration (from AGENTS.md):\nAgent stuff\n\n\
|
||||||
|
📚 Project README (from README.md):\nReadme stuff".to_string();
|
||||||
|
|
||||||
|
let agent = create_test_agent(Some(readme)).await;
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
let content = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
let cwd_pos = content.find("📂 Working Directory").expect("CWD not found");
|
||||||
|
let agents_pos = content.find("🤖 Agent Configuration").expect("AGENTS not found");
|
||||||
|
let readme_pos = content.find("📚 Project README").expect("README not found");
|
||||||
|
|
||||||
|
assert!(cwd_pos < agents_pos, "Working directory should come before AGENTS.md");
|
||||||
|
assert!(agents_pos < readme_pos, "AGENTS.md should come before README");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_project_content_appends_to_readme() {
|
||||||
|
let readme = "📂 Working Directory: /test\n\n\
|
||||||
|
📚 Project README (from README.md):\n# Test".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Set project content
|
||||||
|
let project_content = "=== PROJECT INSTRUCTIONS ===\nGlobal instructions\n=== END PROJECT INSTRUCTIONS ===\n\n\
|
||||||
|
=== ACTIVE PROJECT: /projects/myproject ===\n\
|
||||||
|
## Brief\nProject brief here\n\n\
|
||||||
|
## Status\nIn progress".to_string();
|
||||||
|
|
||||||
|
let success = agent.set_project_content(Some(project_content));
|
||||||
|
assert!(success, "set_project_content should succeed");
|
||||||
|
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
let content = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
// Verify project content was appended
|
||||||
|
assert!(content.contains("=== PROJECT INSTRUCTIONS ==="),
|
||||||
|
"Should contain PROJECT INSTRUCTIONS marker");
|
||||||
|
assert!(content.contains("=== END PROJECT INSTRUCTIONS ==="),
|
||||||
|
"Should contain END PROJECT INSTRUCTIONS marker");
|
||||||
|
assert!(content.contains("=== ACTIVE PROJECT: /projects/myproject ==="),
|
||||||
|
"Should contain ACTIVE PROJECT marker");
|
||||||
|
assert!(content.contains("## Brief"),
|
||||||
|
"Should contain Brief section");
|
||||||
|
assert!(content.contains("## Status"),
|
||||||
|
"Should contain Status section");
|
||||||
|
|
||||||
|
// Verify order: README content comes before project content
|
||||||
|
let readme_pos = content.find("📚 Project README").expect("README not found");
|
||||||
|
let project_instructions_pos = content.find("=== PROJECT INSTRUCTIONS ===").expect("PROJECT INSTRUCTIONS not found");
|
||||||
|
let active_project_pos = content.find("=== ACTIVE PROJECT:").expect("ACTIVE PROJECT not found");
|
||||||
|
|
||||||
|
assert!(readme_pos < project_instructions_pos,
|
||||||
|
"README should come before PROJECT INSTRUCTIONS");
|
||||||
|
assert!(project_instructions_pos < active_project_pos,
|
||||||
|
"PROJECT INSTRUCTIONS should come before ACTIVE PROJECT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_project_content_without_instructions() {
|
||||||
|
let readme = "📂 Working Directory: /test\n\n📚 Project README:\n# Test".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Set project content without PROJECT INSTRUCTIONS (no projects.md in workspace)
|
||||||
|
let project_content = "=== ACTIVE PROJECT: /projects/myproject ===\n\
|
||||||
|
## Brief\nProject brief\n\n\
|
||||||
|
## Contacts\ncontacts: []\n\n\
|
||||||
|
## Status\nDone".to_string();
|
||||||
|
|
||||||
|
agent.set_project_content(Some(project_content));
|
||||||
|
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
let content = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
// Should NOT contain PROJECT INSTRUCTIONS
|
||||||
|
assert!(!content.contains("=== PROJECT INSTRUCTIONS ==="),
|
||||||
|
"Should NOT contain PROJECT INSTRUCTIONS when not provided");
|
||||||
|
|
||||||
|
// Should contain ACTIVE PROJECT
|
||||||
|
assert!(content.contains("=== ACTIVE PROJECT: /projects/myproject ==="),
|
||||||
|
"Should contain ACTIVE PROJECT marker");
|
||||||
|
assert!(content.contains("## Brief"),
|
||||||
|
"Should contain Brief section");
|
||||||
|
assert!(content.contains("## Contacts"),
|
||||||
|
"Should contain Contacts section");
|
||||||
|
assert!(content.contains("## Status"),
|
||||||
|
"Should contain Status section");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_clear_project_content() {
|
||||||
|
let readme = "📂 Working Directory: /test\n\n📚 Project README:\n# Test".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Set project content
|
||||||
|
let project_content = "=== ACTIVE PROJECT: /projects/test ===\n## Brief\nTest".to_string();
|
||||||
|
agent.set_project_content(Some(project_content));
|
||||||
|
|
||||||
|
// Verify it was set
|
||||||
|
assert!(agent.has_project_content(), "Project should be loaded");
|
||||||
|
|
||||||
|
// Clear project content
|
||||||
|
let success = agent.clear_project_content();
|
||||||
|
assert!(success, "clear_project_content should succeed");
|
||||||
|
|
||||||
|
// Verify it was cleared
|
||||||
|
assert!(!agent.has_project_content(), "Project should be unloaded");
|
||||||
|
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
let content = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
assert!(!content.contains("=== ACTIVE PROJECT:"),
|
||||||
|
"Should NOT contain ACTIVE PROJECT after clearing");
|
||||||
|
assert!(content.contains("📚 Project README"),
|
||||||
|
"Should still contain README after clearing project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_project_content_replaces_existing() {
|
||||||
|
let readme = "📂 Working Directory: /test\n\n📚 Project README:\n# Test".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Set first project
|
||||||
|
let project1 = "=== ACTIVE PROJECT: /projects/first ===\n## Brief\nFirst project".to_string();
|
||||||
|
agent.set_project_content(Some(project1));
|
||||||
|
|
||||||
|
// Set second project (should replace first)
|
||||||
|
let project2 = "=== ACTIVE PROJECT: /projects/second ===\n## Brief\nSecond project".to_string();
|
||||||
|
agent.set_project_content(Some(project2));
|
||||||
|
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
let content = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
// Should only contain second project
|
||||||
|
assert!(!content.contains("/projects/first"),
|
||||||
|
"Should NOT contain first project after replacement");
|
||||||
|
assert!(content.contains("/projects/second"),
|
||||||
|
"Should contain second project");
|
||||||
|
assert!(content.contains("Second project"),
|
||||||
|
"Should contain second project content");
|
||||||
|
|
||||||
|
// Should only have one ACTIVE PROJECT marker
|
||||||
|
let count = content.matches("=== ACTIVE PROJECT:").count();
|
||||||
|
assert_eq!(count, 1, "Should have exactly one ACTIVE PROJECT marker, got {}", count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_project_content_with_memory() {
|
||||||
|
// Simulate full content with memory at the end
|
||||||
|
let readme = "📂 Working Directory: /test\n\n\
|
||||||
|
🤖 Agent Configuration (from AGENTS.md):\nAgent config\n\n\
|
||||||
|
📚 Project README (from README.md):\n# Test\n\n\
|
||||||
|
=== Workspace Memory (read from analysis/memory.md, 1.2k chars) ===\n\
|
||||||
|
### Known Features\n- details\n\
|
||||||
|
=== End Workspace Memory ===".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Set project content
|
||||||
|
let project_content = "=== ACTIVE PROJECT: /projects/test ===\n## Brief\nTest brief".to_string();
|
||||||
|
agent.set_project_content(Some(project_content));
|
||||||
|
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
let content = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
// Verify all sections are present
|
||||||
|
assert!(content.contains("📂 Working Directory"), "Should have CWD");
|
||||||
|
assert!(content.contains("🤖 Agent Configuration"), "Should have AGENTS");
|
||||||
|
assert!(content.contains("📚 Project README"), "Should have README");
|
||||||
|
assert!(content.contains("=== Workspace Memory"), "Should have Memory");
|
||||||
|
assert!(content.contains("=== ACTIVE PROJECT:"), "Should have Project");
|
||||||
|
|
||||||
|
// Verify order: Memory should come before Project (since project is appended at the end)
|
||||||
|
let memory_pos = content.find("=== Workspace Memory").expect("Memory not found");
|
||||||
|
let project_pos = content.find("=== ACTIVE PROJECT:").expect("Project not found");
|
||||||
|
|
||||||
|
assert!(memory_pos < project_pos,
|
||||||
|
"Memory should come before Project (project is appended to existing content)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_has_project_content() {
|
||||||
|
let readme = "📂 Working Directory: /test\n\n📚 Project README:\n# Test".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Initially no project
|
||||||
|
assert!(!agent.has_project_content(), "Should not have project initially");
|
||||||
|
|
||||||
|
// After setting project
|
||||||
|
let project = "=== ACTIVE PROJECT: /test ===\n## Brief\nTest".to_string();
|
||||||
|
agent.set_project_content(Some(project));
|
||||||
|
assert!(agent.has_project_content(), "Should have project after setting");
|
||||||
|
|
||||||
|
// After clearing
|
||||||
|
agent.clear_project_content();
|
||||||
|
assert!(!agent.has_project_content(), "Should not have project after clearing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_full_context_order() {
|
||||||
|
// This test verifies the complete expected order of context window content
|
||||||
|
let readme = "📂 Working Directory: /workspace\n\n\
|
||||||
|
🤖 Agent Configuration (from AGENTS.md):\n## Agent Rules\nBe helpful\n\n\
|
||||||
|
📚 Project README (from README.md):\n# My Project\nDescription here\n\n\
|
||||||
|
🔧 Language-Specific Guidance:\n## Rust\nUse cargo\n\n\
|
||||||
|
📎 Included Prompt (from prompt.md):\nCustom instructions\n\n\
|
||||||
|
=== Workspace Memory (read from analysis/memory.md, 500 chars) ===\n\
|
||||||
|
### Known Features\n- Feature A\n\
|
||||||
|
=== End Workspace Memory ===".to_string();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(Some(readme)).await;
|
||||||
|
|
||||||
|
// Add project content
|
||||||
|
let project = "=== PROJECT INSTRUCTIONS ===\n\
|
||||||
|
Global project rules\n\
|
||||||
|
=== END PROJECT INSTRUCTIONS ===\n\n\
|
||||||
|
=== ACTIVE PROJECT: /projects/current ===\n\
|
||||||
|
## Brief\nCurrent project brief\n\n\
|
||||||
|
## Contacts\nname: John\n\n\
|
||||||
|
## Status\nIn progress".to_string();
|
||||||
|
agent.set_project_content(Some(project));
|
||||||
|
|
||||||
|
let context = agent.get_context_window();
|
||||||
|
|
||||||
|
// Message 0: System prompt
|
||||||
|
let system = &context.conversation_history[0].content;
|
||||||
|
assert!(system.contains("IMPORTANT: You must call tools"),
|
||||||
|
"Message 0 should be system prompt");
|
||||||
|
|
||||||
|
// Message 1: Combined content with project appended
|
||||||
|
let combined = &context.conversation_history[1].content;
|
||||||
|
|
||||||
|
// Get positions of all sections
|
||||||
|
let cwd_pos = combined.find("📂 Working Directory").expect("CWD missing");
|
||||||
|
let agents_pos = combined.find("🤖 Agent Configuration").expect("AGENTS missing");
|
||||||
|
let readme_pos = combined.find("📚 Project README").expect("README missing");
|
||||||
|
let lang_pos = combined.find("🔧 Language-Specific").expect("Language missing");
|
||||||
|
let include_pos = combined.find("📎 Included Prompt").expect("Include missing");
|
||||||
|
let memory_pos = combined.find("=== Workspace Memory").expect("Memory missing");
|
||||||
|
let proj_instr_pos = combined.find("=== PROJECT INSTRUCTIONS ===").expect("Project instructions missing");
|
||||||
|
let active_proj_pos = combined.find("=== ACTIVE PROJECT:").expect("Active project missing");
|
||||||
|
|
||||||
|
// Verify complete order
|
||||||
|
assert!(cwd_pos < agents_pos, "CWD < AGENTS");
|
||||||
|
assert!(agents_pos < readme_pos, "AGENTS < README");
|
||||||
|
assert!(readme_pos < lang_pos, "README < Language");
|
||||||
|
assert!(lang_pos < include_pos, "Language < Include");
|
||||||
|
assert!(include_pos < memory_pos, "Include < Memory");
|
||||||
|
assert!(memory_pos < proj_instr_pos, "Memory < Project Instructions");
|
||||||
|
assert!(proj_instr_pos < active_proj_pos, "Project Instructions < Active Project");
|
||||||
|
|
||||||
|
// Verify project sections order
|
||||||
|
let brief_pos = combined.find("## Brief").expect("Brief missing");
|
||||||
|
let contacts_pos = combined.find("## Contacts").expect("Contacts missing");
|
||||||
|
let status_pos = combined.find("## Status").expect("Status missing");
|
||||||
|
|
||||||
|
assert!(active_proj_pos < brief_pos, "Active Project < Brief");
|
||||||
|
assert!(brief_pos < contacts_pos, "Brief < Contacts");
|
||||||
|
assert!(contacts_pos < status_pos, "Contacts < Status");
|
||||||
|
|
||||||
|
// Verify NO closing marker for ACTIVE PROJECT
|
||||||
|
assert!(!combined.contains("=== END ACTIVE PROJECT ==="),
|
||||||
|
"Should NOT have END ACTIVE PROJECT marker");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user