Skip to content

Commit 4d077dd

Browse files
authored
fix(agent): enforce robot search --fields output contract Refs #1735
1 parent 960ab45 commit 4d077dd

1 file changed

Lines changed: 126 additions & 8 deletions

File tree

crates/terraphim_agent/src/robot/output.rs

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,17 +210,56 @@ impl RobotFormatter {
210210
Self { config }
211211
}
212212

213-
/// Format a value as output string
213+
/// Format a value as output string, applying field-mode filtering
214+
/// when the value is a search response with results.
214215
pub fn format<T: Serialize>(&self, value: &T) -> Result<String, serde_json::Error> {
215-
match self.config.format {
216-
OutputFormat::Json => serde_json::to_string_pretty(value),
217-
OutputFormat::Jsonl | OutputFormat::Minimal => serde_json::to_string(value),
218-
OutputFormat::Table => {
219-
// For table format, we still return JSON but it's not used
220-
// The caller should handle table formatting separately
221-
serde_json::to_string_pretty(value)
216+
let filtered = self.apply_fields(value);
217+
let output = match self.config.format {
218+
OutputFormat::Json => serde_json::to_string_pretty(&filtered),
219+
OutputFormat::Jsonl | OutputFormat::Minimal => serde_json::to_string(&filtered),
220+
OutputFormat::Table => serde_json::to_string_pretty(&filtered),
221+
};
222+
output
223+
}
224+
225+
/// Apply field-mode filtering. For non-`Full` modes, drops fields
226+
/// from each item in `data.results` that are not in the allowed set.
227+
/// Returns the original value unchanged when the value is not a
228+
/// search response or serialization fails.
229+
fn apply_fields<T: Serialize>(&self, value: &T) -> serde_json::Value {
230+
if matches!(self.config.fields, FieldMode::Full) {
231+
return serde_json::to_value(value).unwrap_or(serde_json::Value::Null);
232+
}
233+
let mut v = match serde_json::to_value(value) {
234+
Ok(v) => v,
235+
Err(_) => return serde_json::Value::Null,
236+
};
237+
if let Some(results) = v.get_mut("data").and_then(|d| d.get_mut("results")) {
238+
if let Some(arr) = results.as_array_mut() {
239+
let keep: Vec<&str> = match &self.config.fields {
240+
FieldMode::Full => unreachable!(),
241+
FieldMode::Summary => vec![
242+
"rank",
243+
"id",
244+
"title",
245+
"url",
246+
"score",
247+
"preview",
248+
"source",
249+
"date",
250+
"preview_truncated",
251+
],
252+
FieldMode::Minimal => vec!["rank", "id", "title", "url", "score"],
253+
FieldMode::Custom(fields) => fields.iter().map(|s| s.as_str()).collect(),
254+
};
255+
for item in arr {
256+
if let Some(obj) = item.as_object_mut() {
257+
obj.retain(|k, _| keep.contains(&k.as_str()));
258+
}
259+
}
222260
}
223261
}
262+
v
224263
}
225264

226265
/// Format multiple values as JSONL
@@ -501,6 +540,85 @@ mod tests {
501540
assert!(was_truncated);
502541
assert!(truncated.starts_with("abcde"));
503542
}
543+
544+
#[test]
545+
fn test_fields_full_includes_all() {
546+
let config = RobotConfig::new().with_fields(FieldMode::Full);
547+
let formatter = RobotFormatter::new(config);
548+
let data = serde_json::json!({
549+
"data": {
550+
"results": [
551+
{"rank": 1, "id": "a", "title": "T", "url": "u", "score": 0.9, "preview": "p"}
552+
]
553+
}
554+
});
555+
let output = formatter.format(&data).unwrap();
556+
assert!(output.contains("\"preview\""));
557+
assert!(output.contains("\"rank\""));
558+
}
559+
560+
#[test]
561+
fn test_fields_minimal_excludes_preview() {
562+
let config = RobotConfig::new().with_fields(FieldMode::Minimal);
563+
let formatter = RobotFormatter::new(config);
564+
let data = serde_json::json!({
565+
"data": {
566+
"results": [
567+
{"rank": 1, "id": "a", "title": "T", "url": "u", "score": 0.9, "preview": "p"}
568+
]
569+
}
570+
});
571+
let output = formatter.format(&data).unwrap();
572+
assert!(!output.contains("\"preview\""));
573+
assert!(output.contains("\"rank\""));
574+
assert!(output.contains("\"id\""));
575+
}
576+
577+
#[test]
578+
fn test_fields_summary_includes_preview_excludes_body() {
579+
let config = RobotConfig::new().with_fields(FieldMode::Summary);
580+
let formatter = RobotFormatter::new(config);
581+
let data = serde_json::json!({
582+
"data": {
583+
"results": [
584+
{"rank": 1, "id": "a", "title": "T", "preview": "p", "body": "full text"}
585+
]
586+
}
587+
});
588+
let output = formatter.format(&data).unwrap();
589+
assert!(output.contains("\"preview\""));
590+
assert!(!output.contains("\"body\""));
591+
}
592+
593+
#[test]
594+
fn test_fields_custom_only_keeps_named() {
595+
let config =
596+
RobotConfig::new().with_fields(FieldMode::Custom(vec!["rank".into(), "id".into()]));
597+
let formatter = RobotFormatter::new(config);
598+
let data = serde_json::json!({
599+
"data": {
600+
"results": [
601+
{"rank": 1, "id": "a", "title": "T", "url": "u", "score": 0.9}
602+
]
603+
}
604+
});
605+
let output = formatter.format(&data).unwrap();
606+
assert!(output.contains("\"rank\""));
607+
assert!(output.contains("\"id\""));
608+
assert!(!output.contains("\"title\""));
609+
assert!(!output.contains("\"url\""));
610+
assert!(!output.contains("\"score\""));
611+
}
612+
613+
#[test]
614+
fn test_fields_full_noop_on_non_search_response() {
615+
let config = RobotConfig::new().with_fields(FieldMode::Minimal);
616+
let formatter = RobotFormatter::new(config);
617+
let data = serde_json::json!({"status": "ok", "msg": "hello"});
618+
let output = formatter.format(&data).unwrap();
619+
assert!(output.contains("\"msg\""));
620+
assert!(output.contains("\"status\""));
621+
}
504622
}
505623

506624
#[cfg(test)]

0 commit comments

Comments
 (0)