Skip to content

Commit 37e56ba

Browse files
committed
mason: normalize empty edit mode sentinels
1 parent 5644b17 commit 37e56ba

5 files changed

Lines changed: 374 additions & 15 deletions

File tree

crates/aft/src/subc_translate.rs

Lines changed: 197 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ fn normalize_edit_arguments(map: &mut Map<String, Value>) -> Result<(), Translat
188188
}
189189

190190
let modes = edit_modes_present(map);
191+
if has_orphaned_symbol_content(map) {
192+
return Err(invalid_request(
193+
"edit: 'content' requires a non-empty string 'symbol' when symbol mode is selected",
194+
));
195+
}
191196
if modes.len() > 1 {
192197
return Err(invalid_request(format!(
193198
"edit: conflicting modes: {}",
@@ -264,26 +269,82 @@ fn normalize_edit_path_alias(map: &mut Map<String, Value>) -> Result<(), Transla
264269
}
265270
}
266271

267-
fn edit_modes_present(map: &Map<String, Value>) -> Vec<&'static str> {
272+
fn edit_modes_present(map: &mut Map<String, Value>) -> Vec<&'static str> {
273+
// Some hosts serialize every optional field with an empty sentinel. Remove
274+
// fields that cannot select a mode so later translation cannot revive them.
275+
let has_append_content = is_non_empty_string(map.get("appendContent"));
276+
if !has_append_content {
277+
map.remove("appendContent");
278+
}
279+
280+
let has_edits = is_non_empty_edit_array(map.get("edits"));
281+
if !has_edits {
282+
map.remove("edits");
283+
}
284+
285+
let has_symbol = is_non_empty_string(map.get("symbol"));
286+
if !has_symbol {
287+
map.remove("symbol");
288+
if map.get("content").is_some_and(|value| {
289+
value.is_null() || matches!(value, Value::String(value) if value.is_empty())
290+
}) {
291+
map.remove("content");
292+
}
293+
} else if matches!(map.get("content"), Some(Value::Null)) {
294+
map.remove("content");
295+
}
296+
297+
let has_single_edit = is_non_empty_string(map.get("oldString"));
298+
if !has_single_edit {
299+
for key in ["oldString", "newString", "replaceAll", "occurrence"] {
300+
map.remove(key);
301+
}
302+
} else {
303+
for key in ["newString", "replaceAll", "occurrence"] {
304+
if matches!(map.get(key), Some(Value::Null)) {
305+
map.remove(key);
306+
}
307+
}
308+
}
309+
268310
let mut modes = Vec::new();
269-
if map.contains_key("appendContent") {
311+
if has_append_content {
270312
modes.push("appendContent");
271313
}
272-
if map.contains_key("edits") {
314+
if has_edits {
273315
modes.push("edits");
274316
}
275-
if map.contains_key("symbol") || map.contains_key("content") {
317+
if has_symbol {
276318
modes.push("symbol/content");
277319
}
278-
if ["oldString", "newString", "replaceAll", "occurrence"]
279-
.iter()
280-
.any(|key| map.contains_key(*key))
281-
{
320+
if has_single_edit {
282321
modes.push("oldString/newString");
283322
}
284323
modes
285324
}
286325

326+
fn is_non_empty_string(value: Option<&Value>) -> bool {
327+
matches!(value, Some(Value::String(value)) if !value.is_empty())
328+
}
329+
330+
fn is_non_empty_edit_array(value: Option<&Value>) -> bool {
331+
match value {
332+
Some(Value::Array(items)) => !items.is_empty(),
333+
Some(Value::String(raw)) if raw.is_empty() => false,
334+
Some(Value::String(raw)) => serde_json::from_str::<Value>(raw)
335+
.ok()
336+
.map(|value| !matches!(value, Value::Array(items) if items.is_empty()))
337+
// A non-empty malformed string is still an edits claim so the
338+
// existing parser can report its specific validation error.
339+
.unwrap_or(true),
340+
_ => false,
341+
}
342+
}
343+
344+
fn has_orphaned_symbol_content(map: &Map<String, Value>) -> bool {
345+
is_non_empty_string(map.get("content")) && !is_non_empty_string(map.get("symbol"))
346+
}
347+
287348
fn format_unknown_keys(mut keys: Vec<String>) -> String {
288349
keys.sort();
289350
format!(
@@ -2334,9 +2395,137 @@ mod tests {
23342395
);
23352396
}
23362397

2398+
#[test]
2399+
fn edit_normalization_uses_meaningful_mode_presence() {
2400+
let project = Path::new("/project");
2401+
let cases = [
2402+
(
2403+
"edits ignores empty mode sentinels",
2404+
serde_json::json!({
2405+
"filePath": "src/example.ts",
2406+
"edits": [{ "oldString": "old", "newString": "new" }],
2407+
"appendContent": "",
2408+
"symbol": "",
2409+
"content": "",
2410+
}),
2411+
Some("batch"),
2412+
None,
2413+
),
2414+
(
2415+
"append ignores empty edits",
2416+
serde_json::json!({
2417+
"filePath": "src/example.ts",
2418+
"appendContent": "append",
2419+
"edits": [],
2420+
}),
2421+
Some("edit_match"),
2422+
None,
2423+
),
2424+
(
2425+
"symbol deletion keeps empty content",
2426+
serde_json::json!({
2427+
"filePath": "src/example.ts",
2428+
"symbol": "target",
2429+
"content": "",
2430+
}),
2431+
Some("edit_symbol"),
2432+
None,
2433+
),
2434+
(
2435+
"content without a symbol is rejected",
2436+
serde_json::json!({
2437+
"filePath": "src/example.ts",
2438+
"symbol": "",
2439+
"content": "replacement",
2440+
}),
2441+
None,
2442+
Some("requires a non-empty string 'symbol'"),
2443+
),
2444+
(
2445+
"two real modes conflict",
2446+
serde_json::json!({
2447+
"filePath": "src/example.ts",
2448+
"appendContent": "append",
2449+
"edits": [{ "oldString": "old", "newString": "new" }],
2450+
}),
2451+
None,
2452+
Some("conflicting modes"),
2453+
),
2454+
(
2455+
"all empty fields have no mode",
2456+
serde_json::json!({
2457+
"filePath": "src/example.ts",
2458+
"appendContent": "",
2459+
"edits": [],
2460+
"symbol": "",
2461+
"content": "",
2462+
"oldString": "",
2463+
"newString": "",
2464+
"replaceAll": null,
2465+
"occurrence": null,
2466+
}),
2467+
None,
2468+
Some("exactly one of"),
2469+
),
2470+
];
2471+
2472+
for (label, arguments, command, expected_error) in cases {
2473+
match (command, expected_error) {
2474+
(Some(command), None) => {
2475+
let translated = subc_translate_owned("edit", arguments, project)
2476+
.unwrap_or_else(|error| panic!("{label}: {}", error.message));
2477+
assert_eq!(translated.command, command, "{label}");
2478+
match label {
2479+
"edits ignores empty mode sentinels" => {
2480+
assert_eq!(
2481+
translated.args["edits"][0]["match"],
2482+
Value::String("old".to_string())
2483+
);
2484+
assert_eq!(
2485+
translated.args["edits"][0]["replacement"],
2486+
Value::String("new".to_string())
2487+
);
2488+
}
2489+
"append ignores empty edits" => {
2490+
assert_eq!(
2491+
translated.args["append_content"],
2492+
Value::String("append".to_string())
2493+
);
2494+
}
2495+
"symbol deletion keeps empty content" => {
2496+
assert_eq!(translated.args["content"], Value::String(String::new()));
2497+
}
2498+
_ => unreachable!("unexpected successful edit mode case"),
2499+
}
2500+
}
2501+
(None, Some(expected_error)) => {
2502+
let translation_error = subc_translate_owned("edit", arguments, project)
2503+
.expect_err("meaningful mode case must fail");
2504+
assert!(
2505+
translation_error.message.contains(expected_error),
2506+
"{label}: {}",
2507+
translation_error.message
2508+
);
2509+
}
2510+
_ => unreachable!("case must expect exactly one outcome"),
2511+
}
2512+
}
2513+
}
2514+
23372515
#[test]
23382516
fn edit_normalization_accepts_aliases_and_rejects_ambiguous_scalars() {
23392517
let project = Path::new("/project");
2518+
let stringified = subc_translate_owned(
2519+
"edit",
2520+
serde_json::json!({
2521+
"path": "src/main.ts",
2522+
"edits": "[{\"oldString\":\"before\",\"newString\":\"after\"}]"
2523+
}),
2524+
project,
2525+
)
2526+
.expect("stringified non-empty edits array");
2527+
assert_eq!(stringified.command, "batch");
2528+
23402529
let normalized = subc_translate_owned(
23412530
"edit",
23422531
serde_json::json!({

crates/aft/tests/integration/tool_call_parity_test.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,40 @@ fn edit_contract_translation_preserves_exact_code_then_message() {
234234
assert!(aft.shutdown().success());
235235
}
236236

237+
#[test]
238+
fn edit_tool_call_applies_edits_with_empty_optional_mode_sentinels() {
239+
let project = tempfile::tempdir().expect("tool_call edit temp project");
240+
let target = project.path().join("src/example.ts");
241+
fs::create_dir_all(target.parent().expect("example parent")).expect("create src directory");
242+
fs::write(&target, "const value = old;\n").expect("write edit fixture");
243+
244+
let mut aft = AftProcess::spawn();
245+
configure_project(&mut aft, project.path(), "cfg-edit-empty-sentinels");
246+
let response = send_json(
247+
&mut aft,
248+
json!({
249+
"id": "tool-call-edit-empty-sentinels",
250+
"command": "tool_call",
251+
"session_id": SESSION_ID,
252+
"name": "edit",
253+
"arguments": {
254+
"filePath": "src/example.ts",
255+
"edits": [{ "oldString": "old", "newString": "new" }],
256+
"appendContent": "",
257+
"symbol": "",
258+
"content": "",
259+
},
260+
}),
261+
);
262+
263+
assert_eq!(response["success"], true, "edit failed: {response:#}");
264+
assert_eq!(
265+
fs::read_to_string(target).expect("read edited fixture"),
266+
"const value = new;\n"
267+
);
268+
assert!(aft.shutdown().success());
269+
}
270+
237271
#[test]
238272
fn unsupported_translate_tools_still_raw_dispatch_native_commands() {
239273
let project = tempfile::tempdir().expect("tool_call configure temp project");

packages/aft-bridge/src/__tests__/path-aliases.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,81 @@ describe("canonical path alias preparation", () => {
107107
});
108108

109109
describe("edit boundary preparation", () => {
110+
const meaningfulModeCases: Array<{
111+
label: string;
112+
input: Record<string, unknown>;
113+
expected?: Record<string, unknown>;
114+
error?: string;
115+
}> = [
116+
{
117+
label: "edits ignores empty mode sentinels",
118+
input: {
119+
filePath: "src/example.ts",
120+
edits: [{ oldString: "old", newString: "new" }],
121+
appendContent: "",
122+
symbol: "",
123+
content: "",
124+
},
125+
expected: {
126+
path: "src/example.ts",
127+
edits: [{ oldString: "old", newString: "new" }],
128+
},
129+
},
130+
{
131+
label: "append ignores empty edits",
132+
input: {
133+
filePath: "src/example.ts",
134+
appendContent: "append",
135+
edits: [],
136+
},
137+
expected: { path: "src/example.ts", appendContent: "append" },
138+
},
139+
{
140+
label: "symbol deletion keeps empty content",
141+
input: { filePath: "src/example.ts", symbol: "target", content: "" },
142+
expected: { path: "src/example.ts", symbol: "target", content: "" },
143+
},
144+
{
145+
label: "content without a symbol is rejected",
146+
input: { filePath: "src/example.ts", symbol: "", content: "replacement" },
147+
error: "requires a non-empty string 'symbol'",
148+
},
149+
{
150+
label: "two real modes conflict",
151+
input: {
152+
filePath: "src/example.ts",
153+
appendContent: "append",
154+
edits: [{ oldString: "old", newString: "new" }],
155+
},
156+
error: "conflicting modes",
157+
},
158+
{
159+
label: "all empty fields have no mode",
160+
input: {
161+
filePath: "src/example.ts",
162+
appendContent: "",
163+
edits: [],
164+
symbol: "",
165+
content: "",
166+
oldString: "",
167+
newString: "",
168+
replaceAll: null,
169+
occurrence: null,
170+
},
171+
error: "exactly one of",
172+
},
173+
];
174+
175+
for (const { label, input, expected, error } of meaningfulModeCases) {
176+
test(label, () => {
177+
if (error) {
178+
expect(() => prepareCanonicalEditArguments("edit", input)).toThrow(error);
179+
} else {
180+
expect(prepareCanonicalEditArguments("edit", input)).toEqual(expected);
181+
}
182+
});
183+
}
184+
110185
test("applies mode conflict precedence before parsing stringified edits", () => {
111186
expect(() =>
112187
prepareCanonicalEditArguments("edit", {
@@ -122,12 +197,21 @@ describe("edit boundary preparation", () => {
122197
edits: "not-json",
123198
}),
124199
).toThrow("valid JSON");
200+
expect(
201+
prepareCanonicalEditArguments("edit", {
202+
path: "src/main.ts",
203+
edits: '[{"oldString":"before","newString":"after"}]',
204+
}),
205+
).toEqual({
206+
path: "src/main.ts",
207+
edits: [{ oldString: "before", newString: "after" }],
208+
});
125209
expect(() =>
126210
prepareCanonicalEditArguments("edit", {
127211
path: "src/main.ts",
128212
edits: "[]",
129213
}),
130-
).toThrow("must not be empty");
214+
).toThrow("exactly one of");
131215
});
132216

133217
test("keeps canonical-only path validation after edit contract validation", () => {

0 commit comments

Comments
 (0)