1use std::collections::HashSet;
12
13use khive_types::VerbPresentationPolicy;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum NoteContentScope {
21 #[default]
22 None,
23 Record,
24 Items,
25 Notes,
26}
27
28impl NoteContentScope {
29 pub fn protect(self, mut value: Value, transform: impl FnOnce(Value) -> Value) -> Value {
33 if self == Self::None {
34 return transform(value);
35 }
36 fn take_content(record: &mut Value) -> Option<Value> {
37 record.as_object_mut()?.remove("content")
38 }
39 fn restore_content(record: &mut Value, content: Option<Value>) {
40 if let (Some(record), Some(content)) = (record.as_object_mut(), content) {
41 record.insert("content".to_string(), content);
42 }
43 }
44 if self == Self::Record {
45 let content = take_content(&mut value);
46 let mut value = transform(value);
47 restore_content(&mut value, content);
48 return value;
49 }
50 let key = if self == Self::Items {
51 "items"
52 } else {
53 "notes"
54 };
55 let contents: Vec<_> = value
56 .get_mut(key)
57 .and_then(Value::as_array_mut)
58 .into_iter()
59 .flatten()
60 .map(take_content)
61 .collect();
62 let mut value = transform(value);
63 if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
64 for (record, content) in records.iter_mut().zip(contents) {
65 restore_content(record, content);
66 }
67 }
68 value
69 }
70
71 fn stringify_table_content(self, value: &mut Value) {
72 fn stringify(record: &mut Value) {
73 if let Some(content) = record.get_mut("content") {
74 if content.is_object() || content.is_array() {
75 *content = Value::String(content.to_string());
76 }
77 }
78 }
79 match self {
80 Self::None => {}
81 Self::Record => stringify(value),
82 Self::Items | Self::Notes => {
83 let key = if self == Self::Items {
84 "items"
85 } else {
86 "notes"
87 };
88 if let Some(records) = value.get_mut(key).and_then(Value::as_array_mut) {
89 records.iter_mut().for_each(stringify);
90 }
91 }
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "snake_case")]
116pub enum OutputFormat {
117 #[default]
121 Json,
122 Auto,
126 Table,
130}
131
132const CELL_TRUNCATE: usize = 120;
134
135const PROPERTY_HOIST_FIELDS: &[&str] = &["trigger_at", "due", "status"];
141
142pub fn render_format(value: Value, format: OutputFormat, presentation: PresentationMode) -> String {
160 render_format_with_note_content(value, format, presentation, NoteContentScope::None)
161}
162
163pub fn render_format_with_note_content(
167 value: Value,
168 format: OutputFormat,
169 presentation: PresentationMode,
170 content_scope: NoteContentScope,
171) -> String {
172 let value = prepare_format_value_with_note_content(value, format, presentation, content_scope);
173 match format {
174 OutputFormat::Json => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
175 OutputFormat::Auto if content_scope != NoteContentScope::None => {
176 serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
177 }
178 OutputFormat::Auto | OutputFormat::Table => {
179 render_auto(value, presentation != PresentationMode::Verbose)
180 }
181 }
182}
183
184pub fn prepare_format_value(
191 value: Value,
192 format: OutputFormat,
193 presentation: PresentationMode,
194) -> Value {
195 let scope = match format {
196 OutputFormat::Json if presentation == PresentationMode::Agent => {
197 Some(RedundancyScope::Machine)
198 }
199 OutputFormat::Json => None,
200 OutputFormat::Auto | OutputFormat::Table if presentation != PresentationMode::Verbose => {
201 Some(RedundancyScope::View)
202 }
203 OutputFormat::Auto | OutputFormat::Table => None,
204 };
205 match scope {
206 Some(scope) => apply_redundancy_drop(value, scope),
207 None => value,
208 }
209}
210
211pub fn prepare_format_value_with_note_content(
213 value: Value,
214 format: OutputFormat,
215 presentation: PresentationMode,
216 content_scope: NoteContentScope,
217) -> Value {
218 let mut value = content_scope.protect(value, |value| {
219 prepare_format_value(value, format, presentation)
220 });
221 if format == OutputFormat::Table {
222 content_scope.stringify_table_content(&mut value);
223 }
224 value
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum RedundancyScope {
247 View,
248 Machine,
249}
250
251pub fn apply_redundancy_drop(value: Value, scope: RedundancyScope) -> Value {
262 match value {
263 Value::Object(_) => drop_record(value, scope),
264 Value::Array(arr) => Value::Array(
265 arr.into_iter()
266 .map(|v| {
267 if v.is_object() {
268 drop_record(v, scope)
269 } else {
270 v
271 }
272 })
273 .collect(),
274 ),
275 other => other,
276 }
277}
278
279fn drop_record(value: Value, scope: RedundancyScope) -> Value {
281 let Value::Object(mut map) = value else {
282 return value;
283 };
284
285 if scope == RedundancyScope::View {
289 map.remove("full_id");
290 }
291
292 if map.get("namespace").and_then(Value::as_str) == Some("local") {
294 map.remove("namespace");
295 }
296
297 let props_val = map.remove("properties");
309 if let Some(Value::Object(props)) = props_val {
310 let mut new_props = Map::new();
311 for (k, v) in props {
312 let strict_round_trip = scope == RedundancyScope::Machine
313 && (k == "full_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&k.as_str()));
314 if !strict_round_trip && map.get(&k) == Some(&v) {
315 continue;
316 }
317 new_props.insert(k, v);
318 }
319 if !new_props.is_empty() {
320 map.insert("properties".to_string(), Value::Object(new_props));
321 }
322 } else if let Some(other) = props_val {
323 map.insert("properties".to_string(), other);
324 }
325
326 let stream_entry = is_stream_entry(&map);
329 let out: Map<String, Value> = map
331 .into_iter()
332 .map(|(k, v)| {
333 if stream_entry && k == "record" {
334 return (k, v);
335 }
336 let v = match v {
337 Value::Array(arr) => Value::Array(
338 arr.into_iter()
339 .map(|item| {
340 if item.is_object() {
341 drop_record(item, scope)
342 } else {
343 item
344 }
345 })
346 .collect(),
347 ),
348 other => other,
349 };
350 (k, v)
351 })
352 .collect();
353 Value::Object(out)
354}
355
356struct RecordArray {
362 key: Option<String>,
363 records: Vec<Value>,
364 columns: Vec<String>,
365}
366
367fn render_auto(value: Value, truncate: bool) -> String {
375 match locate_record_array(&value) {
376 Some(found) => render_table_with_siblings(&value, &found, truncate),
377 None => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
378 }
379}
380
381fn locate_record_array(value: &Value) -> Option<RecordArray> {
387 let build = |key: Option<String>, arr: &[Value]| {
388 let records: Vec<Value> = arr.iter().cloned().map(hoist_table_scalars).collect();
389 let columns = collect_keys(&records);
390 RecordArray {
391 key,
392 records,
393 columns,
394 }
395 };
396 match value {
397 Value::Array(arr) if is_record_array(arr) => Some(build(None, arr)),
398 Value::Object(map) => map.iter().find_map(|(k, v)| match v {
399 Value::Array(arr) if is_record_array(arr) => Some(build(Some(k.clone()), arr)),
400 _ => None,
401 }),
402 _ => None,
403 }
404}
405
406fn hoist_table_scalars(record: Value) -> Value {
413 let Value::Object(mut map) = record else {
414 return record;
415 };
416 let mut props = match map.remove("properties") {
417 Some(Value::Object(props)) => props,
418 Some(other) => {
419 map.insert("properties".to_string(), other);
421 return Value::Object(map);
422 }
423 None => return Value::Object(map),
424 };
425 for field in PROPERTY_HOIST_FIELDS {
426 if map.contains_key(*field) {
427 continue;
428 }
429 if props
430 .get(*field)
431 .is_some_and(|v| !v.is_object() && !v.is_array())
432 {
433 let v = props.remove(*field).expect("checked above");
434 map.insert((*field).to_string(), v);
435 }
436 }
437 if !props.is_empty() {
438 map.insert("properties".to_string(), Value::Object(props));
439 }
440 Value::Object(map)
441}
442
443fn is_record_array(arr: &[Value]) -> bool {
445 arr.len() >= 2 && arr.iter().all(Value::is_object)
446}
447
448fn render_table_with_siblings(value: &Value, found: &RecordArray, truncate: bool) -> String {
453 let mut out = render_table(&found.records, &found.columns, truncate);
454 let (Value::Object(map), Some(array_key)) = (value, &found.key) else {
455 return out;
456 };
457 for (k, v) in map {
458 if k == array_key {
459 continue;
460 }
461 let text = match v {
462 Value::String(s) if !s.contains(['\n', '\r']) => s.clone(),
466 other => serde_json::to_string(other).unwrap_or_default(),
469 };
470 out.push_str(&format!("{k}: {text}\n"));
471 }
472 out
473}
474
475fn collect_keys(records: &[Value]) -> Vec<String> {
477 let mut seen = HashSet::new();
478 let mut keys = Vec::new();
479 for record in records {
480 if let Value::Object(map) = record {
481 for k in map.keys() {
482 if seen.insert(k.clone()) {
483 keys.push(k.clone());
484 }
485 }
486 }
487 }
488 keys
489}
490
491fn render_table(records: &[Value], keys: &[String], truncate: bool) -> String {
495 let mut out = String::new();
496
497 out.push('|');
498 for k in keys {
499 out.push(' ');
500 out.push_str(k);
501 out.push_str(" |");
502 }
503 out.push('\n');
504
505 out.push('|');
506 for _ in keys {
507 out.push_str("---|");
508 }
509 out.push('\n');
510
511 for record in records {
512 out.push('|');
513 for k in keys {
514 let cell = record.get(k).unwrap_or(&Value::Null);
515 let text = cell_text(k, cell, truncate);
516 out.push(' ');
517 out.push_str(&text);
518 out.push_str(" |");
519 }
520 out.push('\n');
521 }
522
523 out
524}
525
526fn truncation_exempt(key: &str) -> bool {
530 matches!(
531 key,
532 "id" | "kind"
533 | "status"
534 | "priority"
535 | "relation"
536 | "title"
537 | "name"
538 | "signature"
539 | "slug"
540 | "assignee"
541 | "from"
542 | "to"
543 | "due"
544 ) || key.ends_with("_id")
545 || key.ends_with("_at")
546 || key.starts_with("due")
547}
548
549fn cell_text(key: &str, value: &Value, truncate: bool) -> String {
556 let raw = match value {
557 Value::Null => String::new(),
558 Value::Bool(b) => b.to_string(),
559 Value::Number(n) => n.to_string(),
560 Value::String(s) => s.clone(),
561 Value::Object(_) => return "{…}".to_string(),
562 Value::Array(arr) => {
563 if arr.iter().any(|v| v.is_object() || v.is_array()) {
564 return "[…]".to_string();
565 }
566 serde_json::to_string(value).unwrap_or_default()
568 }
569 };
570
571 let escaped = raw.replace('|', "\\|").replace(['\n', '\r'], " ");
573
574 if !truncate || truncation_exempt(key) {
575 return escaped;
576 }
577
578 let char_count = escaped.chars().count();
581 if char_count > CELL_TRUNCATE {
582 let truncated: String = escaped.chars().take(CELL_TRUNCATE).collect();
583 format!("{truncated}...")
584 } else {
585 escaped
586 }
587}
588
589pub fn micros_to_iso(micros: i64) -> String {
596 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
597 .unwrap_or_else(chrono::Utc::now)
598 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
599}
600
601pub fn rfc3339_to_utc_micros(raw: &str) -> Result<i64, chrono::ParseError> {
609 chrono::DateTime::parse_from_rfc3339(raw.trim()).map(|dt| dt.timestamp_micros())
610}
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
614#[serde(rename_all = "snake_case")]
615pub enum PresentationMode {
616 #[default]
622 Agent,
623 Verbose,
627 Human,
634}
635
636const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
642 "completed_at",
643 "deleted_at",
644 "due_at",
645 "read_at",
646 "started_at",
647 "superseded_at",
648 "applied_at",
649 "withdrawn_at",
650 "reviewed_at",
651 "parent_id",
652 "superseded_by",
653 "replaced_by",
654 "poll_interval_secs",
655 "stalled",
656 "last_success_at",
657 "last_poll_attempt_at",
658 "last_failure_at",
659 "last_error",
660 "consecutive_failures",
661];
662
663const EMPTY_ARRAY_PRESERVE: &[&str] = &[
668 "items",
669 "entities",
670 "notes",
671 "edges",
672 "results",
673 "entries",
674 "neighbors",
675];
676
677fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
678 map.contains_key("requested_limit")
679 && map.contains_key("effective_limit")
680 && map.contains_key("limit_clamped")
681 && EMPTY_ARRAY_PRESERVE
682 .iter()
683 .any(|field| map.contains_key(*field))
684}
685
686fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
693 map.contains_key("next_after")
694 && (map.get("results").is_some_and(Value::is_array)
695 || (map.get("head_seq").is_some_and(Value::is_number)
696 && map.get("entries").is_some_and(Value::is_array)))
697}
698
699fn is_stream_entry(map: &Map<String, Value>) -> bool {
700 map.get("seq").is_some_and(Value::is_number)
701 && map.contains_key("id")
702 && map.contains_key("created_at")
703 && map.contains_key("record")
704}
705
706const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
723
724const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
734 "context_entity_id",
735 "thread_id",
736 "outbound_ref",
737 "parent_id",
738 "session_id",
739 "project_id",
740];
741
742const SCORE_FIELDS: &[&str] = &[
744 "score",
745 "rank_score",
746 "vector_similarity",
747 "keyword_score",
748 "salience",
749 "decay_factor",
750 "rrf_score",
751 "similarity",
752 "cross_encoder_score",
753 "graph_proximity_score",
754];
755
756const UUID_CANONICAL_LEN: usize = 36;
758
759fn should_shorten_uuid_field(key: &str) -> bool {
767 if key == "full_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key) {
768 return false;
769 }
770 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
771}
772
773pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
783 present_with_policy(
784 value,
785 mode,
786 now_unix_seconds,
787 VerbPresentationPolicy::Standard,
788 )
789}
790
791pub fn present_with_policy(
794 value: Value,
795 mode: PresentationMode,
796 now_unix_seconds: i64,
797 policy: VerbPresentationPolicy,
798) -> Value {
799 if policy == VerbPresentationPolicy::AlwaysVerbose {
800 return value;
801 }
802 match mode {
803 PresentationMode::Verbose | PresentationMode::Human => value,
804 PresentationMode::Agent => {
805 let preserved_nulls: HashSet<&str> = LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
806 let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
807 let payload_timestamps: HashSet<&str> =
808 PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect();
809 transform_agent(
810 value,
811 &preserved_nulls,
812 &score_fields,
813 &payload_timestamps,
814 now_unix_seconds,
815 false,
816 match policy {
817 VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
818 VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
819 _ => ReceiptContext::None,
820 },
821 )
822 }
823 }
824}
825
826#[derive(Clone, Copy, PartialEq, Eq)]
827enum ReceiptContext {
828 None,
829 AppendRoot,
830 BatchRoot,
831 BatchResults,
832 BatchMember,
833}
834
835fn transform_agent(
841 value: Value,
842 preserved_nulls: &HashSet<&str>,
843 scores: &HashSet<&str>,
844 payload_timestamps: &HashSet<&str>,
845 now: i64,
846 inside_properties: bool,
847 receipt_context: ReceiptContext,
848) -> Value {
849 match value {
850 Value::Object(map) => {
851 let preserve_list_envelope =
852 is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
853 let stream_entry = is_stream_entry(&map);
854 let mut out = Map::new();
855 for (k, v) in map {
856 if stream_entry && k == "record" {
857 out.insert(k, v);
858 continue;
859 }
860 if v.is_string()
861 && ((receipt_context == ReceiptContext::AppendRoot && k == "created_at")
862 || (receipt_context == ReceiptContext::BatchMember
863 && matches!(k.as_str(), "created_at" | "updated_at")))
864 {
865 out.insert(k, v);
866 continue;
867 }
868 let child_receipt_context = if receipt_context == ReceiptContext::BatchRoot
869 && k == "results"
870 && v.is_array()
871 {
872 ReceiptContext::BatchResults
873 } else {
874 ReceiptContext::None
875 };
876 let child_inside_properties =
880 inside_properties || (k == "properties" && v.is_object());
881 let transformed = transform_field_agent(
882 &k,
883 v,
884 preserved_nulls,
885 scores,
886 payload_timestamps,
887 now,
888 AgentFieldContext {
889 inside_properties: child_inside_properties,
890 preserve_list_envelope,
891 receipt_context: child_receipt_context,
892 },
893 );
894 match transformed {
895 None => {} Some(tv) => {
897 out.insert(k, tv);
898 }
899 }
900 }
901 Value::Object(out)
902 }
903 Value::Array(arr) => {
904 let items: Vec<Value> = arr
905 .into_iter()
906 .map(|v| {
907 transform_agent(
908 v,
909 preserved_nulls,
910 scores,
911 payload_timestamps,
912 now,
913 inside_properties,
914 if receipt_context == ReceiptContext::BatchResults {
915 ReceiptContext::BatchMember
916 } else {
917 ReceiptContext::None
918 },
919 )
920 })
921 .collect();
922 Value::Array(items)
923 }
924 other => other,
925 }
926}
927
928#[derive(Clone, Copy)]
940struct AgentFieldContext {
941 inside_properties: bool,
942 preserve_list_envelope: bool,
943 receipt_context: ReceiptContext,
944}
945
946fn transform_field_agent(
947 key: &str,
948 value: Value,
949 preserved_nulls: &HashSet<&str>,
950 scores: &HashSet<&str>,
951 payload_timestamps: &HashSet<&str>,
952 now: i64,
953 context: AgentFieldContext,
954) -> Option<Value> {
955 match &value {
956 Value::Null => {
958 if preserved_nulls.contains(key)
959 || (context.preserve_list_envelope && key == "next_after")
960 {
961 Some(value)
962 } else {
963 None
964 }
965 }
966 Value::Array(a)
968 if context.preserve_list_envelope
969 && a.is_empty()
970 && EMPTY_ARRAY_PRESERVE.contains(&key) =>
971 {
972 Some(value)
973 }
974 Value::String(s) if s.is_empty() && context.inside_properties => Some(value),
980 Value::String(s) if s.is_empty() => None,
982 Value::Array(a) if a.is_empty() => None,
983 Value::Object(o) if o.is_empty() => None,
984 Value::Number(_) if scores.contains(key) => {
986 if let Some(f) = value.as_f64() {
987 Some(truncate_to_3_sig_figs(f))
988 } else {
989 Some(value)
990 }
991 }
992 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
994 Some(Value::String(s[..8].to_string()))
995 }
996 Value::String(s)
999 if !context.inside_properties
1000 && !payload_timestamps.contains(key)
1001 && looks_like_iso8601(s) =>
1002 {
1003 Some(Value::String(compact_timestamp(s, now)))
1004 }
1005 Value::Object(_) | Value::Array(_) => Some(transform_agent(
1007 value,
1008 preserved_nulls,
1009 scores,
1010 payload_timestamps,
1011 now,
1012 context.inside_properties,
1013 context.receipt_context,
1014 )),
1015 _ => Some(value),
1017 }
1018}
1019
1020fn is_canonical_uuid(s: &str) -> bool {
1022 if s.len() != UUID_CANONICAL_LEN {
1023 return false;
1024 }
1025 let b = s.as_bytes();
1026 b[8] == b'-'
1028 && b[13] == b'-'
1029 && b[18] == b'-'
1030 && b[23] == b'-'
1031 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1032 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1033 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1034 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1035 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1036}
1037
1038fn looks_like_iso8601(s: &str) -> bool {
1042 if s.len() < 16 {
1043 return false;
1044 }
1045 let b = s.as_bytes();
1046 b[4] == b'-'
1047 && b[7] == b'-'
1048 && b[10] == b'T'
1049 && b[13] == b':'
1050 && b[..4].iter().all(|c| c.is_ascii_digit())
1051 && b[5..7].iter().all(|c| c.is_ascii_digit())
1052 && b[8..10].iter().all(|c| c.is_ascii_digit())
1053 && b[11..13].iter().all(|c| c.is_ascii_digit())
1054}
1055
1056fn compact_timestamp(s: &str, now: i64) -> String {
1061 if let Some(unix) = parse_iso8601_unix(s) {
1063 let diff = now - unix;
1064 if (0..86400).contains(&diff) {
1065 return relative_time(diff);
1066 }
1067 }
1068 s.chars().take(16).collect()
1070}
1071
1072fn parse_iso8601_unix(s: &str) -> Option<i64> {
1079 if s.len() < 19 {
1081 return None;
1082 }
1083 let b = s.as_bytes();
1084 let year: i64 = parse_digits(&b[0..4])?;
1085 let month: i64 = parse_digits(&b[5..7])?;
1086 let day: i64 = parse_digits(&b[8..10])?;
1087 let hour: i64 = parse_digits(&b[11..13])?;
1088 let minute: i64 = parse_digits(&b[14..16])?;
1089 let second: i64 = parse_digits(&b[17..19])?;
1090
1091 let days_since_epoch = days_from_civil(year, month, day);
1095 let local = days_since_epoch * 86400 + hour * 3600 + minute * 60 + second;
1096 let offset_secs = parse_tz_offset_secs(&s[19..])?;
1097 Some(local - offset_secs)
1098}
1099
1100fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1110 let mut rest = tail;
1111 if let Some(after_dot) = rest.strip_prefix('.') {
1112 let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1113 if frac_len == 0 {
1114 return None;
1115 }
1116 rest = &after_dot[frac_len..];
1117 }
1118
1119 if rest.is_empty() || rest == "Z" {
1120 return Some(0);
1121 }
1122
1123 let sign: i64 = match rest.as_bytes().first()? {
1124 b'+' => 1,
1125 b'-' => -1,
1126 _ => return None,
1127 };
1128 let digits = &rest[1..];
1129 let (hh, mm) = match digits.len() {
1130 5 if digits.as_bytes()[2] == b':' => (
1132 parse_digits(&digits.as_bytes()[0..2])?,
1133 parse_digits(&digits.as_bytes()[3..5])?,
1134 ),
1135 4 => (
1137 parse_digits(&digits.as_bytes()[0..2])?,
1138 parse_digits(&digits.as_bytes()[2..4])?,
1139 ),
1140 _ => return None,
1141 };
1142 if hh > 23 || mm > 59 {
1143 return None;
1144 }
1145 Some(sign * (hh * 3600 + mm * 60))
1146}
1147
1148fn parse_digits(b: &[u8]) -> Option<i64> {
1149 let s = std::str::from_utf8(b).ok()?;
1150 s.parse().ok()
1151}
1152
1153fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
1155 let y = if m <= 2 { y - 1 } else { y };
1156 let era = y.div_euclid(400);
1157 let yoe = y - era * 400;
1158 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
1159 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1160 era * 146097 + doe - 719468
1161}
1162
1163fn relative_time(diff_secs: i64) -> String {
1165 if diff_secs < 60 {
1166 format!("{diff_secs}s ago")
1167 } else if diff_secs < 3600 {
1168 format!("{}m ago", diff_secs / 60)
1169 } else {
1170 format!("{}h ago", diff_secs / 3600)
1171 }
1172}
1173
1174fn truncate_to_3_sig_figs(f: f64) -> Value {
1176 if f == 0.0 || !f.is_finite() {
1177 return Value::from(f);
1178 }
1179 let magnitude = f.abs().log10().floor() as i32;
1180 let factor = 10f64.powi(2 - magnitude);
1181 let rounded = (f * factor).round() / factor;
1182 serde_json::Number::from_f64(rounded)
1184 .map(Value::Number)
1185 .unwrap_or(Value::from(rounded))
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191 use serde_json::json;
1192
1193 const NOW: i64 = 1_748_016_480;
1195
1196 #[test]
1197 fn rfc3339_to_utc_micros_round_trips_and_rejects_partial_forms() {
1198 let micros = 1_748_016_480_000_000_i64;
1199 assert_eq!(rfc3339_to_utc_micros(µs_to_iso(micros)), Ok(micros));
1200 assert_eq!(
1203 rfc3339_to_utc_micros(" 2025-05-23T12:08:00-04:00 "),
1204 Ok(micros)
1205 );
1206 assert!(rfc3339_to_utc_micros("2026-05-23").is_err());
1207 assert!(rfc3339_to_utc_micros("2026-05-23T16:18:00").is_err());
1208 }
1209
1210 fn agent(v: Value) -> Value {
1211 present(v, PresentationMode::Agent, NOW)
1212 }
1213
1214 #[test]
1215 fn agent_preserves_empty_string_under_properties() {
1216 let v = json!({
1221 "id": "a1b2c3d4",
1222 "summary": "",
1223 "properties": {"k": "", "nested": {"deep": ""}},
1224 });
1225 let out = agent(v);
1226 assert_eq!(out["properties"]["k"], json!(""));
1227 assert_eq!(out["properties"]["nested"]["deep"], json!(""));
1228 assert!(out.get("summary").is_none());
1230 }
1231
1232 #[test]
1233 fn agent_drops_scalar_empty_properties_value() {
1234 let v = json!({
1238 "id": "a1b2c3d4",
1239 "properties": "",
1240 });
1241 let out = agent(v);
1242 assert!(out.get("properties").is_none());
1243 }
1244
1245 #[test]
1246 fn agent_still_drops_empty_containers_under_properties() {
1247 let v = json!({
1250 "id": "a1b2c3d4",
1251 "properties": {"tags": [], "meta": {}, "kept": "x"},
1252 });
1253 let out = agent(v);
1254 assert!(out["properties"].get("tags").is_none());
1255 assert!(out["properties"].get("meta").is_none());
1256 assert_eq!(out["properties"]["kept"], json!("x"));
1257 }
1258
1259 #[test]
1260 fn verbose_passthrough() {
1261 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
1262 let out = present(v.clone(), PresentationMode::Verbose, NOW);
1263 assert_eq!(out, v);
1264 }
1265
1266 #[test]
1267 fn agent_shortens_uuid() {
1268 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
1269 let out = agent(v);
1270 assert_eq!(out["id"], json!("a1b2c3d4"));
1271 }
1272
1273 #[test]
1274 fn agent_drops_empty_string() {
1275 let v = json!({"title": "ok", "description": ""});
1276 let out = agent(v);
1277 assert!(out.get("description").is_none());
1278 assert_eq!(out["title"], json!("ok"));
1279 }
1280
1281 #[test]
1282 fn agent_drops_empty_array() {
1283 let v = json!({"tags": [], "title": "ok"});
1284 let out = agent(v);
1285 assert!(out.get("tags").is_none());
1286 }
1287
1288 #[test]
1289 fn agent_preserves_empty_list_page_arrays() {
1290 let v = json!({
1291 "items": [],
1292 "entities": [],
1293 "notes": [],
1294 "edges": [],
1295 "results": [],
1296 "entries": [],
1297 "neighbors": [],
1298 "next_after": null,
1299 "requested_limit": 10,
1300 "effective_limit": 10,
1301 "limit_clamped": false,
1302 });
1303 let out = agent(v);
1304 for key in EMPTY_ARRAY_PRESERVE {
1305 assert_eq!(out[*key], json!([]), "missing structural key {key}");
1306 }
1307 assert_eq!(out["next_after"], json!(null));
1308 }
1309
1310 #[test]
1311 fn agent_preserves_keyset_cursor_envelope_completion_signals() {
1312 let out = agent(json!({
1314 "results": [],
1315 "limit": 500,
1316 "order": "created_at ASC, id ASC",
1317 "next_after": null,
1318 }));
1319 assert_eq!(out["results"], json!([]));
1320 assert!(
1321 out.get("next_after").is_some_and(Value::is_null),
1322 "terminal cursor page must keep next_after:null: {out}"
1323 );
1324 }
1325
1326 #[test]
1327 fn agent_results_without_next_after_is_not_a_cursor_envelope() {
1328 let out = agent(json!({"results": [], "limit": 500, "title": "ordinary response"}));
1329 assert!(out.get("results").is_none());
1330 }
1331
1332 #[test]
1333 fn agent_still_drops_empty_arrays_outside_list_envelopes() {
1334 let out = agent(json!({"items": [], "entities": [], "title": "ordinary response"}));
1335 assert!(out.get("items").is_none());
1336 assert!(out.get("entities").is_none());
1337 }
1338
1339 #[test]
1340 fn agent_drops_empty_object() {
1341 let v = json!({"properties": {}, "title": "ok"});
1342 let out = agent(v);
1343 assert!(out.get("properties").is_none());
1344 }
1345
1346 #[test]
1347 fn agent_drops_non_lifecycle_null() {
1348 let v = json!({"result": null, "title": "ok"});
1349 let out = agent(v);
1350 assert!(out.get("result").is_none());
1351 }
1352
1353 #[test]
1354 fn agent_preserves_lifecycle_null() {
1355 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
1356 let out = agent(v);
1357 assert_eq!(out["completed_at"], json!(null));
1358 assert_eq!(out["due_at"], json!(null));
1359 }
1360
1361 #[test]
1362 fn agent_preserves_relationship_null() {
1363 let v = json!({"parent_id": null, "superseded_by": null});
1364 let out = agent(v);
1365 assert_eq!(out["parent_id"], json!(null));
1366 assert_eq!(out["superseded_by"], json!(null));
1367 }
1368
1369 #[test]
1370 fn agent_preserves_unknown_channel_heartbeat_nulls() {
1371 let v = json!({
1372 "channels": [{
1373 "poll_interval_secs": null,
1374 "stalled": null,
1375 "last_success_at": null,
1376 "last_poll_attempt_at": null,
1377 "last_failure_at": null,
1378 "last_error": null,
1379 "consecutive_failures": null,
1380 "quarantined_count": 1,
1381 }]
1382 });
1383 let out = agent(v);
1384 let channel = out["channels"][0].as_object().expect("channel object");
1385 for field in [
1386 "poll_interval_secs",
1387 "stalled",
1388 "last_success_at",
1389 "last_poll_attempt_at",
1390 "last_failure_at",
1391 "last_error",
1392 "consecutive_failures",
1393 ] {
1394 assert_eq!(
1395 channel.get(field),
1396 Some(&Value::Null),
1397 "Agent presentation must preserve unknown heartbeat fact `{field}`"
1398 );
1399 }
1400 }
1401
1402 #[test]
1403 fn agent_truncates_score_field() {
1404 let v = json!({"score": 0.12345678});
1405 let out = agent(v);
1406 let s = out["score"].as_f64().unwrap();
1407 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
1408 }
1409
1410 #[test]
1411 fn agent_presents_ranking_alias_and_nested_evidence() {
1412 let canonical = json!([
1413 {
1414 "rank_score": 0.0325224749,
1415 "score": 0.0325224749,
1416 "rank_score_kind": "rrf",
1417 "signals": {"vector_similarity": 0.842567, "keyword_score": 18.375}
1418 },
1419 {"rank_score": 0.0, "score": 0.0, "rank_score_kind": "keyword", "signals": {}}
1420 ]);
1421 for mode in [PresentationMode::Verbose, PresentationMode::Human] {
1422 assert_eq!(present(canonical.clone(), mode, NOW), canonical);
1423 }
1424 let shown = agent(canonical);
1425 assert_eq!(shown[0]["rank_score"], json!(0.0325));
1426 assert_eq!(shown[0]["score"], shown[0]["rank_score"]);
1427 assert_eq!(shown[0]["rank_score_kind"], "rrf");
1428 assert_eq!(shown[0]["signals"]["vector_similarity"], json!(0.843));
1429 assert_eq!(shown[0]["signals"]["keyword_score"], json!(18.4));
1430 assert_eq!(shown[1]["rank_score"], json!(0.0));
1431 assert_eq!(shown[1]["score"], shown[1]["rank_score"]);
1432 assert_eq!(shown[1]["rank_score_kind"], "keyword");
1433 assert!(shown[1].get("signals").is_none());
1434 }
1435
1436 #[test]
1437 fn agent_compacts_old_timestamp_to_minutes() {
1438 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
1440 let out = agent(v);
1441 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
1442 }
1443
1444 #[test]
1445 fn agent_compacts_recent_timestamp_to_relative() {
1446 let ts_unix = NOW - 180;
1448 let ts = unix_to_iso8601(ts_unix);
1450 let v = json!({"updated_at": ts});
1451 let out = agent(v);
1452 assert_eq!(out["updated_at"], json!("3m ago"));
1453 }
1454
1455 #[test]
1456 fn agent_does_not_compact_top_level_trigger_at_field() {
1457 let at = "2026-07-11T19:00:00-04:00";
1466 let v = json!({
1467 "id": "a1b2c3d4",
1468 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1469 "event_type": "remind",
1470 "trigger_at": at,
1471 "repeat": null,
1472 "status": "pending",
1473 });
1474 let out = agent(v);
1475 assert_eq!(out["trigger_at"], json!(at));
1476 }
1477
1478 #[test]
1479 fn agent_does_not_compact_top_level_trigger_at_utc() {
1480 let at = "2026-07-11T23:00:00Z";
1481 let v = json!({"trigger_at": at});
1482 let out = agent(v);
1483 assert_eq!(out["trigger_at"], json!(at));
1484 }
1485
1486 #[test]
1487 fn agent_does_not_compact_top_level_trigger_at_offset_less() {
1488 let at = "2026-07-11T23:00:00";
1489 let v = json!({"trigger_at": at});
1490 let out = agent(v);
1491 assert_eq!(out["trigger_at"], json!(at));
1492 }
1493
1494 #[test]
1495 fn agent_still_compacts_other_top_level_timestamps_alongside_trigger_at() {
1496 let v = json!({
1500 "trigger_at": "2026-07-11T19:00:00-04:00",
1501 "created_at": "2020-01-01T10:30:45.123456Z",
1502 });
1503 let out = agent(v);
1504 assert_eq!(out["trigger_at"], json!("2026-07-11T19:00:00-04:00"));
1505 assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
1506 }
1507
1508 #[test]
1509 fn agent_does_not_compact_top_level_due() {
1510 let due = "2026-08-01T09:30:15-04:00";
1515 let v = json!({"due": due});
1516 let out = agent(v);
1517 assert_eq!(out["due"], json!(due));
1518 }
1519
1520 #[test]
1521 fn agent_still_protects_nested_trigger_at_under_properties() {
1522 let at = "2026-07-11T19:00:00-04:00";
1526 let v = json!({
1527 "id": "a1b2c3d4",
1528 "properties": {"trigger_at": at, "status": "pending"},
1529 });
1530 let out = agent(v);
1531 assert_eq!(out["properties"]["trigger_at"], json!(at));
1532 }
1533
1534 #[test]
1535 fn agent_recurses_into_nested_objects() {
1536 let v = json!({
1537 "items": [
1538 {
1539 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1540 "tags": [],
1541 "score": 0.9999
1542 }
1543 ]
1544 });
1545 let out = agent(v);
1546 let item = &out["items"][0];
1547 assert_eq!(item["id"], json!("a1b2c3d4"));
1548 assert!(item.get("tags").is_none());
1549 let s = item["score"].as_f64().unwrap();
1550 assert!((s - 1.0).abs() < 1e-9);
1551 }
1552
1553 #[test]
1556 fn agent_preserves_full_id_as_36_chars() {
1557 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1558 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
1559 let out = agent(v);
1560 assert_eq!(
1562 out["id"],
1563 json!("a1b2c3d4"),
1564 "id should be 8-char short form"
1565 );
1566 assert_eq!(
1568 out["full_id"].as_str().unwrap().len(),
1569 36,
1570 "full_id must be 36 chars in agent mode"
1571 );
1572 assert_eq!(
1573 out["full_id"],
1574 json!(uuid),
1575 "full_id must equal the original UUID"
1576 );
1577 assert!(
1579 out["full_id"]
1580 .as_str()
1581 .unwrap()
1582 .starts_with(out["id"].as_str().unwrap()),
1583 "full_id must start with the short id prefix"
1584 );
1585 }
1586
1587 #[test]
1588 fn is_canonical_uuid_recognizes_valid() {
1589 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1590 assert!(!is_canonical_uuid("a1b2c3d4"));
1591 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
1592 }
1593
1594 #[test]
1595 fn looks_like_iso8601_recognizes_valid() {
1596 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
1597 assert!(!looks_like_iso8601("not a timestamp"));
1598 assert!(!looks_like_iso8601("2026-05-23"));
1599 }
1600
1601 fn unix_to_iso8601(unix: i64) -> String {
1603 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
1604 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
1605 }
1606
1607 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
1608 let s = unix % 86400;
1609 let days = unix / 86400;
1610 let h = s / 3600;
1611 let m = (s % 3600) / 60;
1612 let sec = s % 60;
1613 let z = days + 719468;
1615 let era = z.div_euclid(146097);
1616 let doe = z - era * 146097;
1617 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1618 let y = yoe + era * 400;
1619 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1620 let mp = (5 * doy + 2) / 153;
1621 let d = doy - (153 * mp + 2) / 5 + 1;
1622 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
1623 let y = if mo <= 2 { y + 1 } else { y };
1624 (y, mo, d, h, m, sec)
1625 }
1626
1627 #[test]
1628 fn agent_does_not_shorten_uuid_shaped_content_fields() {
1629 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1630 let out = agent(json!({
1631 "id": uuid,
1632 "full_id": uuid,
1633 "content": uuid,
1634 "description": uuid,
1635 "title": uuid,
1636 "query": uuid,
1637 }));
1638
1639 assert_eq!(out["id"], json!("a1b2c3d4"));
1640 assert_eq!(out["full_id"], json!(uuid));
1641 assert_eq!(out["content"], json!(uuid));
1642 assert_eq!(out["description"], json!(uuid));
1643 assert_eq!(out["title"], json!(uuid));
1644 assert_eq!(out["query"], json!(uuid));
1645 }
1646
1647 #[test]
1648 fn agent_shortens_suffix_id_fields() {
1649 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1650 let out = agent(json!({
1651 "note_id": uuid,
1652 "source_id": uuid,
1653 "target_id": uuid,
1654 }));
1655
1656 assert_eq!(out["note_id"], json!("a1b2c3d4"));
1657 assert_eq!(out["source_id"], json!("a1b2c3d4"));
1658 assert_eq!(out["target_id"], json!("a1b2c3d4"));
1659 }
1660
1661 #[test]
1662 fn agent_preserves_strict_round_trip_uuid_fields() {
1663 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1664 let out = agent(json!({
1665 "context_entity_id": uuid,
1666 "thread_id": uuid,
1667 "session_id": uuid,
1668 "project_id": uuid,
1669 "properties": {
1670 "context_entity_id": uuid,
1671 "thread_id": uuid,
1672 "outbound_ref": uuid,
1673 "parent_id": uuid,
1674 "session_id": uuid,
1675 "project_id": uuid,
1676 },
1677 "parent_id": uuid,
1678 }));
1679
1680 assert_eq!(out["context_entity_id"], json!(uuid));
1681 assert_eq!(out["thread_id"], json!(uuid));
1682 assert_eq!(out["session_id"], json!(uuid));
1683 assert_eq!(out["project_id"], json!(uuid));
1684 assert_eq!(out["properties"]["context_entity_id"], json!(uuid));
1685 assert_eq!(out["properties"]["thread_id"], json!(uuid));
1686 assert_eq!(out["properties"]["outbound_ref"], json!(uuid));
1687 assert_eq!(out["properties"]["parent_id"], json!(uuid));
1688 assert_eq!(out["properties"]["session_id"], json!(uuid));
1689 assert_eq!(out["properties"]["project_id"], json!(uuid));
1690 assert_eq!(out["parent_id"], json!(uuid));
1691 }
1692
1693 #[test]
1697 fn format_verbose_json_preserves_full_shape() {
1698 let v = json!({
1699 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1700 "namespace": "local",
1701 "properties": {"k": "v"},
1702 "title": "test"
1703 });
1704 let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Verbose);
1705 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1706 assert!(
1708 parsed.get("full_id").is_some(),
1709 "json mode must keep full_id"
1710 );
1711 assert_eq!(
1713 parsed.get("namespace").and_then(Value::as_str),
1714 Some("local")
1715 );
1716 assert!(parsed.get("properties").is_some());
1718 }
1719
1720 #[test]
1725 fn format_agent_json_drops_redundancy() {
1726 let v = json!({
1727 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1728 "namespace": "local",
1729 "title": "test",
1730 "status": "next",
1731 "properties": {"status": "next", "additive": true}
1732 });
1733 let rendered = render_format(v, OutputFormat::Json, PresentationMode::Agent);
1734 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1735 assert!(
1736 parsed.get("full_id").is_some(),
1737 "Agent JSON must keep full_id: it is the machine contract's chaining handle"
1738 );
1739 assert!(parsed.get("namespace").is_none());
1740 assert_eq!(parsed["status"], json!("next"));
1741 assert!(parsed["properties"].get("status").is_none());
1742 assert_eq!(parsed["properties"]["additive"], json!(true));
1743 }
1744
1745 #[test]
1750 fn agent_json_keeps_full_id_while_auto_still_drops_it() {
1751 let v = json!({
1752 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1753 "namespace": "local",
1754 "title": "test",
1755 "properties": {"title": "test", "additive": true}
1756 });
1757
1758 let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1759 let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
1760 assert!(
1761 json_parsed.get("full_id").is_some(),
1762 "Agent JSON must keep full_id"
1763 );
1764 assert!(
1765 json_parsed.get("namespace").is_none(),
1766 "Agent JSON must elide namespace=\"local\""
1767 );
1768 assert!(
1769 json_parsed["properties"].get("title").is_none(),
1770 "Agent JSON must dedupe a duplicate properties entry"
1771 );
1772 assert_eq!(json_parsed["properties"]["additive"], json!(true));
1773
1774 let auto_rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1775 assert!(
1776 !auto_rendered.contains("full_id"),
1777 "Auto (view scope) must still drop full_id: {auto_rendered}"
1778 );
1779 assert!(
1780 !auto_rendered.contains("\"namespace\""),
1781 "Auto must still elide namespace=\"local\": {auto_rendered}"
1782 );
1783 }
1784
1785 #[test]
1787 fn format_auto_homogeneous_array_renders_markdown_table() {
1788 let v = json!([
1789 {"id": "abc", "title": "First"},
1790 {"id": "def", "title": "Second"}
1791 ]);
1792 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1793 assert!(rendered.starts_with('|'), "must start with |");
1794 assert!(
1795 rendered.contains("| id |") || rendered.contains("| id"),
1796 "must have id column"
1797 );
1798 assert!(rendered.contains("title"), "must have title column");
1799 assert!(rendered.contains("|---|"), "must have separator row");
1800 assert!(rendered.contains("abc"), "must have first row data");
1801 assert!(rendered.contains("Second"), "must have second row data");
1802 }
1803
1804 #[test]
1806 fn format_auto_single_record_renders_compact_json() {
1807 let v = json!({"id": "abc", "title": "Hello World"});
1808 let rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
1809 let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
1810 assert_eq!(parsed, v, "single record must round-trip losslessly");
1811 assert!(
1812 !rendered.starts_with('|'),
1813 "single record must not be a markdown table"
1814 );
1815 }
1816
1817 #[test]
1821 fn format_auto_single_record_large_payload_survives_whole() {
1822 let briefing = "line one\nline two\n".repeat(500); let v = json!({"id": "abc", "markdown": briefing.clone()});
1824 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1825 let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
1826 assert_eq!(
1827 parsed.get("markdown").and_then(Value::as_str),
1828 Some(briefing.as_str()),
1829 "large payload field must survive untruncated"
1830 );
1831 }
1832
1833 #[test]
1836 fn format_auto_table_preserves_sibling_scalars() {
1837 let v = json!({
1838 "results": [
1839 {"id": "abc", "title": "First"},
1840 {"id": "def", "title": "Second"}
1841 ],
1842 "has_more": true,
1843 "offset": 20,
1844 "page_size": 2,
1845 "truncated": false
1846 });
1847 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1848 assert!(rendered.contains("| id"), "records must render as a table");
1849 assert!(
1850 rendered.contains("has_more: true"),
1851 "has_more sibling must survive: {rendered}"
1852 );
1853 assert!(
1854 rendered.contains("offset: 20"),
1855 "offset sibling must survive"
1856 );
1857 assert!(
1858 rendered.contains("page_size: 2"),
1859 "page_size sibling must survive"
1860 );
1861 assert!(
1862 rendered.contains("truncated: false"),
1863 "truncated sibling must survive"
1864 );
1865 }
1866
1867 #[test]
1870 fn format_auto_nested_cell_renders_elision_marker() {
1871 let big_nested: Value = json!({"k": "v".repeat(300), "other": {"deep": true}});
1872 let v = json!([
1873 {"id": "abc", "properties": big_nested},
1874 {"id": "def", "properties": {"x": 1}}
1875 ]);
1876 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1877 assert!(
1878 rendered.contains("{…}"),
1879 "nested object cell must render the elision marker: {rendered}"
1880 );
1881 assert!(
1882 !rendered.contains("..."),
1883 "no truncated pseudo-JSON in nested cells"
1884 );
1885 }
1886
1887 #[test]
1889 fn format_auto_scalar_array_cell_renders_compact_json() {
1890 let v = json!([
1891 {"id": "abc", "tags": ["lesson", "khive"]},
1892 {"id": "def", "tags": ["adr"]}
1893 ]);
1894 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1895 assert!(
1896 rendered.contains(r#"["lesson","khive"]"#),
1897 "scalar array cell must be compact JSON: {rendered}"
1898 );
1899 }
1900
1901 #[test]
1904 fn format_auto_identity_columns_not_truncated() {
1905 let long_title = "T".repeat(300);
1906 let long_note = "N".repeat(300);
1907 let v = json!([
1908 {"id": "abc", "title": long_title.clone(), "note": long_note.clone()},
1909 {"id": "def", "title": "short", "note": "short"}
1910 ]);
1911 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1912 assert!(
1913 rendered.contains(&long_title),
1914 "title column must not be truncated"
1915 );
1916 assert!(
1917 !rendered.contains(&long_note),
1918 "non-exempt column must still truncate"
1919 );
1920 }
1921
1922 #[test]
1924 fn format_auto_verbose_disables_truncation() {
1925 let long_note = "N".repeat(300);
1926 let v = json!([
1927 {"id": "abc", "note": long_note.clone()},
1928 {"id": "def", "note": "short"}
1929 ]);
1930 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
1931 assert!(
1932 rendered.contains(&long_note),
1933 "verbose must render full cell content"
1934 );
1935 }
1936
1937 #[test]
1941 fn table_path_hoists_payload_scalars_to_columns() {
1942 let record = |n: u32| {
1946 json!({
1947 "id": format!("evt-{n}"),
1948 "properties": {
1949 "trigger_at": "2026-09-01T14:00:00-04:00",
1950 "status": "pending",
1951 "dispatch_receipt": {"state": "succeeded"}
1952 }
1953 })
1954 };
1955 let out = render_format(
1956 json!([record(1), record(2)]),
1957 OutputFormat::Auto,
1958 PresentationMode::Agent,
1959 );
1960 let header = out.lines().next().expect("table header");
1961 assert!(
1962 header.contains("trigger_at") && header.contains("status"),
1963 "hoisted scalars must appear as table columns, got header: {header}"
1964 );
1965 assert!(
1966 out.contains("2026-09-01T14:00:00-04:00") && out.contains("pending"),
1967 "hoisted values must render in cells:\n{out}"
1968 );
1969 }
1970
1971 #[test]
1972 fn redundancy_drop_no_longer_hoists_outside_tables() {
1973 let v = json!({
1977 "id": "abc",
1978 "properties": {
1979 "trigger_at": "2026-09-01T14:00:00-04:00",
1980 "status": "pending"
1981 }
1982 });
1983 let reduced = apply_redundancy_drop(v, RedundancyScope::View);
1984 assert!(
1985 reduced.get("trigger_at").is_none() && reduced.get("status").is_none(),
1986 "the pre-pass must not hoist; the hoist is table-path only"
1987 );
1988 let props = reduced.get("properties").expect("properties must remain");
1989 assert_eq!(
1990 props.get("status").and_then(Value::as_str),
1991 Some("pending"),
1992 "fallback shape keeps payload fields inside properties"
1993 );
1994 }
1995
1996 #[test]
1997 fn sibling_string_with_newline_renders_as_json_literal() {
1998 let v = json!({
2001 "items": [{"id": "a", "kind": "x"}, {"id": "b", "kind": "y"}],
2002 "note": "line one\nforged_key: forged_value",
2003 "has_more": true
2004 });
2005 let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2006 assert!(
2007 !out.contains("\nforged_key: forged_value"),
2008 "raw newline from a sibling string must not start a new line:\n{out}"
2009 );
2010 assert!(
2011 out.contains(r#"note: "line one\nforged_key: forged_value""#),
2012 "newline-bearing sibling renders as its JSON literal:\n{out}"
2013 );
2014 assert!(out.contains("has_more: true"), "siblings still preserved");
2015 }
2016
2017 #[test]
2018 fn carriage_return_in_cell_collapses_like_newline() {
2019 let v = json!([
2023 {"id": "a", "note": "before\rafter"},
2024 {"id": "b", "note": "one\r\ntwo"}
2025 ]);
2026 let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2027 assert!(
2028 !out.contains('\r'),
2029 "no raw carriage return may survive into rendered output:\n{out:?}"
2030 );
2031 assert!(
2032 out.contains("before after") && out.contains("one two"),
2033 "\\r and \\r\\n collapse to spaces inside cells:\n{out}"
2034 );
2035 }
2036
2037 #[test]
2039 fn redundancy_drop_hoist_does_not_overwrite_top_level() {
2040 let v = json!({
2041 "id": "abc",
2042 "status": "active",
2043 "properties": {"status": "pending"}
2044 });
2045 let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2046 assert_eq!(
2047 reduced.get("status").and_then(Value::as_str),
2048 Some("active"),
2049 "existing top-level status must win"
2050 );
2051 assert_eq!(
2052 reduced
2053 .get("properties")
2054 .and_then(|p| p.get("status"))
2055 .and_then(Value::as_str),
2056 Some("pending"),
2057 "conflicting properties value must stay where it was"
2058 );
2059 }
2060
2061 #[test]
2063 fn format_auto_scalar_fallback_compact_json() {
2064 let v = json!(42);
2065 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2066 assert_eq!(rendered, "42");
2067 }
2068
2069 #[test]
2071 fn format_table_forces_markdown_when_array() {
2072 let v = json!({
2073 "items": [
2074 {"name": "A", "score": 1},
2075 {"name": "B", "score": 2}
2076 ]
2077 });
2078 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2079 assert!(
2080 rendered.contains("|"),
2081 "table format must produce markdown table"
2082 );
2083 assert!(rendered.contains("name"), "must have name column");
2084 assert!(rendered.contains("score"), "must have score column");
2085 }
2086
2087 #[test]
2089 fn format_table_falls_back_to_json_when_no_array() {
2090 let v = json!({"single": "value"});
2091 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2092 let parsed: Value = serde_json::from_str(&rendered).unwrap();
2094 assert_eq!(parsed["single"], json!("value"));
2095 }
2096
2097 #[test]
2099 fn format_auto_verbose_skips_redundancy_drop() {
2100 let v = json!({
2101 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
2102 "namespace": "local",
2103 "title": "test"
2104 });
2105 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
2108 assert!(
2109 rendered.contains("full_id"),
2110 "verbose must preserve full_id"
2111 );
2112 assert!(
2113 rendered.contains("namespace"),
2114 "verbose must preserve namespace"
2115 );
2116 }
2117
2118 #[test]
2122 fn redundancy_drop_does_not_corrupt_error_shape() {
2123 let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
2124 let reduced = apply_redundancy_drop(v.clone(), RedundancyScope::View);
2129 assert!(
2130 reduced.get("error").is_some(),
2131 "redundancy drop must preserve error field"
2132 );
2133 assert_eq!(
2134 reduced.get("ok").and_then(Value::as_bool),
2135 Some(false),
2136 "redundancy drop must preserve ok=false"
2137 );
2138 }
2139
2140 #[test]
2142 fn redundancy_drop_properties_dedup() {
2143 let v = json!({
2144 "id": "abc",
2145 "title": "Same",
2146 "properties": {
2147 "title": "Same", "extra": "unique" }
2150 });
2151 let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2152 let props = reduced.get("properties").expect("properties must remain");
2153 assert!(props.get("extra").is_some(), "unique property must be kept");
2154 assert!(
2155 props.get("title").is_none(),
2156 "duplicate top-level property must be removed"
2157 );
2158 }
2159
2160 #[test]
2162 fn cell_text_truncates_long_values() {
2163 let long = "X".repeat(200);
2164 let v = json!([
2165 {"col": long.clone()},
2166 {"col": "short"}
2167 ]);
2168 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2169 assert!(
2171 rendered.contains("..."),
2172 "long cell must be truncated with ..."
2173 );
2174 assert!(
2175 !rendered.contains(&long),
2176 "full long string must not appear in table"
2177 );
2178 }
2179
2180 #[test]
2186 fn cell_text_truncation_is_utf8_safe() {
2187 let prefix = "a".repeat(119);
2190 let suffix = "中".repeat(10); let long_multibyte = format!("{prefix}{suffix}trailing");
2192 let v = json!([
2193 {"col": long_multibyte.clone()},
2194 {"col": "ok"}
2195 ]);
2196 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2198 assert!(
2199 rendered.contains("..."),
2200 "multibyte cell must be truncated with ..."
2201 );
2202 assert!(
2204 std::str::from_utf8(rendered.as_bytes()).is_ok(),
2205 "rendered output must be valid UTF-8"
2206 );
2207 }
2208
2209 #[test]
2212 fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
2213 assert_eq!(
2215 parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
2216 parse_iso8601_unix("2026-07-09T15:55:00Z")
2217 );
2218 }
2219
2220 #[test]
2221 fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
2222 assert_eq!(
2224 parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
2225 parse_iso8601_unix("2026-05-23T16:15:00Z")
2226 );
2227 }
2228
2229 #[test]
2230 fn parse_iso8601_unix_zero_offset_matches_z() {
2231 assert_eq!(
2232 parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
2233 parse_iso8601_unix("2026-07-09T15:55:00Z")
2234 );
2235 }
2236
2237 #[test]
2238 fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
2239 assert_eq!(
2240 parse_iso8601_unix("2026-07-09T11:55:00-0400"),
2241 parse_iso8601_unix("2026-07-09T11:55:00-04:00")
2242 );
2243 }
2244
2245 #[test]
2246 fn parse_iso8601_unix_fractional_seconds_with_offset() {
2247 assert_eq!(
2250 parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
2251 parse_iso8601_unix("2026-07-09T15:55:00Z")
2252 );
2253 }
2254
2255 #[test]
2256 fn parse_iso8601_unix_fractional_seconds_with_z() {
2257 assert_eq!(
2258 parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
2259 parse_iso8601_unix("2026-07-09T15:55:00Z")
2260 );
2261 }
2262
2263 #[test]
2264 fn parse_iso8601_unix_bare_form_unchanged() {
2265 assert_eq!(
2267 parse_iso8601_unix("2026-07-09T15:55:00"),
2268 parse_iso8601_unix("2026-07-09T15:55:00Z")
2269 );
2270 }
2271
2272 #[test]
2273 fn parse_iso8601_unix_malformed_tail_returns_none() {
2274 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
2275 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
2276 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
2277 }
2278
2279 #[test]
2280 fn parse_iso8601_unix_out_of_range_offset_returns_none() {
2281 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
2283 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
2284 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
2286 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
2287 }
2288
2289 #[test]
2290 fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
2291 assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
2293 assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
2294 assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
2295 }
2296
2297 #[test]
2298 fn compact_timestamp_offset_bearing_future_time_not_shown_as_ago() {
2299 let out = compact_timestamp("2025-05-23T16:08:00-02:00", NOW);
2303 assert_ne!(out, "0s ago");
2304 assert_eq!(out, "2025-05-23T16:08");
2305 }
2306
2307 #[test]
2308 fn compact_timestamp_offset_bearing_past_time_renders_relative() {
2309 let out = compact_timestamp("2025-05-23T20:05:00+04:00", NOW);
2315 assert_eq!(out, "3m ago");
2316 }
2317}
2318
2319#[cfg(test)]
2320mod stream_presentation_tests {
2321 use super::*;
2322 use serde_json::json;
2323
2324 #[test]
2325 fn stream_agent_json_preserves_opaque_record_and_empty_page() {
2326 let record = json!([{"id": "aabbccdd-1234-4321-1234-abcdefabcdef", "score": 0.123456789, "created_at": "2026-09-08T00:00:00.123456Z", "empty": [], "null": null, "namespace": "local", "properties": {"namespace": "local"}}]);
2327 for payload in [record, Value::Null, json!([]), json!({}), json!("")] {
2328 let page = json!({"entries": [{"seq": 1, "id": "aabbccdd-1234-4321-1234-abcdefabcdef", "created_at": "2026-09-08T00:00:00.123456Z", "record": payload}], "head_seq": 1, "next_after": null});
2329 let presented = present(page, PresentationMode::Agent, 0);
2330 let out = prepare_format_value(presented, OutputFormat::Json, PresentationMode::Agent);
2331 assert_eq!(out["entries"][0].get("record"), Some(&payload));
2332 assert!(out.get("next_after").is_some_and(Value::is_null));
2333 }
2334 let empty = json!({"entries": [], "head_seq": 0, "next_after": null});
2335 assert_eq!(present(empty.clone(), PresentationMode::Agent, 0), empty);
2336 }
2337}
2338
2339#[cfg(test)]
2340mod issue_2537_standard_tests {
2341 use super::*;
2342 use serde_json::json;
2343
2344 #[test]
2345 fn issue_2537_standard_context_baseline_controls() {
2346 let timestamp = "2026-01-01T00:00:00.123456Z";
2347 let now = 1_767_225_780; let uuid = "aabbccdd-1234-4321-1234-abcdefabcdef";
2349 let payload = json!({"id":uuid,"created_at":timestamp,"empty":[],"null":null});
2350 let value = json!({"tool":"stream.batch","policy":"StreamBatchReceipts","id":uuid,
2351 "updated_at":timestamp,"score":0.123456,"empty":[],"null":null,
2352 "results":[{"id":uuid,"version":1,"updated_at":timestamp,"details":{"updated_at":timestamp}}],
2353 "properties":{"created_at":timestamp,"id":uuid,"empty":""},"trigger_at":timestamp,"due":timestamp,
2354 "entry":{"seq":1,"id":uuid,"created_at":timestamp,"record":payload},
2355 "cursor":{"head_seq":0,"entries":[],"next_after":null}});
2356 let shown = present(value.clone(), PresentationMode::Agent, now);
2357 assert_eq!(shown["updated_at"], "3m ago");
2358 assert_eq!(shown["results"][0]["updated_at"], "3m ago");
2359 assert_eq!(shown["results"][0]["details"]["updated_at"], "3m ago");
2360 assert_eq!(shown["id"], "aabbccdd");
2361 assert_eq!(shown["score"], json!(0.123));
2362 assert!(shown.get("empty").is_none());
2363 assert!(shown.get("null").is_none());
2364 assert_eq!(
2365 shown["properties"],
2366 json!({"created_at":timestamp,"id":"aabbccdd","empty":""})
2367 );
2368 assert_eq!(shown["trigger_at"], timestamp);
2369 assert_eq!(shown["due"], timestamp);
2370 assert_eq!(shown["entry"]["record"], payload);
2371 assert_eq!(shown["entry"]["created_at"], "3m ago");
2372 assert_eq!(shown["cursor"], value["cursor"]);
2373 for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2374 assert_eq!(present(value.clone(), mode, now), value);
2375 }
2376 assert_eq!(
2377 present(
2378 json!({"properties":timestamp}),
2379 PresentationMode::Agent,
2380 now
2381 )["properties"],
2382 "3m ago"
2383 );
2384 assert_eq!(
2385 present(
2386 json!({"properties":[timestamp,{"created_at":timestamp}]}),
2387 PresentationMode::Agent,
2388 now
2389 )["properties"],
2390 json!([timestamp,{"created_at":"3m ago"}])
2391 );
2392 }
2393}
2394
2395#[cfg(test)]
2396mod issue_2537_receipt_policy_tests {
2397 use super::*;
2398 use serde_json::json;
2399
2400 const TIMESTAMP: &str = "2026-01-01T00:00:00.123456Z";
2401 const NOW: i64 = 1_767_225_780;
2402 const UUID: &str = "aabbccdd-1234-4321-1234-abcdefabcdef";
2403
2404 #[test]
2405 fn issue_2537_receipt_policies_preserve_only_the_named_fields() {
2406 let append = json!({
2407 "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2408 "score": 0.123456, "empty": [], "null": null,
2409 "details": {"created_at": TIMESTAMP},
2410 "ticker": {"last_tick_at": TIMESTAMP},
2411 });
2412 let batch = json!({
2413 "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2414 "results": [{
2415 "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2416 "details": {"created_at": TIMESTAMP, "updated_at": TIMESTAMP},
2417 "record": {"updated_at": TIMESTAMP},
2418 "score": 0.123456, "empty": [], "null": null,
2419 }],
2420 "properties": {"created_at": TIMESTAMP, "id": UUID, "empty": ""},
2421 "entry": {"seq": 1, "id": UUID, "created_at": TIMESTAMP,
2422 "record": {"id": UUID, "updated_at": TIMESTAMP, "empty": [], "null": null}},
2423 "trigger_at": TIMESTAMP, "due": TIMESTAMP,
2424 "ticker": {"last_tick_at": TIMESTAMP},
2425 });
2426 for (value, policy) in [
2427 (append, VerbPresentationPolicy::StreamAppendReceipt),
2428 (batch, VerbPresentationPolicy::StreamBatchReceipts),
2429 ] {
2430 let mut expected = present(value.clone(), PresentationMode::Agent, NOW);
2431 assert_eq!(expected["created_at"], "3m ago");
2432 assert_eq!(expected["updated_at"], "3m ago");
2433 assert_eq!(expected["id"], "aabbccdd");
2434 assert_eq!(expected["ticker"]["last_tick_at"], "3m ago");
2435 if policy == VerbPresentationPolicy::StreamAppendReceipt {
2436 expected["created_at"] = json!(TIMESTAMP);
2437 } else {
2438 expected["results"][0]["created_at"] = json!(TIMESTAMP);
2439 expected["results"][0]["updated_at"] = json!(TIMESTAMP);
2440 }
2441 assert_eq!(
2442 present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2443 expected
2444 );
2445 for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2446 assert_eq!(present_with_policy(value.clone(), mode, NOW, policy), value);
2447 }
2448 assert_eq!(
2449 present_with_policy(
2450 value.clone(),
2451 PresentationMode::Agent,
2452 NOW,
2453 VerbPresentationPolicy::AlwaysVerbose
2454 ),
2455 value
2456 );
2457 }
2458 }
2459
2460 #[test]
2461 fn issue_2537_receipt_paths_do_not_match_other_containers_or_descendants() {
2462 let row = json!({"created_at": TIMESTAMP, "updated_at": TIMESTAMP});
2463 for (policy, cases) in [
2464 (
2465 VerbPresentationPolicy::StreamAppendReceipt,
2466 vec![
2467 json!([row.clone()]),
2468 json!({"details": row.clone()}),
2469 json!({"results": [row.clone()]}),
2470 json!({"created_at": {"created_at": TIMESTAMP}}),
2471 ],
2472 ),
2473 (
2474 VerbPresentationPolicy::StreamBatchReceipts,
2475 vec![
2476 json!([{"results": [row.clone()]}]),
2477 json!({"results": row.clone()}),
2478 json!({"results": [[row.clone()]]}),
2479 json!({"details": {"results": [row.clone()]}}),
2480 json!({"results": [{"details": row.clone(), "record": row.clone(), "results": [row.clone()]}]}),
2481 json!({"results": [{"created_at": {"updated_at": TIMESTAMP}, "updated_at": null}]}),
2482 ],
2483 ),
2484 ] {
2485 for value in cases {
2486 assert_eq!(
2487 present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2488 present(value.clone(), PresentationMode::Agent, NOW),
2489 "container must not acquire receipt protection: {value}"
2490 );
2491 }
2492 }
2493 }
2494
2495 #[test]
2496 fn issue_2537_receipt_strings_are_preserved_without_parsing() {
2497 for text in ["", "not a timestamp", "2026-01-01T05:30:00.123456+05:30"] {
2498 let append = json!({"created_at": text});
2499 assert_eq!(
2500 present_with_policy(
2501 append.clone(),
2502 PresentationMode::Agent,
2503 NOW,
2504 VerbPresentationPolicy::StreamAppendReceipt
2505 ),
2506 append
2507 );
2508 let batch = json!({"results": [{"created_at": text, "updated_at": text}]});
2509 assert_eq!(
2510 present_with_policy(
2511 batch.clone(),
2512 PresentationMode::Agent,
2513 NOW,
2514 VerbPresentationPolicy::StreamBatchReceipts
2515 ),
2516 batch
2517 );
2518 }
2519 for non_string in [Value::Null, json!([]), json!({}), json!(42), json!(true)] {
2520 for (value, policy) in [
2521 (
2522 json!({"created_at": non_string}),
2523 VerbPresentationPolicy::StreamAppendReceipt,
2524 ),
2525 (
2526 json!({"results": [{"created_at": non_string, "updated_at": non_string}]}),
2527 VerbPresentationPolicy::StreamBatchReceipts,
2528 ),
2529 ] {
2530 assert_eq!(
2531 present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2532 present(value, PresentationMode::Agent, NOW)
2533 );
2534 }
2535 }
2536 }
2537}
2538
2539#[cfg(test)]
2540mod parsed_note_content_tests {
2541 use super::*;
2542 use serde_json::json;
2543
2544 #[test]
2545 fn issue2757_content_is_opaque_to_metadata_and_format_reductions() {
2546 let payload = json!([
2547 {"id":"11111111-1111-4111-8111-111111111111", "full_id":"keep", "namespace":"local",
2548 "created_at":"2026-09-15T12:34:56.123456Z", "score":0.123456789,
2549 "nil":null, "empty":"", "array":[], "object":{},
2550 "properties":{"id":"11111111-1111-4111-8111-111111111111"}},
2551 {"status":"pending"}
2552 ]);
2553 for (scope, response, pointer) in [
2554 (
2555 NoteContentScope::Record,
2556 json!({"id":"22222222-2222-4222-8222-222222222222", "content":payload}),
2557 "/content",
2558 ),
2559 (
2560 NoteContentScope::Items,
2561 json!({"items":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "requested_limit":1, "effective_limit":1, "limit_clamped":false}),
2562 "/items/0/content",
2563 ),
2564 (
2565 NoteContentScope::Notes,
2566 json!({"notes":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "next_after":null}),
2567 "/notes/0/content",
2568 ),
2569 ] {
2570 let presented =
2571 scope.protect(response, |value| present(value, PresentationMode::Agent, 0));
2572 for format in [OutputFormat::Json, OutputFormat::Auto, OutputFormat::Table] {
2573 let rendered = render_format_with_note_content(
2574 presented.clone(),
2575 format,
2576 PresentationMode::Agent,
2577 scope,
2578 );
2579 let actual: Value = serde_json::from_str(&rendered)
2580 .expect("single note stays a JSON record, never a table of its body");
2581 let expected = if format == OutputFormat::Table {
2582 Value::String(payload.to_string())
2583 } else {
2584 payload.clone()
2585 };
2586 assert_eq!(actual.pointer(pointer), Some(&expected));
2587 }
2588 }
2589 }
2590}