Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions crates/jcode-app-core/src/agent/prompting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,60 @@ impl Agent {
.as_ref()
.map(std::path::PathBuf::from);

// Detect keywords, update mode state, execute workflows, build prompt.
// Uses the canonical `process_turn` entry point (shared with the TUI
// path in turn_memory.rs) so the two callers cannot drift apart.
let keyword_prompt = {
let latest_input = self
.session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::message::Role::User))
.and_then(|m| {
m.content.iter().find_map(|b| match b {
crate::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
})
.unwrap_or("");
let last_assistant = self
.session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::message::Role::Assistant))
.and_then(|m| {
m.content.iter().find_map(|b| match b {
crate::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
});
let result = jcode_keywords::process_turn(
latest_input,
last_assistant,
working_dir.as_deref(),
&self.session.id,
);
for conflict in &result.conflicts {
crate::logging::warn(&jcode_keywords::conflict::format_warning(conflict));
}
if !result.deferred_spawns.is_empty() {
crate::logging::warn(&format!(
"Keyword workflow: {} spawn action(s) deferred — they will not run until SubagentTool is wired from the agent runtime. (See issue #391 follow-up.)",
result.deferred_spawns.len()
));
}
result.keyword_prompt
};

let (mut split, _context_info) = crate::prompt::build_system_prompt_split(
skill_prompt.as_deref(),
&available_skills,
self.session.is_canary,
memory_prompt,
working_dir.as_deref(),
keyword_prompt,
);

self.append_current_turn_system_reminder(&mut split);
Expand Down
17 changes: 17 additions & 0 deletions crates/jcode-base/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ pub fn build_system_prompt_with_context_and_memory(
is_selfdev,
memory_prompt,
None,
None,
)
}

Expand All @@ -214,6 +215,7 @@ pub fn build_system_prompt_full(
is_selfdev: bool,
memory_prompt: Option<&str>,
working_dir: Option<&Path>,
keyword_prompt: Option<String>,
) -> (String, ContextInfo) {
let mut parts = vec![DEFAULT_SYSTEM_PROMPT.to_string()];
let mut info = ContextInfo {
Expand Down Expand Up @@ -262,6 +264,13 @@ pub fn build_system_prompt_full(
parts.push(memory.to_string());
}

// Keyword mode prompt (changes per turn based on detected keywords)
if let Some(kw) = keyword_prompt {
if !kw.is_empty() {
parts.push(kw);
}
}

// Add available skills list
if !available_skills.is_empty() {
let mut skills_section = "# Available Skills\n\nYou have access to the following skills that the user can invoke with `/skillname`:\n".to_string();
Expand Down Expand Up @@ -294,6 +303,7 @@ pub fn build_system_prompt_split(
is_selfdev: bool,
memory_prompt: Option<&str>,
working_dir: Option<&Path>,
keyword_prompt: Option<String>,
) -> (SplitSystemPrompt, ContextInfo) {
let mut static_parts = vec![DEFAULT_SYSTEM_PROMPT.to_string()];
let mut dynamic_parts = Vec::new();
Expand Down Expand Up @@ -360,6 +370,13 @@ pub fn build_system_prompt_split(
dynamic_parts.push(memory.to_string());
}

// Keyword mode prompt (changes per turn based on detected keywords)
if let Some(kw) = keyword_prompt {
if !kw.is_empty() {
dynamic_parts.push(kw);
}
}

// Active skill prompt (changes per skill invocation)
if let Some(skill) = skill_prompt {
dynamic_parts.push(format!("# Active Skill\n\n{}", skill));
Expand Down
227 changes: 220 additions & 7 deletions crates/jcode-base/src/prompt_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fn test_session_context_includes_time_timezone_and_system_info() {

#[test]
fn test_split_prompt_does_not_inject_session_context_per_turn() {
let (split, _info) = build_system_prompt_split(None, &[], false, None, None);
let (split, _info) = build_system_prompt_split(None, &[], false, None, None, None);
assert!(!split.dynamic_part.contains("# Session Context"));
assert!(!split.dynamic_part.contains("Time: "));
assert!(!split.dynamic_part.contains("Timezone: UTC"));
Expand Down Expand Up @@ -120,7 +120,7 @@ fn test_prompt_overlay_files_are_loaded_from_project_and_global_jcode_dirs() {
"expected global prompt overlay content"
);

let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()));
let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()), None);
assert!(prompt.contains("project prompt overlay instructions"));
assert!(prompt.contains("global prompt overlay instructions"));
assert!(info.prompt_overlay_chars > 0);
Expand Down Expand Up @@ -174,13 +174,13 @@ fn test_preferred_tools_files_are_loaded_from_project_and_global_jcode_dirs() {
"expected global preferred tools content"
);

let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()));
let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(project_dir.path()), None);
assert!(prompt.contains("project preferred tools instructions"));
assert!(prompt.contains("global preferred tools instructions"));
assert!(info.preferred_tools_chars > 0);

