From feb7c3e40d0b5e890c8846259d3549d6edca65f3 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Wed, 21 Jan 2026 14:53:30 +0530 Subject: [PATCH] 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 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 --- crates/g3-cli/src/commands.rs | 78 +++++ crates/g3-cli/src/interactive.rs | 6 +- crates/g3-cli/src/lib.rs | 1 + crates/g3-cli/src/project.rs | 194 +++++++++++ crates/g3-core/src/lib.rs | 107 ++++++ crates/g3-core/tests/project_context_test.rs | 324 +++++++++++++++++++ 6 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 crates/g3-cli/src/project.rs create mode 100644 crates/g3-core/tests/project_context_test.rs diff --git a/crates/g3-cli/src/commands.rs b/crates/g3-cli/src/commands.rs index 650026f..6033627 100644 --- a/crates/g3-cli/src/commands.rs +++ b/crates/g3-cli/src/commands.rs @@ -4,6 +4,8 @@ use anyhow::Result; use rustyline::Editor; +use std::path::PathBuf; +use crossterm::style::{Color, SetForegroundColor, ResetColor}; use g3_core::ui_writer::UiWriter; use g3_core::Agent; @@ -11,6 +13,7 @@ use g3_core::Agent; use crate::completion::G3Helper; use crate::g3_status::{G3Status, Status}; use crate::simple_output::SimpleOutput; +use crate::project::Project; use crate::template::process_template; 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( input: &str, agent: &mut Agent, + workspace_dir: &std::path::Path, output: &SimpleOutput, + active_project: &mut Option, rl: &mut Editor, show_prompt: bool, show_code: bool, @@ -34,6 +39,8 @@ pub async fn handle_command( output.print(" /fragments - List dehydrated context fragments (ACD)"); output.print(" /rehydrate - Restore a dehydrated fragment by ID"); output.print(" /resume - List and switch to a previous session"); + output.print(" /project - 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(" /readme - Reload README.md and AGENTS.md from disk"); output.print(" /stats - Show detailed context and performance statistics"); @@ -317,6 +324,77 @@ pub async fn handle_command( } 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 "); + 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!( "❌ Unknown command: {}. Type /help for available commands.", diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index 33bebd3..2364fef 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -14,6 +14,7 @@ use g3_core::Agent; use crate::commands::handle_command; use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path}; use crate::g3_status::{G3Status, Status}; +use crate::project::Project; use crate::project_files::extract_readme_heading; use crate::simple_output::SimpleOutput; use crate::task_execution::execute_task_with_retry; @@ -142,6 +143,9 @@ pub async fn run_interactive( let mut multiline_buffer = String::new(); let mut in_multiline = false; + // Track active project + let mut active_project: Option = None; + loop { // Display context window progress bar before each prompt display_context_progress(&agent, &output); @@ -222,7 +226,7 @@ pub async fn run_interactive( // Check for control commands 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; } } diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 156f519..0e428b2 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -22,6 +22,7 @@ mod utils; mod g3_status; mod template; mod completion; +mod project; use anyhow::Result; use std::path::PathBuf; diff --git a/crates/g3-cli/src/project.rs b/crates/g3-cli/src/project.rs new file mode 100644 index 0000000..b75a9c9 --- /dev/null +++ b/crates/g3-cli/src/project.rs @@ -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, +} + +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 { + 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::>() + .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()); + } +} diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 49eec28..c9fa4ef 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -238,6 +238,67 @@ impl Agent { }) } + /// 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, + ) -> Result { + 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( config: Config, ui_writer: W, @@ -1285,6 +1346,52 @@ impl Agent { } } + /// 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) -> 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 pub fn get_stats(&self) -> String { use crate::stats::AgentStatsSnapshot; diff --git a/crates/g3-core/tests/project_context_test.rs b/crates/g3-core/tests/project_context_test.rs new file mode 100644 index 0000000..51e145b --- /dev/null +++ b/crates/g3-core/tests/project_context_test.rs @@ -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) -> Agent { + 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"); +}