use crate::automation_task::AutomationTask;
use crate::config::{ConfigError, ConfigStore};
use crate::profile::{AgentApproval, AgentProfile, AgentProfileId, SkillFilter, ToolFilter};
use crate::stored_prompt::{StoredPrompt, STORED_PROMPT_SCHEMA_VERSION};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
pub const AUTOMATION_RUN_SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedExecutionInput {
pub task: AutomationTask,
pub prompt: StoredPrompt,
pub profile_id: AgentProfileId,
pub profile: AgentProfile,
pub user_goal: String,
pub effective_skills: Vec<String>,
pub workspace: PathBuf,
pub timeout: Duration,
pub resolved_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutomationRunStatus {
Completed,
Failed,
Cancelled,
TimedOut,
}
impl AutomationRunStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
Self::TimedOut => "timed_out",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutomationRunErrorCategory {
Config,
Reference,
UnsafePolicy,
ProviderInit,
Execution,
Cancelled,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutomationRunError {
pub category: AutomationRunErrorCategory,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AutomationRunResult {
pub schema_version: i64,
pub run_id: String,
pub task_id: String,
pub task_name: String,
pub status: AutomationRunStatus,
#[serde(default)]
pub output: String,
#[serde(default)]
pub expected_outcome: String,
#[serde(default)]
pub profile_id: String,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
pub workspace: PathBuf,
#[serde(default)]
pub effective_tools: Vec<String>,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub duration_ms: u64,
#[serde(default)]
pub error: Option<AutomationRunError>,
}
impl AutomationRunResult {
pub fn started(task: &AutomationTask, workspace: PathBuf) -> Self {
Self {
schema_version: AUTOMATION_RUN_SCHEMA_VERSION,
run_id: uuid::Uuid::new_v4().to_string(),
task_id: task.id.clone(),
task_name: task.display_name.clone(),
status: AutomationRunStatus::Failed,
output: String::new(),
expected_outcome: task.expected_outcome.clone(),
profile_id: String::new(),
provider: None,
model: None,
workspace,
effective_tools: Vec::new(),
started_at: Utc::now(),
ended_at: Utc::now(),
duration_ms: 0,
error: None,
}
}
pub fn complete(&mut self, output: String) {
self.status = AutomationRunStatus::Completed;
self.output = output;
self.error = None;
self.finalize_timing();
}
pub fn fail(&mut self, category: AutomationRunErrorCategory, message: String) {
self.status = AutomationRunStatus::Failed;
self.error = Some(AutomationRunError { category, message });
self.finalize_timing();
}
pub fn cancel(&mut self, message: String) {
self.status = AutomationRunStatus::Cancelled;
self.error = Some(AutomationRunError {
category: AutomationRunErrorCategory::Cancelled,
message,
});
self.finalize_timing();
}
pub fn timeout(&mut self, message: String) {
self.status = AutomationRunStatus::TimedOut;
self.error = Some(AutomationRunError {
category: AutomationRunErrorCategory::TimedOut,
message,
});
self.finalize_timing();
}
pub fn set_resolved_metadata(
&mut self,
profile_id: &str,
provider: Option<String>,
model: Option<String>,
effective_tools: Vec<String>,
) {
self.profile_id = profile_id.to_string();
self.provider = provider;
self.model = model;
self.effective_tools = effective_tools;
}
pub fn cli_failure(
task_id: &str,
workspace: PathBuf,
category: AutomationRunErrorCategory,
message: String,
) -> Self {
let now = Utc::now();
Self {
schema_version: AUTOMATION_RUN_SCHEMA_VERSION,
run_id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.to_string(),
task_name: String::new(),
status: AutomationRunStatus::Failed,
output: String::new(),
expected_outcome: String::new(),
profile_id: String::new(),
provider: None,
model: None,
workspace,
effective_tools: Vec::new(),
started_at: now,
ended_at: now,
duration_ms: 0,
error: Some(AutomationRunError { category, message }),
}
}
fn finalize_timing(&mut self) {
self.ended_at = Utc::now();
self.duration_ms = self
.ended_at
.signed_duration_since(self.started_at)
.num_milliseconds()
.max(0) as u64;
}
}
#[derive(Debug, thiserror::Error)]
pub enum ResolutionError {
#[error("automation task '{0}' not found")]
TaskNotFound(String),
#[error("stored prompt '{0}' not found")]
PromptNotFound(String),
#[error("stored prompt '{0}' has an unsupported schema version or invalid payload")]
InvalidPrompt(String),
#[error("profile '{}' not found", _0.as_str())]
ProfileNotFound(AgentProfileId),
#[error(transparent)]
Store(#[from] ConfigError),
}
pub fn compose_user_goal(primary: &str, addition: Option<&str>) -> String {
match addition {
Some(add) => format!("{}\n\n{}", primary, add),
None => primary.to_string(),
}
}
pub fn resolve_profile_for_prompt(
prompt_profile_id: Option<&AgentProfileId>,
registry: &HashMap<AgentProfileId, AgentProfile>,
) -> Result<(AgentProfileId, AgentProfile), ResolutionError> {
let profile_id = prompt_profile_id
.cloned()
.unwrap_or_else(|| AgentProfileId::from("default"));
let profile = registry
.get(&profile_id)
.cloned()
.ok_or_else(|| ResolutionError::ProfileNotFound(profile_id.clone()))?;
Ok((profile_id, profile))
}
pub async fn resolve_task_execution(
store: &ConfigStore,
profiles: &HashMap<AgentProfileId, AgentProfile>,
task_id: &str,
workspace: PathBuf,
timeout: Duration,
) -> Result<ResolvedExecutionInput, ResolutionError> {
let task = store
.get_automation_task(task_id)
.await?
.ok_or_else(|| ResolutionError::TaskNotFound(task_id.to_string()))?;
let prompt = load_single_prompt(store, &task.stored_prompt_id).await?;
let (profile_id, profile) = resolve_profile_for_prompt(prompt.profile.as_ref(), profiles)?;
let user_goal = compose_user_goal(&prompt.instructions, Some(&task.expected_outcome));
let skills = match &profile.skills {
SkillFilter::None => Vec::new(),
SkillFilter::Inherit => prompt.skills.clone(),
SkillFilter::Allow(allowed) => prompt
.skills
.iter()
.filter(|s| allowed.iter().any(|a| a == *s))
.cloned()
.collect(),
};
Ok(ResolvedExecutionInput {
task,
prompt,
profile_id,
profile,
user_goal,
effective_skills: skills,
workspace,
timeout,
resolved_at: Utc::now(),
})
}
async fn load_single_prompt(
store: &ConfigStore,
prompt_id: &str,
) -> Result<StoredPrompt, ResolutionError> {
use crate::stored_prompt::LEGACY_STORED_PROMPT_SCHEMA_VERSION;
let record = store
.get_prompt(prompt_id)
.await?
.ok_or_else(|| ResolutionError::PromptNotFound(prompt_id.to_string()))?;
if record.schema_version != STORED_PROMPT_SCHEMA_VERSION
&& record.schema_version != LEGACY_STORED_PROMPT_SCHEMA_VERSION
{
return Err(ResolutionError::InvalidPrompt(prompt_id.to_string()));
}
let prompt: StoredPrompt = serde_json::from_value(record.payload.clone())
.map_err(|_| ResolutionError::InvalidPrompt(prompt_id.to_string()))?;
if prompt.instructions.trim().is_empty() {
return Err(ResolutionError::InvalidPrompt(prompt_id.to_string()));
}
if record.schema_version == LEGACY_STORED_PROMPT_SCHEMA_VERSION {
let display_name = crate::stored_prompt::kebab_to_title_case(&record.id);
let normalized_name = crate::stored_prompt::normalize_prompt_name(&display_name);
let prompt = StoredPrompt {
display_name,
normalized_name,
instructions: prompt.instructions,
skills: prompt.skills,
profile: prompt.profile,
};
prompt
.validate()
.map_err(|_| ResolutionError::InvalidPrompt(prompt_id.to_string()))?;
return Ok(prompt);
}
prompt
.validate()
.map_err(|_| ResolutionError::InvalidPrompt(prompt_id.to_string()))?;
Ok(prompt)
}
pub fn effective_skills(input: &ResolvedExecutionInput) -> Vec<String> {
input.effective_skills.clone()
}
pub fn is_headless_safe(approval: AgentApproval) -> bool {
matches!(approval, AgentApproval::AutoApprove)
}
pub fn effective_tool_filter(input: &ResolvedExecutionInput) -> ToolFilter {
input.profile.tools.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::automation_task::normalize_task_name;
use crate::automation_task::AutomationTaskInput;
use crate::config::ConfigStore;
use crate::profile::{AgentApproval, AgentProfile, SkillFilter};
use crate::stored_prompt::StoredPrompt;
fn test_task(id: &str, display_name: &str) -> AutomationTask {
let now = Utc::now();
AutomationTask {
id: id.to_string(),
display_name: display_name.to_string(),
normalized_name: normalize_task_name(display_name),
stored_prompt_id: "p1".to_string(),
expected_outcome: "done".to_string(),
project_root: PathBuf::from("/tmp"),
timeout_seconds: 60,
created_at: now,
updated_at: now,
}
}
const TEST_TIMEOUT: Duration = Duration::from_secs(300);
#[test]
fn compose_user_goal_no_addition() {
let result = compose_user_goal("do thing", None);
assert_eq!(result, "do thing");
}
#[test]
fn compose_user_goal_with_addition() {
let result = compose_user_goal("do thing", Some("expected: done"));
assert_eq!(result, "do thing\n\nexpected: done");
}
#[test]
fn compose_user_goal_preserves_multiline() {
let result = compose_user_goal("line1\nline2", Some("outcome"));
assert_eq!(result, "line1\nline2\n\noutcome");
}
fn make_registry() -> HashMap<AgentProfileId, AgentProfile> {
let mut reg = HashMap::new();
reg.insert(
AgentProfileId::from("default"),
AgentProfile::with_name("default"),
);
reg.insert(
AgentProfileId::from("auto"),
AgentProfile {
name: "auto".to_string(),
approval: AgentApproval::AutoApprove,
..AgentProfile::with_name("auto")
},
);
reg
}
#[test]
fn resolve_profile_explicit() {
let reg = make_registry();
let id = AgentProfileId::from("auto");
let (resolved_id, profile) = resolve_profile_for_prompt(Some(&id), ®).unwrap();
assert_eq!(resolved_id, AgentProfileId::from("auto"));
assert_eq!(profile.approval, AgentApproval::AutoApprove);
}
#[test]
fn resolve_profile_defaults_to_default() {
let reg = make_registry();
let (resolved_id, profile) = resolve_profile_for_prompt(None, ®).unwrap();
assert_eq!(resolved_id, AgentProfileId::from("default"));
assert_eq!(profile.approval, AgentApproval::PerTool);
}
#[test]
fn resolve_profile_not_found() {
let reg = make_registry();
let id = AgentProfileId::from("missing");
let result = resolve_profile_for_prompt(Some(&id), ®);
assert!(matches!(result, Err(ResolutionError::ProfileNotFound(_))));
}
#[test]
fn headless_safe_autoapprove() {
assert!(is_headless_safe(AgentApproval::AutoApprove));
}
#[test]
fn headless_not_safe_pertool() {
assert!(!is_headless_safe(AgentApproval::PerTool));
}
#[test]
fn effective_skills_inherit() {
let now = Utc::now();
let task = test_task("t1", "T");
let prompt = StoredPrompt {
display_name: "Do Thing".to_string(),
normalized_name: "do-thing".to_string(),
instructions: "do thing".to_string(),
skills: vec!["s1".to_string(), "s2".to_string()],
profile: None,
};
let profile = AgentProfile {
skills: SkillFilter::Inherit,
..AgentProfile::with_name("default")
};
let input = ResolvedExecutionInput {
task,
prompt,
profile_id: AgentProfileId::from("default"),
profile,
user_goal: "do thing\n\ndone".to_string(),
effective_skills: vec!["s1".to_string(), "s2".to_string()],
workspace: PathBuf::from("/tmp"),
timeout: TEST_TIMEOUT,
resolved_at: now,
};
let skills = effective_skills(&input);
assert_eq!(skills, vec!["s1", "s2"]);
}
#[test]
fn effective_skills_none_filter() {
let now = Utc::now();
let prompt = StoredPrompt {
display_name: "Do".to_string(),
normalized_name: "do".to_string(),
instructions: "do".to_string(),
skills: vec!["s1".to_string()],
profile: None,
};
let profile = AgentProfile {
skills: SkillFilter::None,
..AgentProfile::with_name("default")
};
let input = ResolvedExecutionInput {
task: test_task("t", "T"),
prompt,
profile_id: AgentProfileId::from("default"),
profile,
user_goal: "do\n\no".to_string(),
effective_skills: Vec::new(),
workspace: PathBuf::from("/tmp"),
timeout: TEST_TIMEOUT,
resolved_at: now,
};
assert!(effective_skills(&input).is_empty());
}
#[test]
fn effective_skills_allow_filter() {
let now = Utc::now();
let prompt = StoredPrompt {
display_name: "Do".to_string(),
normalized_name: "do".to_string(),
instructions: "do".to_string(),
skills: vec!["s1".to_string(), "s2".to_string(), "s3".to_string()],
profile: None,
};
let profile = AgentProfile {
skills: SkillFilter::Allow(vec!["s1".to_string(), "s3".to_string()]),
..AgentProfile::with_name("default")
};
let input = ResolvedExecutionInput {
task: test_task("t", "T"),
prompt,
profile_id: AgentProfileId::from("default"),
profile,
user_goal: "do\n\no".to_string(),
effective_skills: vec!["s1".to_string(), "s3".to_string()],
workspace: PathBuf::from("/tmp"),
timeout: TEST_TIMEOUT,
resolved_at: now,
};
let skills = effective_skills(&input);
assert_eq!(skills, vec!["s1", "s3"]);
}
#[test]
fn run_result_started_has_task_identity() {
let task = test_task("daily", "Daily");
let result = AutomationRunResult::started(&task, PathBuf::from("/work"));
assert_eq!(result.task_id, "daily");
assert_eq!(result.task_name, "Daily");
assert_eq!(result.expected_outcome, "done");
assert_eq!(result.workspace, PathBuf::from("/work"));
assert!(!result.run_id.is_empty());
}
#[test]
fn run_result_complete() {
let task = test_task("t", "T");
let mut result = AutomationRunResult::started(&task, PathBuf::from("/w"));
result.complete("final output".to_string());
assert_eq!(result.status, AutomationRunStatus::Completed);
assert_eq!(result.output, "final output");
assert!(result.error.is_none());
}
#[test]
fn run_result_fail_sets_error() {
let task = test_task("t", "T");
let mut result = AutomationRunResult::started(&task, PathBuf::from("/w"));
result.fail(
AutomationRunErrorCategory::Execution,
"provider error".to_string(),
);
assert_eq!(result.status, AutomationRunStatus::Failed);
assert_eq!(
result.error.as_ref().unwrap().category,
AutomationRunErrorCategory::Execution
);
}
#[test]
fn run_result_cancel_and_timeout() {
let task = test_task("t", "T");
let mut r1 = AutomationRunResult::started(&task, PathBuf::from("/w"));
r1.cancel("SIGINT".to_string());
assert_eq!(r1.status, AutomationRunStatus::Cancelled);
assert_eq!(
r1.error.as_ref().unwrap().category,
AutomationRunErrorCategory::Cancelled
);
let mut r2 = AutomationRunResult::started(&task, PathBuf::from("/w"));
r2.timeout("exceeded 30s".to_string());
assert_eq!(r2.status, AutomationRunStatus::TimedOut);
assert_eq!(
r2.error.as_ref().unwrap().category,
AutomationRunErrorCategory::TimedOut
);
}
#[test]
fn run_result_serializes_to_json() {
let task = test_task("t", "T");
let mut result = AutomationRunResult::started(&task, PathBuf::from("/w"));
result.complete("done".to_string());
result.set_resolved_metadata(
"auto",
Some("openai".to_string()),
Some("gpt-4".to_string()),
vec!["webfetch".to_string()],
);
let json = serde_json::to_string(&result).unwrap();
let restored: AutomationRunResult = serde_json::from_str(&json).unwrap();
assert_eq!(result, restored);
}
#[test]
fn status_serializes_snake_case() {
let json = serde_json::to_string(&AutomationRunStatus::TimedOut).unwrap();
assert_eq!(json, "\"timed_out\"");
let restored: AutomationRunStatus = serde_json::from_str("\"completed\"").unwrap();
assert_eq!(restored, AutomationRunStatus::Completed);
}
#[test]
fn json_includes_null_provider_model_error_for_cli_failure() {
let result = AutomationRunResult::cli_failure(
"missing-task",
PathBuf::from("/w"),
AutomationRunErrorCategory::Reference,
"task not found".to_string(),
);
let json = serde_json::to_string(&result).unwrap();
assert!(
json.contains("\"provider\":null"),
"provider should be null, got: {}",
json
);
assert!(
json.contains("\"model\":null"),
"model should be null, got: {}",
json
);
}
#[test]
fn json_includes_null_error_for_completed_run() {
let task = test_task("t", "T");
let mut result = AutomationRunResult::started(&task, PathBuf::from("/w"));
result.complete("done".to_string());
let json = serde_json::to_string(&result).unwrap();
assert!(
json.contains("\"error\":null"),
"error should be null for completed run, got: {}",
json
);
}
async fn setup_store_with_task_and_prompt() -> ConfigStore {
let store = ConfigStore::open_in_memory().await.unwrap();
let temp = tempfile::tempdir().unwrap();
let prompt = StoredPrompt {
display_name: "Daily Report".to_string(),
normalized_name: "daily-report".to_string(),
instructions: "Generate a daily report".to_string(),
skills: Vec::new(),
profile: None,
};
let payload = serde_json::to_value(&prompt).unwrap();
store
.set_prompt(&crate::config::PromptInput {
id: "report-prompt".to_string(),
schema_version: STORED_PROMPT_SCHEMA_VERSION,
payload,
display_name: "Daily Report".to_string(),
normalized_name: "daily-report".to_string(),
})
.await
.unwrap();
store
.set_automation_task(&AutomationTaskInput {
id: "daily-report".to_string(),
display_name: "Daily Report".to_string(),
stored_prompt_id: "report-prompt".to_string(),
expected_outcome: "A summary of today's activity".to_string(),
project_root: temp.path().to_path_buf(),
timeout_seconds: 300,
})
.await
.unwrap();
store
}
fn default_registry() -> HashMap<AgentProfileId, AgentProfile> {
let mut reg = HashMap::new();
reg.insert(
AgentProfileId::from("default"),
AgentProfile::with_name("default"),
);
reg
}
#[tokio::test]
async fn resolve_task_execution_success() {
let store = setup_store_with_task_and_prompt().await;
let reg = default_registry();
let input = resolve_task_execution(
&store,
®,
"daily-report",
PathBuf::from("/workspace"),
TEST_TIMEOUT,
)
.await
.unwrap();
assert_eq!(input.task.id, "daily-report");
assert_eq!(input.task.expected_outcome, "A summary of today's activity");
assert_eq!(input.prompt.instructions, "Generate a daily report");
assert_eq!(input.profile_id, AgentProfileId::from("default"));
assert_eq!(
input.user_goal,
"Generate a daily report\n\nA summary of today's activity"
);
assert_eq!(input.workspace, PathBuf::from("/workspace"));
assert_eq!(input.timeout, TEST_TIMEOUT);
}
#[tokio::test]
async fn resolve_task_execution_task_not_found() {
let store = setup_store_with_task_and_prompt().await;
let reg = default_registry();
let result =
resolve_task_execution(&store, ®, "missing", PathBuf::from("/w"), TEST_TIMEOUT)
.await;
assert!(matches!(result, Err(ResolutionError::TaskNotFound(_))));
}
#[tokio::test]
async fn resolve_task_execution_prompt_not_found() {
let store = ConfigStore::open_in_memory().await.unwrap();
let result = store
.set_automation_task(&AutomationTaskInput {
id: "orphan-task".to_string(),
display_name: "Orphan".to_string(),
stored_prompt_id: "nonexistent".to_string(),
expected_outcome: "nothing".to_string(),
project_root: std::env::temp_dir(),
timeout_seconds: 300,
})
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
crate::config::ConfigError::UnknownStoredPrompt(_)
));
}
#[tokio::test]
async fn resolve_task_execution_profile_not_found() {
let store = setup_store_with_task_and_prompt().await;
let reg: HashMap<AgentProfileId, AgentProfile> = HashMap::new();
let result = resolve_task_execution(
&store,
®,
"daily-report",
PathBuf::from("/w"),
TEST_TIMEOUT,
)
.await;
assert!(matches!(result, Err(ResolutionError::ProfileNotFound(_))));
}
#[tokio::test]
async fn resolve_task_execution_snapshot_is_stable() {
let store = setup_store_with_task_and_prompt().await;
let reg = default_registry();
let input1 = resolve_task_execution(
&store,
®,
"daily-report",
PathBuf::from("/w"),
TEST_TIMEOUT,
)
.await
.unwrap();
let temp = tempfile::tempdir().unwrap();
store
.set_automation_task(&AutomationTaskInput {
id: "daily-report".to_string(),
display_name: "Daily Report".to_string(),
stored_prompt_id: "report-prompt".to_string(),
expected_outcome: "Updated outcome".to_string(),
project_root: temp.path().to_path_buf(),
timeout_seconds: 300,
})
.await
.unwrap();
assert_eq!(
input1.task.expected_outcome,
"A summary of today's activity"
);
let input2 = resolve_task_execution(
&store,
®,
"daily-report",
PathBuf::from("/w"),
TEST_TIMEOUT,
)
.await
.unwrap();
assert_eq!(input2.task.expected_outcome, "Updated outcome");
}
#[tokio::test]
async fn resolve_task_execution_with_explicit_profile() {
let store = ConfigStore::open_in_memory().await.unwrap();
let temp = tempfile::tempdir().unwrap();
let prompt = StoredPrompt {
display_name: "Work".to_string(),
normalized_name: "work".to_string(),
instructions: "Do work".to_string(),
skills: Vec::new(),
profile: Some(AgentProfileId::from("automation")),
};
let payload = serde_json::to_value(&prompt).unwrap();
store
.set_prompt(&crate::config::PromptInput {
id: "work-prompt".to_string(),
schema_version: STORED_PROMPT_SCHEMA_VERSION,
payload,
display_name: "Work".to_string(),
normalized_name: "work".to_string(),
})
.await
.unwrap();
store
.set_automation_task(&AutomationTaskInput {
id: "work-task".to_string(),
display_name: "Work".to_string(),
stored_prompt_id: "work-prompt".to_string(),
expected_outcome: "Work done".to_string(),
project_root: temp.path().to_path_buf(),
timeout_seconds: 300,
})
.await
.unwrap();
let mut reg = HashMap::new();
reg.insert(
AgentProfileId::from("automation"),
AgentProfile {
name: "Automation".to_string(),
approval: AgentApproval::AutoApprove,
..AgentProfile::with_name("automation")
},
);
let input =
resolve_task_execution(&store, ®, "work-task", PathBuf::from("/w"), TEST_TIMEOUT)
.await
.unwrap();
assert_eq!(input.profile_id, AgentProfileId::from("automation"));
assert_eq!(input.profile.approval, AgentApproval::AutoApprove);
}
#[tokio::test]
async fn load_single_prompt_validates_migrated_legacy_prompt() {
use crate::stored_prompt::LEGACY_STORED_PROMPT_SCHEMA_VERSION;
let store = ConfigStore::open_in_memory().await.unwrap();
let legacy_payload = serde_json::json!({
"instructions": "do something",
"skills": [],
"profile": null,
});
store
.set_prompt(&crate::config::PromptInput {
id: "!!!".to_string(),
schema_version: LEGACY_STORED_PROMPT_SCHEMA_VERSION,
payload: legacy_payload,
display_name: String::new(),
normalized_name: String::new(),
})
.await
.unwrap();
let result = load_single_prompt(&store, "!!!").await;
assert!(
matches!(result, Err(ResolutionError::InvalidPrompt(_))),
"malformed legacy prompt should fail before execution, got {:?}",
result
);
}
#[tokio::test]
async fn load_single_prompt_accepts_valid_legacy_prompt() {
use crate::stored_prompt::LEGACY_STORED_PROMPT_SCHEMA_VERSION;
let store = ConfigStore::open_in_memory().await.unwrap();
let legacy_payload = serde_json::json!({
"instructions": "Generate a report",
"skills": [],
"profile": null,
});
store
.set_prompt(&crate::config::PromptInput {
id: "daily-report".to_string(),
schema_version: LEGACY_STORED_PROMPT_SCHEMA_VERSION,
payload: legacy_payload,
display_name: String::new(),
normalized_name: String::new(),
})
.await
.unwrap();
let prompt = load_single_prompt(&store, "daily-report").await.unwrap();
assert_eq!(prompt.display_name, "Daily Report");
assert_eq!(prompt.normalized_name, "daily-report");
assert_eq!(prompt.instructions, "Generate a report");
}
}