let (split, split_info) =
build_system_prompt_split(None, &[], false, None, Some(project_dir.path()));
build_system_prompt_split(None, &[], false, None, Some(project_dir.path()), None);
assert!(
split
.static_part
Expand Down Expand Up @@ -221,7 +221,7 @@ fn test_selfdev_prompt_uses_full_selfdev_instructions() {
#[test]
fn test_selfdev_prompt_uses_desktop_focus_for_desktop_working_dir() {
let desktop_dir = std::path::Path::new("/tmp/jcode/crates/jcode-desktop/src");
let (prompt, _info) = build_system_prompt_full(None, &[], true, None, Some(desktop_dir));
let (prompt, _info) = build_system_prompt_full(None, &[], true, None, Some(desktop_dir), None);
assert!(prompt.contains("launched from the desktop app context"));
assert!(prompt.contains("selfdev build target=desktop"));
assert!(!prompt.contains("launched from the TUI/root jcode context"));
Expand All @@ -230,7 +230,7 @@ fn test_selfdev_prompt_uses_desktop_focus_for_desktop_working_dir() {
#[test]
fn test_split_selfdev_prompt_defaults_to_tui_focus_for_repo_root() {
let repo_dir = std::path::Path::new("/tmp/jcode");
let (split, _info) = build_system_prompt_split(None, &[], true, None, Some(repo_dir));
let (split, _info) = build_system_prompt_split(None, &[], true, None, Some(repo_dir), None);
assert!(
split
.static_part
Expand Down Expand Up @@ -264,7 +264,220 @@ fn test_selfdev_prompt_template_placeholders_are_resolved() {

#[test]
fn split_prompt_estimated_tokens_is_positive_when_populated() {
let (split, _info) = build_system_prompt_split(None, &[], false, None, None);
let (split, _info) = build_system_prompt_split(None, &[], false, None, None, None);
assert!(split.chars() > 0);
assert!(split.estimated_tokens() > 0);
}

// ---------------------------------------------------------------------------
// Regression tests for issue #22:
// - .jcode/SYSTEM.md replaces the default system prompt.
// - .jcode/APPEND_SYSTEM.md (and the CLI/env equivalents) extend it.
// ---------------------------------------------------------------------------

#[test]
fn system_prompt_env_var_replaces_default_root() {
let _lock = crate::storage::lock_test_env();
let prev = std::env::var_os("JCODE_SYSTEM_PROMPT");
crate::env::set_var("JCODE_SYSTEM_PROMPT", "ROOT_FROM_CLI");

let temp = tempfile::TempDir::new().expect("temp");
let resolved = resolve_system_prompt_override(Some(temp.path()));

if let Some(prev) = prev {
crate::env::set_var("JCODE_SYSTEM_PROMPT", prev);
} else {
crate::env::remove_var("JCODE_SYSTEM_PROMPT");
}

assert_eq!(resolved.as_deref(), Some("ROOT_FROM_CLI"));
}

#[test]
fn project_jcode_system_md_replaces_default_root() {
let _lock = crate::storage::lock_test_env();
let prev_env = std::env::var_os("JCODE_SYSTEM_PROMPT");
crate::env::remove_var("JCODE_SYSTEM_PROMPT");

let temp = tempfile::TempDir::new().expect("temp");
let dot = temp.path().join(".jcode");
std::fs::create_dir_all(&dot).unwrap();
std::fs::write(dot.join("SYSTEM.md"), "PROJECT_ROOT").unwrap();

let resolved = resolve_system_prompt_override(Some(temp.path()));

if let Some(prev) = prev_env {
crate::env::set_var("JCODE_SYSTEM_PROMPT", prev);
}

assert_eq!(resolved.as_deref(), Some("PROJECT_ROOT"));
}

#[test]
fn append_system_prompt_collects_env_and_files_in_order() {
let _lock = crate::storage::lock_test_env();
let prev_env = std::env::var_os("JCODE_APPEND_SYSTEM_PROMPT");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_APPEND_SYSTEM_PROMPT", "FROM_CLI");

let home_temp = tempfile::TempDir::new().expect("home temp");
crate::env::set_var("JCODE_HOME", home_temp.path());
let agent_dir = home_temp.path().join("agent");
std::fs::create_dir_all(&agent_dir).unwrap();
std::fs::write(agent_dir.join("APPEND_SYSTEM.md"), "FROM_GLOBAL").unwrap();

let proj_temp = tempfile::TempDir::new().expect("proj temp");
let dot = proj_temp.path().join(".jcode");
std::fs::create_dir_all(&dot).unwrap();
std::fs::write(dot.join("APPEND_SYSTEM.md"), "FROM_PROJECT").unwrap();

let (joined, total) = load_append_system_prompt_files_from_dir(Some(proj_temp.path()));

if let Some(prev) = prev_env {
crate::env::set_var("JCODE_APPEND_SYSTEM_PROMPT", prev);
} else {
crate::env::remove_var("JCODE_APPEND_SYSTEM_PROMPT");
}
if let Some(prev) = prev_home {
crate::env::set_var("JCODE_HOME", prev);
} else {
crate::env::remove_var("JCODE_HOME");
}

let joined = joined.expect("expected appended content");
let global_pos = joined.find("FROM_GLOBAL").expect("global section present");
let project_pos = joined
.find("FROM_PROJECT")
.expect("project section present");
let cli_pos = joined.find("FROM_CLI").expect("cli section present");
assert!(
global_pos < project_pos && project_pos < cli_pos,
"expected global < project < cli order in {joined:?}"
);
assert!(total >= "FROM_GLOBAL".len() + "FROM_PROJECT".len() + "FROM_CLI".len());
}

#[test]
fn build_system_prompt_full_uses_jcode_system_md_root() {
let _lock = crate::storage::lock_test_env();
let prev_env = std::env::var_os("JCODE_SYSTEM_PROMPT");
crate::env::remove_var("JCODE_SYSTEM_PROMPT");

let temp = tempfile::TempDir::new().expect("temp");
let dot = temp.path().join(".jcode");
std::fs::create_dir_all(&dot).unwrap();
std::fs::write(dot.join("SYSTEM.md"), "MY_OVERRIDDEN_ROOT").unwrap();

let (prompt, info) = build_system_prompt_full(None, &[], false, None, Some(temp.path()), None);

if let Some(prev) = prev_env {
crate::env::set_var("JCODE_SYSTEM_PROMPT", prev);
}

assert!(prompt.starts_with("MY_OVERRIDDEN_ROOT"));
// Default prompt is much longer; the override is a tiny string.
assert!(info.system_prompt_chars < 200);
assert!(!prompt.contains(crate::prompt::DEFAULT_SYSTEM_PROMPT));
}

#[test]
fn test_context_files_disabled_returns_false_by_default() {
let _guard = crate::storage::lock_test_env();
// Ensure the env var is NOT set
crate::env::remove_var("JCODE_NO_CONTEXT_FILES");
assert!(!context_files_disabled());
}

#[test]
fn test_context_files_disabled_returns_true_when_env_set() {
let _guard = crate::storage::lock_test_env();
let prev_val = std::env::var("JCODE_NO_CONTEXT_FILES");
// Ensure the env var is set for this test
crate::env::set_var("JCODE_NO_CONTEXT_FILES", "1");
assert!(context_files_disabled());
// Restore previous state
match prev_val {
Ok(val) => crate::env::set_var("JCODE_NO_CONTEXT_FILES", val),
Err(_) => crate::env::remove_var("JCODE_NO_CONTEXT_FILES"),
}
}

#[test]
fn test_load_agents_md_from_dir_returns_none_when_disabled() {
let _guard = crate::storage::lock_test_env();
let prev_home = std::env::var_os("JCODE_HOME");
let temp = tempfile::TempDir::new().unwrap();
crate::env::set_var("JCODE_HOME", temp.path());
crate::env::set_var("JCODE_NO_CONTEXT_FILES", "1");

// Even with a global AGENTS.md present, loading should be skipped
std::fs::create_dir_all(temp.path()).unwrap();
std::fs::write(temp.path().join("AGENTS.md"), "global agents instructions").unwrap();

let (content, _info) = load_agents_md_files_from_dir(None);
assert!(content.is_none());

if let Some(prev_home) = prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
crate::env::remove_var("JCODE_NO_CONTEXT_FILES");
}

#[test]
fn test_load_agents_md_from_dir_loads_files_when_not_disabled() {
let _guard = crate::storage::lock_test_env();
let prev_home = std::env::var_os("JCODE_HOME");
let temp = tempfile::TempDir::new().unwrap();
crate::env::set_var("JCODE_HOME", temp.path());

// Remove any leftover env var
crate::env::remove_var("JCODE_NO_CONTEXT_FILES");

std::fs::create_dir_all(temp.path().join("external")).unwrap();
std::fs::write(
temp.path().join("external/AGENTS.md"),
"global agents instructions",
)
.unwrap();

let (content, info) = load_agents_md_files_from_dir(None);
assert!(info.has_global_agents_md);
assert!(
content
.as_ref()
.map(|c| c.contains("global agents instructions"))
.unwrap_or(false)
);

if let Some(prev_home) = prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}

#[test]
fn test_cli_flag_no_short_alias() {
// Verify that -c is NOT a valid alias for --no-context-files
let result = Args::try_parse_from(["jcode", "-c", "--provider", "openai"]);
assert!(
result.is_err(),
"-c should not be a valid short flag for --no-context-files"
);
}

#[test]
fn test_cli_flag_no_context_files_parsed() {
let args = Args::parse_from(["jcode", "--no-context-files"]);
assert!(args.no_context_files);

// Without the flag, should be false
let args2 = Args::parse_from(["jcode"]);
assert!(!args2.no_context_files);

// With subcommand
let args3 = Args::parse_from(["jcode", "--no-context-files", "run", "hello"]);
assert!(args3.no_context_files);
}
Loading
Loading