mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): reconcile prompt authority without partial state
This commit is contained in:
@@ -95,6 +95,21 @@ impl PromptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
if matches!(app, AppType::Pi) {
|
||||||
|
let guard = lock_instruction_files()?;
|
||||||
|
let prompts = state.db.get_prompts(AppType::Pi.as_str())?;
|
||||||
|
let snapshot =
|
||||||
|
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
|
||||||
|
reject_pi_prompt_mutation_during_drift(&prompts, &snapshot, id, None)?;
|
||||||
|
if prompts.get(id).is_some_and(|prompt| prompt.enabled)
|
||||||
|
|| preferred_pi_prompt_match(&prompts, &snapshot) == Some(id)
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"无法删除 Pi 当前生效的提示词".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return state.db.delete_prompt(AppType::Pi.as_str(), id);
|
||||||
|
}
|
||||||
let prompts = state.db.get_prompts(app.as_str())?;
|
let prompts = state.db.get_prompts(app.as_str())?;
|
||||||
|
|
||||||
if let Some(prompt) = prompts.get(id) {
|
if let Some(prompt) = prompts.get(id) {
|
||||||
@@ -338,60 +353,40 @@ impl PromptService {
|
|||||||
guard: &PiInstructionFileGuard,
|
guard: &PiInstructionFileGuard,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
const MAX_EXTERNAL_RETRIES: usize = 3;
|
const MAX_EXTERNAL_RETRIES: usize = 3;
|
||||||
|
let original = db.get_prompts(AppType::Pi.as_str())?;
|
||||||
|
|
||||||
for _ in 0..MAX_EXTERNAL_RETRIES {
|
for _ in 0..MAX_EXTERNAL_RETRIES {
|
||||||
let snapshot =
|
let snapshot =
|
||||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||||
let mut prompts = db.get_prompts(AppType::Pi.as_str())?;
|
let prompts = build_pi_reconciled_library(&original, &snapshot);
|
||||||
for prompt in prompts.values_mut() {
|
let precommit =
|
||||||
prompt.enabled = false;
|
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||||
}
|
if precommit.revision != snapshot.revision {
|
||||||
|
continue;
|
||||||
if snapshot.exists {
|
|
||||||
let active_id = prompts
|
|
||||||
.iter()
|
|
||||||
.find_map(|(id, prompt)| {
|
|
||||||
(prompt.content == snapshot.content).then(|| id.clone())
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes()));
|
|
||||||
let base = format!("native-{digest}");
|
|
||||||
let mut id = base.clone();
|
|
||||||
let mut suffix = 1_u32;
|
|
||||||
while prompts.contains_key(&id) {
|
|
||||||
id = format!("{base}-{suffix}");
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
let timestamp = chrono::Utc::now().timestamp();
|
|
||||||
prompts.insert(
|
|
||||||
id.clone(),
|
|
||||||
Prompt {
|
|
||||||
id: id.clone(),
|
|
||||||
name: "Imported from Pi AGENTS.md".to_string(),
|
|
||||||
content: snapshot.content.clone(),
|
|
||||||
description: Some(
|
|
||||||
"Device-local native state preserved during portable import"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
enabled: false,
|
|
||||||
created_at: Some(timestamp),
|
|
||||||
updated_at: Some(timestamp),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
id
|
|
||||||
});
|
|
||||||
prompts
|
|
||||||
.get_mut(&active_id)
|
|
||||||
.expect("selected Pi prompt is present")
|
|
||||||
.enabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.save_prompt_selection(AppType::Pi.as_str(), &prompts)?;
|
db.save_prompt_selection(AppType::Pi.as_str(), &prompts)?;
|
||||||
|
#[cfg(test)]
|
||||||
|
replace_pi_agents_after_reconcile_save_for_test(&snapshot.path)?;
|
||||||
let verified =
|
let verified =
|
||||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
match PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)
|
||||||
|
{
|
||||||
|
Ok(verified) => verified,
|
||||||
|
Err(error) => {
|
||||||
|
restore_pi_library_after_failed_reconcile(db, &original, &error)?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
if verified.revision == snapshot.revision {
|
if verified.revision == snapshot.revision {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
restore_pi_library_after_failed_reconcile(
|
||||||
|
db,
|
||||||
|
&original,
|
||||||
|
&AppError::Conflict(
|
||||||
|
"Pi AGENTS.md changed after prompt-library publication".to_string(),
|
||||||
|
),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(AppError::Conflict(
|
Err(AppError::Conflict(
|
||||||
@@ -410,13 +405,8 @@ impl PromptService {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.clone()))
|
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let matched_prompt_id = if snapshot.exists {
|
let matched_prompt_id =
|
||||||
prompts
|
preferred_pi_prompt_match(&prompts, &snapshot).map(ToOwned::to_owned);
|
||||||
.iter()
|
|
||||||
.find_map(|(id, prompt)| (prompt.content == snapshot.content).then(|| id.clone()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let expected_enabled = matched_prompt_id.iter().cloned().collect::<Vec<_>>();
|
let expected_enabled = matched_prompt_id.iter().cloned().collect::<Vec<_>>();
|
||||||
let needs_reconciliation = persisted_enabled != expected_enabled
|
let needs_reconciliation = persisted_enabled != expected_enabled
|
||||||
|| (snapshot.exists && matched_prompt_id.is_none());
|
|| (snapshot.exists && matched_prompt_id.is_none());
|
||||||
@@ -438,15 +428,17 @@ impl PromptService {
|
|||||||
|
|
||||||
fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> {
|
fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> {
|
||||||
let guard = lock_instruction_files()?;
|
let guard = lock_instruction_files()?;
|
||||||
let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?;
|
let before = state.db.get_prompts(AppType::Pi.as_str())?;
|
||||||
let previous = prompts.insert(prompt.id.clone(), prompt.clone());
|
|
||||||
let snapshot =
|
let snapshot =
|
||||||
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
|
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
|
||||||
|
reject_pi_prompt_mutation_during_drift(&before, &snapshot, &prompt.id, Some(&prompt))?;
|
||||||
|
let mut prompts = before.clone();
|
||||||
|
let previous = prompts.insert(prompt.id.clone(), prompt.clone());
|
||||||
let current_enabled = previous
|
let current_enabled = previous
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|candidate| candidate.enabled)
|
.filter(|candidate| candidate.enabled)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
prompts
|
before
|
||||||
.values()
|
.values()
|
||||||
.find(|candidate| candidate.id != prompt.id && candidate.enabled)
|
.find(|candidate| candidate.id != prompt.id && candidate.enabled)
|
||||||
});
|
});
|
||||||
@@ -572,6 +564,150 @@ impl PromptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preferred_pi_prompt_match<'a>(
|
||||||
|
prompts: &'a IndexMap<String, Prompt>,
|
||||||
|
snapshot: &PiPromptFileSnapshot,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
if !snapshot.exists {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
prompts
|
||||||
|
.iter()
|
||||||
|
.find_map(|(id, prompt)| {
|
||||||
|
(prompt.enabled && prompt.content == snapshot.content).then_some(id.as_str())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
prompts.iter().find_map(|(id, prompt)| {
|
||||||
|
(prompt.content == snapshot.content).then_some(id.as_str())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pi_library_needs_reconciliation(
|
||||||
|
prompts: &IndexMap<String, Prompt>,
|
||||||
|
snapshot: &PiPromptFileSnapshot,
|
||||||
|
) -> bool {
|
||||||
|
let persisted_enabled = prompts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let matched = preferred_pi_prompt_match(prompts, snapshot);
|
||||||
|
persisted_enabled != matched.into_iter().collect::<Vec<_>>()
|
||||||
|
|| (snapshot.exists && matched.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_pi_prompt_mutation_during_drift(
|
||||||
|
prompts: &IndexMap<String, Prompt>,
|
||||||
|
snapshot: &PiPromptFileSnapshot,
|
||||||
|
target_id: &str,
|
||||||
|
replacement: Option<&Prompt>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if !pi_library_needs_reconciliation(prompts, snapshot) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let target_is_persisted_active = prompts.get(target_id).is_some_and(|prompt| prompt.enabled);
|
||||||
|
let target_is_live_match = preferred_pi_prompt_match(prompts, snapshot) == Some(target_id);
|
||||||
|
let replacement_claims_live = replacement.is_some_and(|prompt| {
|
||||||
|
prompt.enabled || (snapshot.exists && prompt.content == snapshot.content)
|
||||||
|
});
|
||||||
|
if target_is_persisted_active || target_is_live_match || replacement_claims_live {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Pi AGENTS.md and the prompt library disagree; reconcile native truth before changing \
|
||||||
|
an active prompt"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pi_reconciled_library(
|
||||||
|
original: &IndexMap<String, Prompt>,
|
||||||
|
snapshot: &PiPromptFileSnapshot,
|
||||||
|
) -> IndexMap<String, Prompt> {
|
||||||
|
let mut prompts = original.clone();
|
||||||
|
for prompt in prompts.values_mut() {
|
||||||
|
prompt.enabled = false;
|
||||||
|
}
|
||||||
|
if !snapshot.exists {
|
||||||
|
return prompts;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_id = preferred_pi_prompt_match(original, snapshot)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes()));
|
||||||
|
let base = format!("native-{digest}");
|
||||||
|
let mut id = base.clone();
|
||||||
|
let mut suffix = 1_u32;
|
||||||
|
while prompts.contains_key(&id) {
|
||||||
|
id = format!("{base}-{suffix}");
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
let timestamp = chrono::Utc::now().timestamp();
|
||||||
|
prompts.insert(
|
||||||
|
id.clone(),
|
||||||
|
Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: "Imported from Pi AGENTS.md".to_string(),
|
||||||
|
content: snapshot.content.clone(),
|
||||||
|
description: Some(
|
||||||
|
"Device-local native state preserved during portable import".to_string(),
|
||||||
|
),
|
||||||
|
enabled: false,
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
id
|
||||||
|
});
|
||||||
|
prompts
|
||||||
|
.get_mut(&active_id)
|
||||||
|
.expect("selected Pi prompt is present")
|
||||||
|
.enabled = true;
|
||||||
|
prompts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_pi_library_after_failed_reconcile(
|
||||||
|
db: &Database,
|
||||||
|
original: &IndexMap<String, Prompt>,
|
||||||
|
cause: &AppError,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
db.save_prompt_selection(AppType::Pi.as_str(), original)
|
||||||
|
.map_err(|restore_error| {
|
||||||
|
AppError::Config(format!(
|
||||||
|
"Pi prompt reconciliation lost its native revision ({cause}) and failed to \
|
||||||
|
restore the previous portable library: {restore_error}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
static PI_RECONCILE_AFTER_SAVE_REPLACEMENTS: std::sync::LazyLock<
|
||||||
|
std::sync::Mutex<std::collections::VecDeque<String>>,
|
||||||
|
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn replace_pi_agents_after_reconcile_save_for_test(path: &str) -> Result<(), AppError> {
|
||||||
|
let replacement = PI_RECONCILE_AFTER_SAVE_REPLACEMENTS
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| AppError::Lock(error.to_string()))?
|
||||||
|
.pop_front();
|
||||||
|
if let Some(replacement) = replacement {
|
||||||
|
std::fs::write(path, replacement).map_err(|error| AppError::io(path, error))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn replace_pi_agents_after_each_reconcile_save_for_test(
|
||||||
|
replacements: impl IntoIterator<Item = &'static str>,
|
||||||
|
) {
|
||||||
|
*PI_RECONCILE_AFTER_SAVE_REPLACEMENTS
|
||||||
|
.lock()
|
||||||
|
.expect("Pi reconcile hook lock") =
|
||||||
|
replacements.into_iter().map(ToOwned::to_owned).collect();
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_pi_library_projection_matches(
|
fn ensure_pi_library_projection_matches(
|
||||||
snapshot: &PiPromptFileSnapshot,
|
snapshot: &PiPromptFileSnapshot,
|
||||||
current_enabled: Option<&Prompt>,
|
current_enabled: Option<&Prompt>,
|
||||||
@@ -641,6 +777,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prompt(id: &str, content: &str, enabled: bool, timestamp: i64) -> Prompt {
|
||||||
|
Prompt {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: id.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
description: None,
|
||||||
|
enabled,
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn public_pi_import_reconciles_an_active_empty_agents_file() {
|
fn public_pi_import_reconciles_an_active_empty_agents_file() {
|
||||||
@@ -909,4 +1057,139 @@ mod tests {
|
|||||||
.all(|prompt| !prompt.enabled));
|
.all(|prompt| !prompt.enabled));
|
||||||
assert!(!temp.path().join("AGENTS.md").exists());
|
assert!(!temp.path().join("AGENTS.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn duplicate_prompt_content_prefers_the_persisted_enabled_identity() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
|
||||||
|
std::fs::write(temp.path().join("AGENTS.md"), "same-content").expect("seed AGENTS.md");
|
||||||
|
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_prompt(
|
||||||
|
AppType::Pi.as_str(),
|
||||||
|
&prompt("first", "same-content", false, 1),
|
||||||
|
)
|
||||||
|
.expect("first duplicate");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_prompt(
|
||||||
|
AppType::Pi.as_str(),
|
||||||
|
&prompt("persisted-active", "same-content", true, 2),
|
||||||
|
)
|
||||||
|
.expect("active duplicate");
|
||||||
|
|
||||||
|
let effective = PromptService::get_prompts(&state, AppType::Pi).expect("effective prompts");
|
||||||
|
assert!(!effective["first"].enabled);
|
||||||
|
assert!(effective["persisted-active"].enabled);
|
||||||
|
let status = PromptService::get_pi_library_status(&state).expect("status");
|
||||||
|
assert_eq!(
|
||||||
|
status.matched_prompt_id.as_deref(),
|
||||||
|
Some("persisted-active")
|
||||||
|
);
|
||||||
|
assert!(!status.needs_reconciliation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn external_prompt_drift_blocks_deleting_or_disabling_the_live_match() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
|
||||||
|
std::fs::write(temp.path().join("AGENTS.md"), "external-live").expect("seed AGENTS.md");
|
||||||
|
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_prompt(
|
||||||
|
AppType::Pi.as_str(),
|
||||||
|
&prompt("stale-active", "stale-content", true, 1),
|
||||||
|
)
|
||||||
|
.expect("stale prompt");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_prompt(
|
||||||
|
AppType::Pi.as_str(),
|
||||||
|
&prompt("live-match", "external-live", false, 2),
|
||||||
|
)
|
||||||
|
.expect("live match");
|
||||||
|
let before = serde_json::to_value(
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_prompts(AppType::Pi.as_str())
|
||||||
|
.expect("before prompts"),
|
||||||
|
)
|
||||||
|
.expect("serialize before");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
PromptService::delete_prompt(&state, AppType::Pi, "live-match"),
|
||||||
|
Err(AppError::Conflict(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
PromptService::upsert_prompt(
|
||||||
|
&state,
|
||||||
|
AppType::Pi,
|
||||||
|
"live-match",
|
||||||
|
prompt("live-match", "external-live", false, 3),
|
||||||
|
),
|
||||||
|
Err(AppError::Conflict(_))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_prompts(AppType::Pi.as_str())
|
||||||
|
.expect("after prompts")
|
||||||
|
)
|
||||||
|
.expect("serialize after"),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("live remains"),
|
||||||
|
"external-live"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn repeated_external_reconcile_races_restore_the_original_library() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
|
||||||
|
std::fs::write(temp.path().join("AGENTS.md"), "native-0").expect("seed AGENTS.md");
|
||||||
|
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_prompt(
|
||||||
|
AppType::Pi.as_str(),
|
||||||
|
&prompt("portable", "portable-content", true, 1),
|
||||||
|
)
|
||||||
|
.expect("portable prompt");
|
||||||
|
let before = serde_json::to_value(
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_prompts(AppType::Pi.as_str())
|
||||||
|
.expect("before prompts"),
|
||||||
|
)
|
||||||
|
.expect("serialize before");
|
||||||
|
replace_pi_agents_after_each_reconcile_save_for_test(["native-1", "native-2", "native-3"]);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
PromptService::reconcile_pi_library(&state),
|
||||||
|
Err(AppError::Conflict(_))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_prompts(AppType::Pi.as_str())
|
||||||
|
.expect("restored prompts")
|
||||||
|
)
|
||||||
|
.expect("serialize restored"),
|
||||||
|
before,
|
||||||
|
"a failed reconcile must leave no imported rows or selection changes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("latest native"),
|
||||||
|
"native-3"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user