fix(goal): inject session goal into system prompt

Thread the /goal objective from the TUI into engine prompt assembly so follow-up turns can see the current session objective. Add prompt and engine regression tests that pin the session_goal block and verify empty goals are skipped.
This commit is contained in:
Hunter Bown
2026-05-03 13:26:00 -05:00
parent 12de76b7b5
commit db2f761120
7 changed files with 143 additions and 10 deletions
+17 -4
View File
@@ -139,6 +139,7 @@ pub struct EngineConfig {
/// Path to the user memory file (#489). Always populated; only
/// consulted when `memory_enabled` is `true`.
pub memory_path: PathBuf,
pub goal_objective: Option<String>,
}
impl Default for EngineConfig {
@@ -168,6 +169,7 @@ impl Default for EngineConfig {
subagent_model_overrides: HashMap::new(),
memory_enabled: false,
memory_path: PathBuf::from("./memory.md"),
goal_objective: None,
}
}
}
@@ -355,13 +357,16 @@ impl Engine {
let working_set_summary = session.working_set.summary_block(&config.workspace);
let user_memory_block =
crate::memory::compose_block(config.memory_enabled, &config.memory_path);
let system_prompt = prompts::system_prompt_for_mode_with_context_and_skills(
let system_prompt = prompts::system_prompt_for_mode_with_context_skills_and_session(
AppMode::Agent,
&config.workspace,
None,
Some(&config.skills_dir),
Some(&config.instructions),
user_memory_block.as_deref(),
prompts::PromptSessionContext {
user_memory_block: user_memory_block.as_deref(),
goal_objective: config.goal_objective.as_deref(),
},
);
session.system_prompt =
append_working_set_summary(Some(system_prompt), working_set_summary.as_deref());
@@ -461,6 +466,7 @@ impl Engine {
content,
mode,
model,
goal_objective,
reasoning_effort,
allow_shell,
trust_mode,
@@ -470,6 +476,7 @@ impl Engine {
content,
mode,
model,
goal_objective,
reasoning_effort,
allow_shell,
trust_mode,
@@ -653,6 +660,7 @@ impl Engine {
new_message,
mode,
self.session.model.clone(),
self.config.goal_objective.clone(),
self.session.reasoning_effort.clone(),
self.session.allow_shell,
self.session.trust_mode,
@@ -691,6 +699,7 @@ impl Engine {
content: String,
mode: AppMode,
model: String,
goal_objective: Option<String>,
reasoning_effort: Option<String>,
allow_shell: bool,
trust_mode: bool,
@@ -770,6 +779,7 @@ impl Engine {
self.session.model = model;
self.config.model.clone_from(&self.session.model);
self.config.goal_objective = goal_objective;
self.session.reasoning_effort = reasoning_effort;
self.session.allow_shell = allow_shell;
self.config.allow_shell = allow_shell;
@@ -1631,13 +1641,16 @@ impl Engine {
.summary_block(&self.config.workspace);
let user_memory_block =
crate::memory::compose_block(self.config.memory_enabled, &self.config.memory_path);
let base = prompts::system_prompt_for_mode_with_context_and_skills(
let base = prompts::system_prompt_for_mode_with_context_skills_and_session(
mode,
&self.config.workspace,
None,
Some(&self.config.skills_dir),
Some(&self.config.instructions),
user_memory_block.as_deref(),
prompts::PromptSessionContext {
user_memory_block: user_memory_block.as_deref(),
goal_objective: self.config.goal_objective.as_deref(),
},
);
let stable_prompt =
merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone());
+21
View File
@@ -66,6 +66,27 @@ fn engine_handle_cancel_tracks_latest_turn_token() {
assert!(!stale_token.is_cancelled());
}
#[test]
fn engine_initial_prompt_includes_configured_goal() {
let config = EngineConfig {
goal_objective: Some("Fix goal handoff".to_string()),
..Default::default()
};
let (engine, _handle) = Engine::new(config, &Config::default());
let prompt = match engine.session.system_prompt {
Some(SystemPrompt::Text(text)) => text,
Some(SystemPrompt::Blocks(blocks)) => blocks
.into_iter()
.map(|block| block.text)
.collect::<Vec<_>>()
.join("\n"),
None => panic!("expected system prompt"),
};
assert!(prompt.contains("<session_goal>"));
assert!(prompt.contains("Fix goal handoff"));
}
#[test]
fn parallel_batch_requires_read_only_parallel_tools() {
let plans = vec![make_plan(true, true, false, false)];
+2
View File
@@ -16,6 +16,7 @@ pub enum Op {
content: String,
mode: AppMode,
model: String,
goal_objective: Option<String>,
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// `None` lets the provider apply its default.
reasoning_effort: Option<String>,
@@ -104,6 +105,7 @@ impl Op {
content: content.into(),
mode,
model: model.into(),
goal_objective: None,
reasoning_effort,
allow_shell,
trust_mode,
+1
View File
@@ -3631,6 +3631,7 @@ async fn run_exec_agent(
subagent_model_overrides: config.subagent_model_overrides(),
memory_enabled: config.memory_enabled(),
memory_path: config.memory_path(),
goal_objective: None,
};
let engine_handle = spawn_engine(engine_config, config);
+86 -1
View File
@@ -12,6 +12,12 @@ use crate::project_context::{ProjectContext, load_project_context_with_parents};
use crate::tui::app::AppMode;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, Default)]
pub struct PromptSessionContext<'a> {
pub user_memory_block: Option<&'a str>,
pub goal_objective: Option<&'a str>,
}
/// Conventional location for the structured session-handoff artifact (#32).
/// A previous session writes it on exit / `/compact`; the next session reads
/// it back on startup and prepends it to the system prompt so a fresh agent
@@ -260,6 +266,27 @@ pub fn system_prompt_for_mode_with_context_and_skills(
skills_dir: Option<&Path>,
instructions: Option<&[PathBuf]>,
user_memory_block: Option<&str>,
) -> SystemPrompt {
system_prompt_for_mode_with_context_skills_and_session(
mode,
workspace,
working_set_summary,
skills_dir,
instructions,
PromptSessionContext {
user_memory_block,
goal_objective: None,
},
)
}
pub fn system_prompt_for_mode_with_context_skills_and_session(
mode: AppMode,
workspace: &Path,
working_set_summary: Option<&str>,
skills_dir: Option<&Path>,
instructions: Option<&[PathBuf]>,
session_context: PromptSessionContext<'_>,
) -> SystemPrompt {
let mode_prompt = compose_mode_prompt(mode);
@@ -293,12 +320,21 @@ pub fn system_prompt_for_mode_with_context_and_skills(
// 2.5b. User memory block (#489). Goes above skills/context-management
// because it's session-stable: the memory file changes when the user
// edits it via `/memory` or `# foo` quick-add, but not turn-over-turn.
if let Some(memory_block) = user_memory_block
if let Some(memory_block) = session_context.user_memory_block
&& !memory_block.trim().is_empty()
{
full_prompt = format!("{full_prompt}\n\n{memory_block}");
}
if let Some(goal_objective) = session_context.goal_objective
&& !goal_objective.trim().is_empty()
{
full_prompt = format!(
"{full_prompt}\n\n## Current Session Goal\n\n<session_goal>\n{}\n</session_goal>",
goal_objective.trim()
);
}
// 3. Skills block. #432: walks every candidate workspace
// skills directory (`.agents/skills`, `skills`,
// `.opencode/skills`, `.claude/skills`) plus the global
@@ -503,6 +539,55 @@ mod tests {
assert!(prompt.contains("### Next step"));
}
#[test]
fn session_goal_is_injected_above_volatile_prompt_tail() {
let tmp = tempdir().expect("tempdir");
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
AppMode::Agent,
tmp.path(),
Some("## Repo Working Set\nsrc/lib.rs"),
None,
None,
PromptSessionContext {
user_memory_block: None,
goal_objective: Some("Fix transcript corruption"),
},
) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
};
let goal_pos = prompt.find("<session_goal>").expect("goal block");
let compact_pos = prompt.find("## Compaction Handoff").expect("compact block");
let working_set_pos = prompt.find("## Repo Working Set").expect("working set");
assert!(prompt.contains("Fix transcript corruption"));
assert!(goal_pos < compact_pos);
assert!(goal_pos < working_set_pos);
}
#[test]
fn empty_session_goal_is_not_injected() {
let tmp = tempdir().expect("tempdir");
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
AppMode::Agent,
tmp.path(),
None,
None,
None,
PromptSessionContext {
user_memory_block: None,
goal_objective: Some(" "),
},
) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
};
assert!(!prompt.contains("<session_goal>"));
assert!(!prompt.contains("## Current Session Goal"));
}
#[test]
fn when_not_to_use_sections_present() {
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
+1
View File
@@ -1566,6 +1566,7 @@ impl RuntimeThreadManager {
subagent_model_overrides: self.config.subagent_model_overrides(),
memory_enabled: self.config.memory_enabled(),
memory_path: self.config.memory_path(),
goal_objective: None,
};
let engine = spawn_engine(engine_cfg, &self.config);
+15 -5
View File
@@ -534,6 +534,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
subagent_model_overrides: config.subagent_model_overrides(),
memory_enabled: config.memory_enabled(),
memory_path: config.memory_path(),
goal_objective: app.goal.goal_objective.clone(),
}
}
@@ -3017,11 +3018,19 @@ async fn dispatch_user_message(
);
let content = queued_message_content_for_app(app, &message, cwd);
let message_index = app.api_messages.len();
app.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
app.mode,
&app.workspace,
None,
));
app.system_prompt = Some(
prompts::system_prompt_for_mode_with_context_skills_and_session(
app.mode,
&app.workspace,
None,
None,
None,
prompts::PromptSessionContext {
user_memory_block: None,
goal_objective: app.goal.goal_objective.as_deref(),
},
),
);
app.add_message(HistoryCell::User {
content: message.display.clone(),
});
@@ -3065,6 +3074,7 @@ async fn dispatch_user_message(
content,
mode: app.mode,
model: effective_model,
goal_objective: app.goal.goal_objective.clone(),
reasoning_effort: app.reasoning_effort.api_value().map(str::to_string),
allow_shell: app.allow_shell,
trust_mode: app.trust_mode,