# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AkitaOnRails
2026-09-29 23:44:02 -03:00
2 changed files with 176 additions and 2 deletions
+18
View File
@@ -51,6 +51,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
is not a rescope and falls through to the existing project-provenance logic
unchanged; a genuinely DIFFERENT or unresolvable workspace still fails
closed and disqualifies sticky, exactly as before. (#984)
- Auto-improve no longer rejects every proposal from a model that spells the
full-page edit mode as `"full"`. `edit_mode` had the same shape #458 fixed
for `operation`: a free-form string validated by exact match, no schema
constraint, and a system prompt that says "Full-page proposals" without the
literal value. `gpt-oss-20b` via LM Studio answered `"full"` for every
candidate, so runs finished with zero accepted proposals and only
`unsupported_edit_mode` rejections. The schema now advertises
`["full_page", "patch"]`, and normalisation folds `full`, `full-page` and
`Full Page` into `full_page` (with a warning) for providers without
constrained decoding. Unknown modes still fail validation. (#991)
- Auto-improve no longer aborts a whole run when the model fills in
`expected_base_body_sha256`. The field is computed by the server when a
patch is materialized, but the schema shows it to the model, and a
non-hash value on a full-page proposal reached staging and failed
`hex_to_sha256` with `invalid expected_base_body_sha256: expected 64 hex
chars` (HTTP 500 from `/admin/auto-improve`), discarding every other
proposal in the run. Normalisation now drops any model-supplied value; the
patch path still sets it from the materialized target. (#991)
## [2.4.2] - 2026-09-29
@@ -296,7 +296,12 @@ pub struct AutoImproveProposal {
#[serde(default, alias = "body", alias = "markdown", alias = "content")]
pub body_markdown: String,
/// `full_page` (default) or `patch`.
///
/// Advertised as an enum for the same reason as `operation`; still a
/// `String` so an unconstrained provider's `"full"` reaches
/// [`normalize_edit_mode`] instead of failing to deserialise.
#[serde(default = "default_edit_mode")]
#[schemars(extend("enum" = ["full_page", "patch"]))]
pub edit_mode: String,
/// Patch edits for existing _rules/ or procedures/ pages.
#[serde(default)]
@@ -359,6 +364,26 @@ fn default_edit_mode() -> String {
"full_page".into()
}
/// Map the ways a model spells the two supported edit modes onto their
/// canonical form.
///
/// Same narrow policy as [`normalize_operation`]: the system prompt says
/// "Full-page proposals", and models answer `"full"`. Unknown values are left
/// untouched so they still fail validation.
fn normalize_edit_mode(raw: &str) -> String {
let squashed: String = raw
.trim()
.to_ascii_lowercase()
.chars()
.filter(|c| !matches!(c, ' ' | '_' | '-'))
.collect();
match squashed.as_str() {
"" | "fullpage" | "full" => default_edit_mode(),
"patch" => "patch".into(),
_ => raw.to_string(),
}
}
/// A candidate the reviewer or validator rejected.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AutoImproveRejectedCandidate {
@@ -1486,8 +1511,18 @@ pub(crate) fn validate_response(
fn normalize_proposal(proposal: &mut AutoImproveProposal, warnings: &mut Vec<String>) {
normalize_kind(proposal, warnings);
if proposal.edit_mode.trim().is_empty() {
proposal.edit_mode = default_edit_mode();
// Server-owned: set when a patch is materialized. The schema still shows
// it to the model, and a model-supplied non-hash value on a full-page
// proposal would reach staging and fail the whole run on `hex_to_sha256`.
proposal.expected_base_body_sha256 = None;
let original_edit_mode = proposal.edit_mode.clone();
proposal.edit_mode = normalize_edit_mode(&original_edit_mode);
if !original_edit_mode.trim().is_empty() && proposal.edit_mode != original_edit_mode {
warnings.push(format!(
"proposal {} edit_mode normalized from {:?} to {:?}",
proposal.path, original_edit_mode, proposal.edit_mode
));
}
if proposal.edit_mode == "patch" {
return;
@@ -3264,6 +3299,66 @@ mod tests {
);
}
/// Same shape as #458 on the neighbouring field: `gpt-oss-20b` via LM
/// Studio answered `"edit_mode": "full"` for every candidate, so every run
/// ended with zero accepted proposals and only `unsupported_edit_mode`
/// rejections.
#[test]
fn a_proposal_saying_full_is_accepted_as_full_page() {
let mut candidate = proposal("gotchas/thing.md", "gotcha", 0.91);
candidate.edit_mode = "full".into();
let raw = AutoImproveLlmResponse {
summary: "ok".into(),
proposals: vec![candidate],
rejected_candidates: Vec::new(),
};
let (accepted, rejected, warnings) =
validate_response(raw, &cfg(), &ExistingPageIndex::default());
assert!(rejected.is_empty(), "got {rejected:?}");
assert_eq!(accepted.len(), 1);
assert_eq!(accepted[0].edit_mode, "full_page");
assert!(
warnings
.iter()
.any(|w| w.contains("edit_mode normalized from \"full\"")),
"normalisation should be reported, got {warnings:?}"
);
let mut unknown = proposal("gotchas/thing.md", "gotcha", 0.91);
unknown.edit_mode = "rewrite".into();
let raw = AutoImproveLlmResponse {
summary: "ok".into(),
proposals: vec![unknown],
rejected_candidates: Vec::new(),
};
let (accepted, rejected, _) = validate_response(raw, &cfg(), &ExistingPageIndex::default());
assert!(accepted.is_empty());
assert_eq!(rejected[0].reason, "unsupported_edit_mode");
}
/// Once `"full"` stopped being rejected, the same `gpt-oss-20b` runs
/// failed at staging with `invalid expected_base_body_sha256: expected 64
/// hex chars`: the model filled in a field only the server can compute.
#[test]
fn a_model_supplied_base_sha_is_dropped_from_full_page_proposals() {
for supplied in ["", "null", "abc123", "N/A"] {
let mut candidate = proposal("gotchas/thing.md", "gotcha", 0.91);
candidate.expected_base_body_sha256 = Some(supplied.into());
let raw = AutoImproveLlmResponse {
summary: "ok".into(),
proposals: vec![candidate],
rejected_candidates: Vec::new(),
};
let (accepted, rejected, _) =
validate_response(raw, &cfg(), &ExistingPageIndex::default());
assert!(rejected.is_empty(), "{supplied:?}: got {rejected:?}");
assert_eq!(
accepted[0].expected_base_body_sha256, None,
"{supplied:?} must not survive to staging"
);
}
}
#[test]
fn patch_to_missing_or_non_context_target_rejects() {
let raw = AutoImproveLlmResponse {
@@ -3673,3 +3768,64 @@ mod operation_normalization_tests {
assert_eq!(parsed.operation, "create");
}
}
#[cfg(test)]
mod edit_mode_normalization_tests {
use super::*;
#[test]
fn the_spellings_models_actually_emit_are_accepted() {
for raw in [
"full_page",
"full",
"Full",
"full-page",
"Full Page",
"FULLPAGE",
"",
" ",
] {
assert_eq!(
normalize_edit_mode(raw),
"full_page",
"{raw:?} means full page and must normalise"
);
}
for raw in ["patch", "PATCH", " Patch "] {
assert_eq!(normalize_edit_mode(raw), "patch", "{raw:?} must normalise");
}
}
#[test]
fn unknown_edit_modes_are_left_to_fail() {
for raw in ["rewrite", "replace", "append", "diff", "delete"] {
assert_eq!(
normalize_edit_mode(raw),
raw,
"{raw:?} is not a supported mode and must keep failing validation"
);
}
}
#[test]
fn schema_constrains_edit_mode_to_the_supported_values() {
let schema = schemars::schema_for!(AutoImproveProposal);
let value = serde_json::to_value(&schema).expect("schema serialises");
let variants = value
.pointer("/properties/edit_mode/enum")
.and_then(|e| e.as_array())
.expect("edit_mode carries an enum constraint");
assert_eq!(
variants,
&vec![serde_json::json!("full_page"), serde_json::json!("patch")]
);
}
#[test]
fn a_non_canonical_edit_mode_still_deserialises() {
let parsed: AutoImproveProposal =
serde_json::from_value(serde_json::json!({ "edit_mode": "full" }))
.expect("must not fail to parse; normalisation happens before validation");
assert_eq!(parsed.edit_mode, "full");
}
}