1use std::{cmp::Ordering, 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 {
595 if let Some(dt) = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros) {
596 return dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
597 }
598
599 const CYCLE_MICROS: i64 = 146_097 * 86_400 * 1_000_000;
602 let cycles = micros.div_euclid(CYCLE_MICROS);
603 let reduced =
604 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros.rem_euclid(CYCLE_MICROS))
605 .expect("400-year remainder is within Chrono's range");
606 let year = i64::from(chrono::Datelike::year(&reduced)) + cycles * 400;
607 let rendered = reduced.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
608 format!("{year:+05}{}", &rendered[4..])
609}
610
611pub fn rfc3339_to_utc_micros(raw: &str) -> Result<i64, chrono::ParseError> {
620 chrono::DateTime::parse_from_rfc3339(raw.trim()).map(|dt| dt.timestamp_micros())
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
625#[serde(rename_all = "snake_case")]
626pub enum PresentationMode {
627 #[default]
633 Agent,
634 Verbose,
638 Human,
645}
646
647const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
653 "completed_at",
654 "deleted_at",
655 "due_at",
656 "read_at",
657 "started_at",
658 "superseded_at",
659 "applied_at",
660 "withdrawn_at",
661 "reviewed_at",
662 "parent_id",
663 "superseded_by",
664 "replaced_by",
665 "poll_interval_secs",
666 "stalled",
667 "last_success_at",
668 "last_poll_attempt_at",
669 "last_failure_at",
670 "last_error",
671 "consecutive_failures",
672];
673
674const EMPTY_ARRAY_PRESERVE: &[&str] = &[
679 "items",
680 "entities",
681 "notes",
682 "edges",
683 "results",
684 "entries",
685 "neighbors",
686];
687
688fn is_stable_list_envelope(map: &Map<String, Value>) -> bool {
689 map.contains_key("requested_limit")
690 && map.contains_key("effective_limit")
691 && map.contains_key("limit_clamped")
692 && EMPTY_ARRAY_PRESERVE
693 .iter()
694 .any(|field| map.contains_key(*field))
695}
696
697fn is_keyset_cursor_envelope(map: &Map<String, Value>) -> bool {
704 map.contains_key("next_after")
705 && (map.get("results").is_some_and(Value::is_array)
706 || (map.get("head_seq").is_some_and(Value::is_number)
707 && map.get("entries").is_some_and(Value::is_array)))
708}
709
710fn is_stream_entry(map: &Map<String, Value>) -> bool {
711 map.get("seq").is_some_and(Value::is_number)
712 && map.contains_key("id")
713 && map.contains_key("created_at")
714 && map.contains_key("record")
715}
716
717const PAYLOAD_TIMESTAMP_FIELDS: &[&str] = &["trigger_at", "due"];
733
734const ROUND_TRIP_FULL_UUID_FIELDS: &[&str] = &[
744 "context_entity_id",
745 "thread_id",
746 "outbound_ref",
747 "parent_id",
748 "session_id",
749 "project_id",
750];
751
752const SCORE_FIELDS: &[&str] = &[
754 "score",
755 "rank_score",
756 "vector_similarity",
757 "keyword_score",
758 "salience",
759 "decay_factor",
760 "rrf_score",
761 "similarity",
762 "cross_encoder_score",
763 "graph_proximity_score",
764];
765
766const UUID_CANONICAL_LEN: usize = 36;
768
769fn should_shorten_uuid_field(key: &str) -> bool {
777 if key == "full_id" || key == "bridge_instance_id" || ROUND_TRIP_FULL_UUID_FIELDS.contains(&key)
778 {
779 return false;
780 }
781 key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
782}
783
784pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
794 present_with_policy(
795 value,
796 mode,
797 now_unix_seconds,
798 VerbPresentationPolicy::Standard,
799 )
800}
801
802pub fn present_with_policy(
805 value: Value,
806 mode: PresentationMode,
807 now_unix_seconds: i64,
808 policy: VerbPresentationPolicy,
809) -> Value {
810 present_with_policy_at(value, mode, now_unix_seconds.into(), policy)
811}
812
813#[derive(Clone, Copy)]
816pub struct PresentationNow {
817 seconds: i64,
818 nanoseconds: u32,
819}
820
821impl From<i64> for PresentationNow {
822 fn from(seconds: i64) -> Self {
823 Self {
824 seconds,
825 nanoseconds: 0,
826 }
827 }
828}
829
830impl From<i32> for PresentationNow {
831 fn from(seconds: i32) -> Self {
832 i64::from(seconds).into()
833 }
834}
835
836impl From<chrono::DateTime<chrono::Utc>> for PresentationNow {
837 fn from(now: chrono::DateTime<chrono::Utc>) -> Self {
838 Self {
839 seconds: now.timestamp(),
840 nanoseconds: now.timestamp_subsec_nanos(),
841 }
842 }
843}
844
845pub fn present_with_policy_at(
847 value: Value,
848 mode: PresentationMode,
849 now: PresentationNow,
850 policy: VerbPresentationPolicy,
851) -> Value {
852 if policy == VerbPresentationPolicy::AlwaysVerbose {
853 return value;
854 }
855 match mode {
856 PresentationMode::Verbose | PresentationMode::Human => value,
857 PresentationMode::Agent => {
858 let mut value = value;
862 let agenda_next = (policy == VerbPresentationPolicy::AgendaContinuation)
863 .then(|| value.as_object_mut().and_then(|map| map.remove("next")))
864 .flatten();
865 let agenda_empty = policy == VerbPresentationPolicy::AgendaContinuation
866 && value
867 .get("events")
868 .and_then(Value::as_array)
869 .is_some_and(Vec::is_empty);
870 let config = AgentTransformConfig {
871 preserved_nulls: LIFECYCLE_NULL_PRESERVE.iter().copied().collect(),
872 scores: SCORE_FIELDS.iter().copied().collect(),
873 payload_timestamps: PAYLOAD_TIMESTAMP_FIELDS.iter().copied().collect(),
874 now,
875 };
876 let mut value = transform_agent(
877 value,
878 &config,
879 AgentTreeContext {
880 inside_properties: false,
881 object_properties: false,
882 array_member: false,
883 receipt_context: match policy {
884 VerbPresentationPolicy::StreamAppendReceipt => ReceiptContext::AppendRoot,
885 VerbPresentationPolicy::StreamBatchReceipts => ReceiptContext::BatchRoot,
886 _ => ReceiptContext::None,
887 },
888 },
889 );
890 if let Some(map) = value.as_object_mut() {
891 if let Some(next) = agenda_next {
892 map.insert("next".into(), next);
893 }
894 if agenda_empty {
895 map.insert("events".into(), Value::Array(Vec::new()));
896 }
897 }
898 value
899 }
900 }
901}
902
903#[derive(Clone, Copy, PartialEq, Eq)]
904enum ReceiptContext {
905 None,
906 AppendRoot,
907 BatchRoot,
908 BatchResults,
909 BatchMember,
910}
911
912struct AgentTransformConfig {
913 preserved_nulls: HashSet<&'static str>,
914 scores: HashSet<&'static str>,
915 payload_timestamps: HashSet<&'static str>,
916 now: PresentationNow,
917}
918
919#[derive(Clone, Copy)]
920struct AgentTreeContext {
921 inside_properties: bool,
922 object_properties: bool,
923 array_member: bool,
924 receipt_context: ReceiptContext,
925}
926
927fn transform_agent(
933 value: Value,
934 config: &AgentTransformConfig,
935 context: AgentTreeContext,
936) -> Value {
937 match value {
938 Value::Object(map) => {
939 let preserve_list_envelope =
940 is_stable_list_envelope(&map) || is_keyset_cursor_envelope(&map);
941 let stream_entry = is_stream_entry(&map);
942 let relative_created_at = (context.array_member
943 && !context.inside_properties
944 && !map.contains_key("created_at_relative")
945 && !matches!(
946 context.receipt_context,
947 ReceiptContext::AppendRoot | ReceiptContext::BatchMember
948 ))
949 .then(|| map.get("created_at").and_then(Value::as_str))
950 .flatten()
951 .and_then(|raw| {
952 let (_, created_at) = exact_timestamp(raw)?;
953 let age = config.now.seconds.checked_sub(created_at)?;
954 age.checked_sub(i64::from(
957 timestamp_fraction_cmp(raw, config.now.nanoseconds) == Ordering::Greater,
958 ))
959 })
960 .filter(|difference| *difference >= 0)
961 .map(relative_time);
962 let mut out = Map::new();
963 for (k, v) in map {
964 if stream_entry && k == "record" {
965 out.insert(k, v);
966 continue;
967 }
968 if v.is_string()
969 && ((context.receipt_context == ReceiptContext::AppendRoot
970 && k == "created_at")
971 || (context.receipt_context == ReceiptContext::BatchMember
972 && matches!(k.as_str(), "created_at" | "updated_at")))
973 {
974 out.insert(k, v);
975 continue;
976 }
977 let child_receipt_context = if context.receipt_context == ReceiptContext::BatchRoot
978 && k == "results"
979 && v.is_array()
980 {
981 ReceiptContext::BatchResults
982 } else {
983 ReceiptContext::None
984 };
985 let child_inside_properties = context.inside_properties || k == "properties";
989 let child_object_properties =
990 context.object_properties || (k == "properties" && v.is_object());
991 let transformed = transform_field_agent(
992 &k,
993 v,
994 config,
995 AgentFieldContext {
996 inside_properties: child_inside_properties,
997 object_properties: child_object_properties,
998 preserve_list_envelope,
999 receipt_context: child_receipt_context,
1000 },
1001 );
1002 match transformed {
1003 None => {} Some(tv) => {
1005 out.insert(k, tv);
1006 }
1007 }
1008 }
1009 if let Some(relative) = relative_created_at {
1010 out.insert("created_at_relative".to_string(), Value::String(relative));
1011 }
1012 Value::Object(out)
1013 }
1014 Value::Array(arr) => {
1015 let items: Vec<Value> = arr
1016 .into_iter()
1017 .map(|v| {
1018 transform_agent(
1019 v,
1020 config,
1021 AgentTreeContext {
1022 inside_properties: context.inside_properties,
1023 object_properties: context.object_properties,
1024 array_member: true,
1025 receipt_context: if context.receipt_context
1026 == ReceiptContext::BatchResults
1027 {
1028 ReceiptContext::BatchMember
1029 } else {
1030 ReceiptContext::None
1031 },
1032 },
1033 )
1034 })
1035 .collect();
1036 Value::Array(items)
1037 }
1038 other => other,
1039 }
1040}
1041
1042#[derive(Clone, Copy)]
1054struct AgentFieldContext {
1055 inside_properties: bool,
1056 object_properties: bool,
1057 preserve_list_envelope: bool,
1058 receipt_context: ReceiptContext,
1059}
1060
1061fn transform_field_agent(
1062 key: &str,
1063 value: Value,
1064 config: &AgentTransformConfig,
1065 context: AgentFieldContext,
1066) -> Option<Value> {
1067 match &value {
1068 Value::Null => {
1070 if config.preserved_nulls.contains(key)
1071 || (context.preserve_list_envelope && key == "next_after")
1072 {
1073 Some(value)
1074 } else {
1075 None
1076 }
1077 }
1078 Value::Array(a)
1080 if context.preserve_list_envelope
1081 && a.is_empty()
1082 && EMPTY_ARRAY_PRESERVE.contains(&key) =>
1083 {
1084 Some(value)
1085 }
1086 Value::String(s) if s.is_empty() && context.object_properties => Some(value),
1092 Value::String(s) if s.is_empty() => None,
1094 Value::Array(a) if a.is_empty() => None,
1095 Value::Object(o) if o.is_empty() => None,
1096 Value::Number(_) if config.scores.contains(key) => {
1098 if let Some(f) = value.as_f64() {
1099 Some(truncate_to_3_sig_figs(f))
1100 } else {
1101 Some(value)
1102 }
1103 }
1104 Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
1106 Some(Value::String(s[..8].to_string()))
1107 }
1108 Value::String(s)
1111 if !context.inside_properties
1112 && !config.payload_timestamps.contains(key)
1113 && looks_like_iso8601(s) =>
1114 {
1115 Some(Value::String(
1116 exact_timestamp(s).map_or_else(|| s.clone(), |(rendered, _)| rendered),
1117 ))
1118 }
1119 Value::Object(_) | Value::Array(_) => Some(transform_agent(
1121 value,
1122 config,
1123 AgentTreeContext {
1124 inside_properties: context.inside_properties,
1125 object_properties: context.object_properties,
1126 array_member: false,
1127 receipt_context: context.receipt_context,
1128 },
1129 )),
1130 _ => Some(value),
1132 }
1133}
1134
1135fn is_canonical_uuid(s: &str) -> bool {
1137 if s.len() != UUID_CANONICAL_LEN {
1138 return false;
1139 }
1140 let b = s.as_bytes();
1141 b[8] == b'-'
1143 && b[13] == b'-'
1144 && b[18] == b'-'
1145 && b[23] == b'-'
1146 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1147 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1148 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1149 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1150 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1151}
1152
1153fn looks_like_iso8601(s: &str) -> bool {
1157 if s.len() < 16 {
1158 return false;
1159 }
1160 let b = s.as_bytes();
1161 b[4] == b'-'
1162 && b[7] == b'-'
1163 && b[10] == b'T'
1164 && b[13] == b':'
1165 && b[..4].iter().all(|c| c.is_ascii_digit())
1166 && b[5..7].iter().all(|c| c.is_ascii_digit())
1167 && b[8..10].iter().all(|c| c.is_ascii_digit())
1168 && b[11..13].iter().all(|c| c.is_ascii_digit())
1169}
1170
1171fn exact_timestamp(s: &str) -> Option<(String, i64)> {
1173 let unix = parse_iso8601_unix(s)?;
1174 let tail = s.get(19..)?;
1175 let fraction = if let Some(after_dot) = tail.strip_prefix('.') {
1176 let digits = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1177 tail.get(..digits + 1)?
1178 } else {
1179 ""
1180 };
1181 let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(unix, 0)?;
1182 Some((
1183 format!("{}{fraction}Z", utc.format("%Y-%m-%dT%H:%M:%S")),
1184 unix,
1185 ))
1186}
1187
1188fn timestamp_fraction_cmp(timestamp: &str, now_nanoseconds: u32) -> Ordering {
1191 let fraction = if timestamp.as_bytes().get(19) == Some(&b'.') {
1192 timestamp.as_bytes()[20..]
1193 .split(|byte| !byte.is_ascii_digit())
1194 .next()
1195 .unwrap_or_default()
1196 } else {
1197 &[]
1198 };
1199 let now_fraction = format!("{now_nanoseconds:09}");
1200 for index in 0..fraction.len().max(9) {
1201 let created_digit = fraction.get(index).copied().unwrap_or(b'0');
1202 let now_digit = now_fraction.as_bytes().get(index).copied().unwrap_or(b'0');
1203 match created_digit.cmp(&now_digit) {
1204 Ordering::Equal => {}
1205 difference => return difference,
1206 }
1207 }
1208 Ordering::Equal
1209}
1210
1211fn parse_iso8601_unix(s: &str) -> Option<i64> {
1216 if s.len() < 20 {
1217 return None;
1218 }
1219 let b = s.as_bytes();
1220 if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
1221 return None;
1222 }
1223 let year: i64 = parse_digits(&b[0..4])?;
1224 let month: i64 = parse_digits(&b[5..7])?;
1225 let day: i64 = parse_digits(&b[8..10])?;
1226 let hour: i64 = parse_digits(&b[11..13])?;
1227 let minute: i64 = parse_digits(&b[14..16])?;
1228 let second: i64 = parse_digits(&b[17..19])?;
1229
1230 let local = chrono::NaiveDate::from_ymd_opt(
1231 year.try_into().ok()?,
1232 month.try_into().ok()?,
1233 day.try_into().ok()?,
1234 )?
1235 .and_hms_opt(
1236 hour.try_into().ok()?,
1237 minute.try_into().ok()?,
1238 second.try_into().ok()?,
1239 )?
1240 .and_utc()
1241 .timestamp();
1242 let offset_secs = parse_tz_offset_secs(s.get(19..)?)?;
1243 local.checked_sub(offset_secs)
1244}
1245
1246fn parse_tz_offset_secs(tail: &str) -> Option<i64> {
1256 let mut rest = tail;
1257 if let Some(after_dot) = rest.strip_prefix('.') {
1258 let frac_len = after_dot.bytes().take_while(u8::is_ascii_digit).count();
1259 if frac_len == 0 {
1260 return None;
1261 }
1262 rest = &after_dot[frac_len..];
1263 }
1264
1265 if rest == "Z" {
1266 return Some(0);
1267 }
1268
1269 let sign: i64 = match rest.as_bytes().first()? {
1270 b'+' => 1,
1271 b'-' => -1,
1272 _ => return None,
1273 };
1274 let digits = &rest[1..];
1275 let (hh, mm) = match digits.len() {
1276 5 if digits.as_bytes()[2] == b':' => (
1278 parse_digits(&digits.as_bytes()[0..2])?,
1279 parse_digits(&digits.as_bytes()[3..5])?,
1280 ),
1281 4 => (
1283 parse_digits(&digits.as_bytes()[0..2])?,
1284 parse_digits(&digits.as_bytes()[2..4])?,
1285 ),
1286 _ => return None,
1287 };
1288 if hh > 23 || mm > 59 {
1289 return None;
1290 }
1291 Some(sign * (hh * 3600 + mm * 60))
1292}
1293
1294fn parse_digits(b: &[u8]) -> Option<i64> {
1295 let s = std::str::from_utf8(b).ok()?;
1296 s.parse().ok()
1297}
1298
1299fn relative_time(diff_secs: i64) -> String {
1301 if diff_secs < 60 {
1302 format!("{diff_secs}s ago")
1303 } else if diff_secs < 3600 {
1304 format!("{}m ago", diff_secs / 60)
1305 } else if diff_secs < 86400 {
1306 format!("{}h ago", diff_secs / 3600)
1307 } else {
1308 format!("{}d ago", diff_secs / 86400)
1309 }
1310}
1311
1312fn truncate_to_3_sig_figs(f: f64) -> Value {
1314 if f == 0.0 || !f.is_finite() {
1315 return Value::from(f);
1316 }
1317 let magnitude = f.abs().log10().floor() as i32;
1318 let factor = 10f64.powi(2 - magnitude);
1319 let rounded = (f * factor).round() / factor;
1320 serde_json::Number::from_f64(rounded)
1322 .map(Value::Number)
1323 .unwrap_or(Value::from(rounded))
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329 use serde_json::json;
1330
1331 const NOW: i64 = 1_748_016_480;
1333
1334 #[test]
1335 fn micros_to_iso_preserves_chrono_range_spelling() {
1336 for (micros, expected) in [
1337 (0, "1970-01-01T00:00:00.000000Z"),
1338 (-1, "1969-12-31T23:59:59.999999Z"),
1339 (1_748_016_480_123_456, "2025-05-23T16:08:00.123456Z"),
1340 (-62_198_755_200_000_000, "-0001-01-01T00:00:00.000000Z"),
1341 (253_402_300_800_000_000, "+10000-01-01T00:00:00.000000Z"),
1342 (-8_334_601_228_800_000_000, "-262143-01-01T00:00:00.000000Z"),
1343 (8_210_266_876_799_999_999, "+262142-12-31T23:59:59.999999Z"),
1344 ] {
1345 assert!(
1346 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros).is_some(),
1347 "{micros}"
1348 );
1349 assert_eq!(micros_to_iso(micros), expected, "{micros}");
1350 }
1351 }
1352
1353 #[test]
1354 fn micros_to_iso_formats_outside_chrono_range_exactly() {
1355 for (micros, expected) in [
1356 (i64::MIN, "-290308-12-21T19:59:05.224192Z"),
1357 (i64::MAX, "+294247-01-10T04:00:54.775807Z"),
1358 (-8_334_601_228_800_000_001, "-262144-12-31T23:59:59.999999Z"),
1359 (8_210_266_876_800_000_000, "+262143-01-01T00:00:00.000000Z"),
1360 (-8_898_108_636_303_876_544, "-280000-02-29T12:34:56.123456Z"),
1361 (8_776_940_198_400_000_001, "+280100-03-01T00:00:00.000001Z"),
1362 ] {
1363 assert!(
1364 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros).is_none(),
1365 "{micros}"
1366 );
1367 assert_eq!(micros_to_iso(micros), expected, "{micros}");
1368 }
1369 }
1370
1371 #[test]
1372 fn expanded_micros_timestamps_pass_through_agent_without_relative_labels() {
1373 for (micros, expected) in [
1374 (i64::MIN, "-290308-12-21T19:59:05.224192Z"),
1375 (i64::MAX, "+294247-01-10T04:00:54.775807Z"),
1376 ] {
1377 let shown = present(
1378 json!({"items": [{"created_at": micros_to_iso(micros)}]}),
1379 PresentationMode::Agent,
1380 NOW,
1381 );
1382 assert_eq!(shown, json!({"items": [{"created_at": expected}]}));
1383 assert!(rfc3339_to_utc_micros(expected).is_err());
1384 }
1385 }
1386
1387 #[test]
1388 fn rfc3339_to_utc_micros_round_trips_and_rejects_partial_forms() {
1389 let micros = 1_748_016_480_000_000_i64;
1390 assert_eq!(rfc3339_to_utc_micros(µs_to_iso(micros)), Ok(micros));
1391 assert_eq!(
1394 rfc3339_to_utc_micros(" 2025-05-23T12:08:00-04:00 "),
1395 Ok(micros)
1396 );
1397 assert!(rfc3339_to_utc_micros("2026-05-23").is_err());
1398 assert!(rfc3339_to_utc_micros("2026-05-23T16:18:00").is_err());
1399 }
1400
1401 fn agent(v: Value) -> Value {
1402 present(v, PresentationMode::Agent, NOW)
1403 }
1404
1405 #[test]
1406 fn agent_preserves_empty_string_under_properties() {
1407 let v = json!({
1412 "id": "a1b2c3d4",
1413 "summary": "",
1414 "properties": {"k": "", "nested": {"deep": ""}},
1415 });
1416 let out = agent(v);
1417 assert_eq!(out["properties"]["k"], json!(""));
1418 assert_eq!(out["properties"]["nested"]["deep"], json!(""));
1419 assert!(out.get("summary").is_none());
1421 }
1422
1423 #[test]
1424 fn agent_drops_scalar_empty_properties_value() {
1425 let v = json!({
1429 "id": "a1b2c3d4",
1430 "properties": "",
1431 });
1432 let out = agent(v);
1433 assert!(out.get("properties").is_none());
1434 }
1435
1436 #[test]
1437 fn agent_still_drops_empty_containers_under_properties() {
1438 let v = json!({
1441 "id": "a1b2c3d4",
1442 "properties": {"tags": [], "meta": {}, "kept": "x"},
1443 });
1444 let out = agent(v);
1445 assert!(out["properties"].get("tags").is_none());
1446 assert!(out["properties"].get("meta").is_none());
1447 assert_eq!(out["properties"]["kept"], json!("x"));
1448 }
1449
1450 #[test]
1451 fn verbose_passthrough() {
1452 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
1453 let out = present(v.clone(), PresentationMode::Verbose, NOW);
1454 assert_eq!(out, v);
1455 }
1456
1457 #[test]
1458 fn agent_shortens_uuid() {
1459 let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
1460 let out = agent(v);
1461 assert_eq!(out["id"], json!("a1b2c3d4"));
1462 }
1463
1464 #[test]
1465 fn agent_drops_empty_string() {
1466 let v = json!({"title": "ok", "description": ""});
1467 let out = agent(v);
1468 assert!(out.get("description").is_none());
1469 assert_eq!(out["title"], json!("ok"));
1470 }
1471
1472 #[test]
1473 fn agent_drops_empty_array() {
1474 let v = json!({"tags": [], "title": "ok"});
1475 let out = agent(v);
1476 assert!(out.get("tags").is_none());
1477 }
1478
1479 #[test]
1480 fn agent_preserves_empty_list_page_arrays() {
1481 let v = json!({
1482 "items": [],
1483 "entities": [],
1484 "notes": [],
1485 "edges": [],
1486 "results": [],
1487 "entries": [],
1488 "neighbors": [],
1489 "next_after": null,
1490 "requested_limit": 10,
1491 "effective_limit": 10,
1492 "limit_clamped": false,
1493 });
1494 let out = agent(v);
1495 for key in EMPTY_ARRAY_PRESERVE {
1496 assert_eq!(out[*key], json!([]), "missing structural key {key}");
1497 }
1498 assert_eq!(out["next_after"], json!(null));
1499 }
1500
1501 #[test]
1502 fn agent_preserves_keyset_cursor_envelope_completion_signals() {
1503 let out = agent(json!({
1505 "results": [],
1506 "limit": 500,
1507 "order": "created_at ASC, id ASC",
1508 "next_after": null,
1509 }));
1510 assert_eq!(out["results"], json!([]));
1511 assert!(
1512 out.get("next_after").is_some_and(Value::is_null),
1513 "terminal cursor page must keep next_after:null: {out}"
1514 );
1515 }
1516
1517 #[test]
1518 fn agent_results_without_next_after_is_not_a_cursor_envelope() {
1519 let out = agent(json!({"results": [], "limit": 500, "title": "ordinary response"}));
1520 assert!(out.get("results").is_none());
1521 }
1522
1523 #[test]
1524 fn agent_still_drops_empty_arrays_outside_list_envelopes() {
1525 let out = agent(json!({"items": [], "entities": [], "title": "ordinary response"}));
1526 assert!(out.get("items").is_none());
1527 assert!(out.get("entities").is_none());
1528 }
1529
1530 #[test]
1531 fn agent_drops_empty_object() {
1532 let v = json!({"properties": {}, "title": "ok"});
1533 let out = agent(v);
1534 assert!(out.get("properties").is_none());
1535 }
1536
1537 #[test]
1538 fn agent_drops_non_lifecycle_null() {
1539 let v = json!({"result": null, "title": "ok"});
1540 let out = agent(v);
1541 assert!(out.get("result").is_none());
1542 }
1543
1544 #[test]
1545 fn agent_preserves_lifecycle_null() {
1546 let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
1547 let out = agent(v);
1548 assert_eq!(out["completed_at"], json!(null));
1549 assert_eq!(out["due_at"], json!(null));
1550 }
1551
1552 #[test]
1553 fn agent_preserves_relationship_null() {
1554 let v = json!({"parent_id": null, "superseded_by": null});
1555 let out = agent(v);
1556 assert_eq!(out["parent_id"], json!(null));
1557 assert_eq!(out["superseded_by"], json!(null));
1558 }
1559
1560 #[test]
1561 fn agent_preserves_unknown_channel_heartbeat_nulls() {
1562 let v = json!({
1563 "channels": [{
1564 "poll_interval_secs": null,
1565 "stalled": null,
1566 "last_success_at": null,
1567 "last_poll_attempt_at": null,
1568 "last_failure_at": null,
1569 "last_error": null,
1570 "consecutive_failures": null,
1571 "quarantined_count": 1,
1572 }]
1573 });
1574 let out = agent(v);
1575 let channel = out["channels"][0].as_object().expect("channel object");
1576 for field in [
1577 "poll_interval_secs",
1578 "stalled",
1579 "last_success_at",
1580 "last_poll_attempt_at",
1581 "last_failure_at",
1582 "last_error",
1583 "consecutive_failures",
1584 ] {
1585 assert_eq!(
1586 channel.get(field),
1587 Some(&Value::Null),
1588 "Agent presentation must preserve unknown heartbeat fact `{field}`"
1589 );
1590 }
1591 }
1592
1593 #[test]
1594 fn agent_truncates_score_field() {
1595 let v = json!({"score": 0.12345678});
1596 let out = agent(v);
1597 let s = out["score"].as_f64().unwrap();
1598 assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
1599 }
1600
1601 #[test]
1602 fn agent_presents_ranking_alias_and_nested_evidence() {
1603 let canonical = json!([
1604 {
1605 "rank_score": 0.0325224749,
1606 "score": 0.0325224749,
1607 "rank_score_kind": "rrf",
1608 "signals": {"vector_similarity": 0.842567, "keyword_score": 18.375}
1609 },
1610 {"rank_score": 0.0, "score": 0.0, "rank_score_kind": "keyword", "signals": {}}
1611 ]);
1612 for mode in [PresentationMode::Verbose, PresentationMode::Human] {
1613 assert_eq!(present(canonical.clone(), mode, NOW), canonical);
1614 }
1615 let shown = agent(canonical);
1616 assert_eq!(shown[0]["rank_score"], json!(0.0325));
1617 assert_eq!(shown[0]["score"], shown[0]["rank_score"]);
1618 assert_eq!(shown[0]["rank_score_kind"], "rrf");
1619 assert_eq!(shown[0]["signals"]["vector_similarity"], json!(0.843));
1620 assert_eq!(shown[0]["signals"]["keyword_score"], json!(18.4));
1621 assert_eq!(shown[1]["rank_score"], json!(0.0));
1622 assert_eq!(shown[1]["score"], shown[1]["rank_score"]);
1623 assert_eq!(shown[1]["rank_score_kind"], "keyword");
1624 assert!(shown[1].get("signals").is_none());
1625 }
1626
1627 #[test]
1628 fn agent_renders_old_timestamp_exactly() {
1629 let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
1630 let out = agent(v);
1631 assert_eq!(out["created_at"], json!("2020-01-01T10:30:45.123456Z"));
1632 assert!(out.get("created_at_relative").is_none());
1633 }
1634
1635 #[test]
1636 fn agent_renders_recent_timestamp_exactly() {
1637 let ts_unix = NOW - 180;
1638 let ts = unix_to_iso8601(ts_unix);
1639 let v = json!({"updated_at": ts.clone()});
1640 let out = agent(v);
1641 assert_eq!(out["updated_at"], json!(ts));
1642 }
1643
1644 #[test]
1645 fn agent_does_not_compact_top_level_trigger_at_field() {
1646 let at = "2026-07-11T19:00:00-04:00";
1655 let v = json!({
1656 "id": "a1b2c3d4",
1657 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1658 "event_type": "remind",
1659 "trigger_at": at,
1660 "repeat": null,
1661 "status": "pending",
1662 });
1663 let out = agent(v);
1664 assert_eq!(out["trigger_at"], json!(at));
1665 }
1666
1667 #[test]
1668 fn agent_does_not_compact_top_level_trigger_at_utc() {
1669 let at = "2026-07-11T23:00:00Z";
1670 let v = json!({"trigger_at": at});
1671 let out = agent(v);
1672 assert_eq!(out["trigger_at"], json!(at));
1673 }
1674
1675 #[test]
1676 fn agent_does_not_compact_top_level_trigger_at_offset_less() {
1677 let at = "2026-07-11T23:00:00";
1678 let v = json!({"trigger_at": at});
1679 let out = agent(v);
1680 assert_eq!(out["trigger_at"], json!(at));
1681 }
1682
1683 #[test]
1684 fn agent_still_renders_other_top_level_timestamps_alongside_trigger_at() {
1685 let v = json!({
1688 "trigger_at": "2026-07-11T19:00:00-04:00",
1689 "created_at": "2020-01-01T10:30:45.123456Z",
1690 });
1691 let out = agent(v);
1692 assert_eq!(out["trigger_at"], json!("2026-07-11T19:00:00-04:00"));
1693 assert_eq!(out["created_at"], json!("2020-01-01T10:30:45.123456Z"));
1694 }
1695
1696 #[test]
1697 fn agent_does_not_compact_top_level_due() {
1698 let due = "2026-08-01T09:30:15-04:00";
1703 let v = json!({"due": due});
1704 let out = agent(v);
1705 assert_eq!(out["due"], json!(due));
1706 }
1707
1708 #[test]
1709 fn agent_still_protects_nested_trigger_at_under_properties() {
1710 let at = "2026-07-11T19:00:00-04:00";
1714 let v = json!({
1715 "id": "a1b2c3d4",
1716 "properties": {"trigger_at": at, "status": "pending"},
1717 });
1718 let out = agent(v);
1719 assert_eq!(out["properties"]["trigger_at"], json!(at));
1720 }
1721
1722 #[test]
1723 fn agent_recurses_into_nested_objects() {
1724 let v = json!({
1725 "items": [
1726 {
1727 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1728 "tags": [],
1729 "score": 0.9999
1730 }
1731 ]
1732 });
1733 let out = agent(v);
1734 let item = &out["items"][0];
1735 assert_eq!(item["id"], json!("a1b2c3d4"));
1736 assert!(item.get("tags").is_none());
1737 let s = item["score"].as_f64().unwrap();
1738 assert!((s - 1.0).abs() < 1e-9);
1739 }
1740
1741 #[test]
1744 fn agent_preserves_full_id_as_36_chars() {
1745 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1746 let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
1747 let out = agent(v);
1748 assert_eq!(
1750 out["id"],
1751 json!("a1b2c3d4"),
1752 "id should be 8-char short form"
1753 );
1754 assert_eq!(
1756 out["full_id"].as_str().unwrap().len(),
1757 36,
1758 "full_id must be 36 chars in agent mode"
1759 );
1760 assert_eq!(
1761 out["full_id"],
1762 json!(uuid),
1763 "full_id must equal the original UUID"
1764 );
1765 assert!(
1767 out["full_id"]
1768 .as_str()
1769 .unwrap()
1770 .starts_with(out["id"].as_str().unwrap()),
1771 "full_id must start with the short id prefix"
1772 );
1773 }
1774
1775 #[test]
1776 fn is_canonical_uuid_recognizes_valid() {
1777 assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1778 assert!(!is_canonical_uuid("a1b2c3d4"));
1779 assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
1780 }
1781
1782 #[test]
1783 fn looks_like_iso8601_recognizes_valid() {
1784 assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
1785 assert!(!looks_like_iso8601("not a timestamp"));
1786 assert!(!looks_like_iso8601("2026-05-23"));
1787 }
1788
1789 fn unix_to_iso8601(unix: i64) -> String {
1791 let (y, mo, d, h, mi, s) = unix_to_civil(unix);
1792 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
1793 }
1794
1795 fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
1796 let s = unix % 86400;
1797 let days = unix / 86400;
1798 let h = s / 3600;
1799 let m = (s % 3600) / 60;
1800 let sec = s % 60;
1801 let z = days + 719468;
1803 let era = z.div_euclid(146097);
1804 let doe = z - era * 146097;
1805 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1806 let y = yoe + era * 400;
1807 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1808 let mp = (5 * doy + 2) / 153;
1809 let d = doy - (153 * mp + 2) / 5 + 1;
1810 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
1811 let y = if mo <= 2 { y + 1 } else { y };
1812 (y, mo, d, h, m, sec)
1813 }
1814
1815 #[test]
1816 fn agent_does_not_shorten_uuid_shaped_content_fields() {
1817 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1818 let out = agent(json!({
1819 "id": uuid,
1820 "full_id": uuid,
1821 "content": uuid,
1822 "description": uuid,
1823 "title": uuid,
1824 "query": uuid,
1825 }));
1826
1827 assert_eq!(out["id"], json!("a1b2c3d4"));
1828 assert_eq!(out["full_id"], json!(uuid));
1829 assert_eq!(out["content"], json!(uuid));
1830 assert_eq!(out["description"], json!(uuid));
1831 assert_eq!(out["title"], json!(uuid));
1832 assert_eq!(out["query"], json!(uuid));
1833 }
1834
1835 #[test]
1836 fn agent_shortens_suffix_id_fields() {
1837 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1838 let out = agent(json!({
1839 "note_id": uuid,
1840 "source_id": uuid,
1841 "target_id": uuid,
1842 }));
1843
1844 assert_eq!(out["note_id"], json!("a1b2c3d4"));
1845 assert_eq!(out["source_id"], json!("a1b2c3d4"));
1846 assert_eq!(out["target_id"], json!("a1b2c3d4"));
1847 }
1848
1849 #[test]
1850 fn agent_preserves_strict_round_trip_uuid_fields() {
1851 let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
1852 let out = agent(json!({
1853 "context_entity_id": uuid,
1854 "thread_id": uuid,
1855 "session_id": uuid,
1856 "project_id": uuid,
1857 "properties": {
1858 "context_entity_id": uuid,
1859 "thread_id": uuid,
1860 "outbound_ref": uuid,
1861 "parent_id": uuid,
1862 "session_id": uuid,
1863 "project_id": uuid,
1864 },
1865 "parent_id": uuid,
1866 }));
1867
1868 assert_eq!(out["context_entity_id"], json!(uuid));
1869 assert_eq!(out["thread_id"], json!(uuid));
1870 assert_eq!(out["session_id"], json!(uuid));
1871 assert_eq!(out["project_id"], json!(uuid));
1872 assert_eq!(out["properties"]["context_entity_id"], json!(uuid));
1873 assert_eq!(out["properties"]["thread_id"], json!(uuid));
1874 assert_eq!(out["properties"]["outbound_ref"], json!(uuid));
1875 assert_eq!(out["properties"]["parent_id"], json!(uuid));
1876 assert_eq!(out["properties"]["session_id"], json!(uuid));
1877 assert_eq!(out["properties"]["project_id"], json!(uuid));
1878 assert_eq!(out["parent_id"], json!(uuid));
1879 }
1880
1881 #[test]
1885 fn format_verbose_json_preserves_full_shape() {
1886 let v = json!({
1887 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1888 "namespace": "local",
1889 "properties": {"k": "v"},
1890 "title": "test"
1891 });
1892 let rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Verbose);
1893 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1894 assert!(
1896 parsed.get("full_id").is_some(),
1897 "json mode must keep full_id"
1898 );
1899 assert_eq!(
1901 parsed.get("namespace").and_then(Value::as_str),
1902 Some("local")
1903 );
1904 assert!(parsed.get("properties").is_some());
1906 }
1907
1908 #[test]
1913 fn format_agent_json_drops_redundancy() {
1914 let v = json!({
1915 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1916 "namespace": "local",
1917 "title": "test",
1918 "status": "next",
1919 "properties": {"status": "next", "additive": true}
1920 });
1921 let rendered = render_format(v, OutputFormat::Json, PresentationMode::Agent);
1922 let parsed: Value = serde_json::from_str(&rendered).unwrap();
1923 assert!(
1924 parsed.get("full_id").is_some(),
1925 "Agent JSON must keep full_id: it is the machine contract's chaining handle"
1926 );
1927 assert!(parsed.get("namespace").is_none());
1928 assert_eq!(parsed["status"], json!("next"));
1929 assert!(parsed["properties"].get("status").is_none());
1930 assert_eq!(parsed["properties"]["additive"], json!(true));
1931 }
1932
1933 #[test]
1938 fn agent_json_keeps_full_id_while_auto_still_drops_it() {
1939 let v = json!({
1940 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1941 "namespace": "local",
1942 "title": "test",
1943 "properties": {"title": "test", "additive": true}
1944 });
1945
1946 let json_rendered = render_format(v.clone(), OutputFormat::Json, PresentationMode::Agent);
1947 let json_parsed: Value = serde_json::from_str(&json_rendered).unwrap();
1948 assert!(
1949 json_parsed.get("full_id").is_some(),
1950 "Agent JSON must keep full_id"
1951 );
1952 assert!(
1953 json_parsed.get("namespace").is_none(),
1954 "Agent JSON must elide namespace=\"local\""
1955 );
1956 assert!(
1957 json_parsed["properties"].get("title").is_none(),
1958 "Agent JSON must dedupe a duplicate properties entry"
1959 );
1960 assert_eq!(json_parsed["properties"]["additive"], json!(true));
1961
1962 let auto_rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1963 assert!(
1964 !auto_rendered.contains("full_id"),
1965 "Auto (view scope) must still drop full_id: {auto_rendered}"
1966 );
1967 assert!(
1968 !auto_rendered.contains("\"namespace\""),
1969 "Auto must still elide namespace=\"local\": {auto_rendered}"
1970 );
1971 }
1972
1973 #[test]
1975 fn format_auto_homogeneous_array_renders_markdown_table() {
1976 let v = json!([
1977 {"id": "abc", "title": "First"},
1978 {"id": "def", "title": "Second"}
1979 ]);
1980 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
1981 assert!(rendered.starts_with('|'), "must start with |");
1982 assert!(
1983 rendered.contains("| id |") || rendered.contains("| id"),
1984 "must have id column"
1985 );
1986 assert!(rendered.contains("title"), "must have title column");
1987 assert!(rendered.contains("|---|"), "must have separator row");
1988 assert!(rendered.contains("abc"), "must have first row data");
1989 assert!(rendered.contains("Second"), "must have second row data");
1990 }
1991
1992 #[test]
1994 fn format_auto_single_record_renders_compact_json() {
1995 let v = json!({"id": "abc", "title": "Hello World"});
1996 let rendered = render_format(v.clone(), OutputFormat::Auto, PresentationMode::Agent);
1997 let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
1998 assert_eq!(parsed, v, "single record must round-trip losslessly");
1999 assert!(
2000 !rendered.starts_with('|'),
2001 "single record must not be a markdown table"
2002 );
2003 }
2004
2005 #[test]
2009 fn format_auto_single_record_large_payload_survives_whole() {
2010 let briefing = "line one\nline two\n".repeat(500); let v = json!({"id": "abc", "markdown": briefing.clone()});
2012 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2013 let parsed: Value = serde_json::from_str(&rendered).expect("must be valid JSON");
2014 assert_eq!(
2015 parsed.get("markdown").and_then(Value::as_str),
2016 Some(briefing.as_str()),
2017 "large payload field must survive untruncated"
2018 );
2019 }
2020
2021 #[test]
2024 fn format_auto_table_preserves_sibling_scalars() {
2025 let v = json!({
2026 "results": [
2027 {"id": "abc", "title": "First"},
2028 {"id": "def", "title": "Second"}
2029 ],
2030 "has_more": true,
2031 "offset": 20,
2032 "page_size": 2,
2033 "truncated": false
2034 });
2035 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2036 assert!(rendered.contains("| id"), "records must render as a table");
2037 assert!(
2038 rendered.contains("has_more: true"),
2039 "has_more sibling must survive: {rendered}"
2040 );
2041 assert!(
2042 rendered.contains("offset: 20"),
2043 "offset sibling must survive"
2044 );
2045 assert!(
2046 rendered.contains("page_size: 2"),
2047 "page_size sibling must survive"
2048 );
2049 assert!(
2050 rendered.contains("truncated: false"),
2051 "truncated sibling must survive"
2052 );
2053 }
2054
2055 #[test]
2058 fn format_auto_nested_cell_renders_elision_marker() {
2059 let big_nested: Value = json!({"k": "v".repeat(300), "other": {"deep": true}});
2060 let v = json!([
2061 {"id": "abc", "properties": big_nested},
2062 {"id": "def", "properties": {"x": 1}}
2063 ]);
2064 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2065 assert!(
2066 rendered.contains("{…}"),
2067 "nested object cell must render the elision marker: {rendered}"
2068 );
2069 assert!(
2070 !rendered.contains("..."),
2071 "no truncated pseudo-JSON in nested cells"
2072 );
2073 }
2074
2075 #[test]
2077 fn format_auto_scalar_array_cell_renders_compact_json() {
2078 let v = json!([
2079 {"id": "abc", "tags": ["lesson", "khive"]},
2080 {"id": "def", "tags": ["adr"]}
2081 ]);
2082 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2083 assert!(
2084 rendered.contains(r#"["lesson","khive"]"#),
2085 "scalar array cell must be compact JSON: {rendered}"
2086 );
2087 }
2088
2089 #[test]
2092 fn format_auto_identity_columns_not_truncated() {
2093 let long_title = "T".repeat(300);
2094 let long_note = "N".repeat(300);
2095 let v = json!([
2096 {"id": "abc", "title": long_title.clone(), "note": long_note.clone()},
2097 {"id": "def", "title": "short", "note": "short"}
2098 ]);
2099 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2100 assert!(
2101 rendered.contains(&long_title),
2102 "title column must not be truncated"
2103 );
2104 assert!(
2105 !rendered.contains(&long_note),
2106 "non-exempt column must still truncate"
2107 );
2108 }
2109
2110 #[test]
2112 fn format_auto_verbose_disables_truncation() {
2113 let long_note = "N".repeat(300);
2114 let v = json!([
2115 {"id": "abc", "note": long_note.clone()},
2116 {"id": "def", "note": "short"}
2117 ]);
2118 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
2119 assert!(
2120 rendered.contains(&long_note),
2121 "verbose must render full cell content"
2122 );
2123 }
2124
2125 #[test]
2129 fn table_path_hoists_payload_scalars_to_columns() {
2130 let record = |n: u32| {
2134 json!({
2135 "id": format!("evt-{n}"),
2136 "properties": {
2137 "trigger_at": "2026-09-01T14:00:00-04:00",
2138 "status": "pending",
2139 "dispatch_receipt": {"state": "succeeded"}
2140 }
2141 })
2142 };
2143 let out = render_format(
2144 json!([record(1), record(2)]),
2145 OutputFormat::Auto,
2146 PresentationMode::Agent,
2147 );
2148 let header = out.lines().next().expect("table header");
2149 assert!(
2150 header.contains("trigger_at") && header.contains("status"),
2151 "hoisted scalars must appear as table columns, got header: {header}"
2152 );
2153 assert!(
2154 out.contains("2026-09-01T14:00:00-04:00") && out.contains("pending"),
2155 "hoisted values must render in cells:\n{out}"
2156 );
2157 }
2158
2159 #[test]
2160 fn redundancy_drop_no_longer_hoists_outside_tables() {
2161 let v = json!({
2165 "id": "abc",
2166 "properties": {
2167 "trigger_at": "2026-09-01T14:00:00-04:00",
2168 "status": "pending"
2169 }
2170 });
2171 let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2172 assert!(
2173 reduced.get("trigger_at").is_none() && reduced.get("status").is_none(),
2174 "the pre-pass must not hoist; the hoist is table-path only"
2175 );
2176 let props = reduced.get("properties").expect("properties must remain");
2177 assert_eq!(
2178 props.get("status").and_then(Value::as_str),
2179 Some("pending"),
2180 "fallback shape keeps payload fields inside properties"
2181 );
2182 }
2183
2184 #[test]
2185 fn sibling_string_with_newline_renders_as_json_literal() {
2186 let v = json!({
2189 "items": [{"id": "a", "kind": "x"}, {"id": "b", "kind": "y"}],
2190 "note": "line one\nforged_key: forged_value",
2191 "has_more": true
2192 });
2193 let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2194 assert!(
2195 !out.contains("\nforged_key: forged_value"),
2196 "raw newline from a sibling string must not start a new line:\n{out}"
2197 );
2198 assert!(
2199 out.contains(r#"note: "line one\nforged_key: forged_value""#),
2200 "newline-bearing sibling renders as its JSON literal:\n{out}"
2201 );
2202 assert!(out.contains("has_more: true"), "siblings still preserved");
2203 }
2204
2205 #[test]
2206 fn carriage_return_in_cell_collapses_like_newline() {
2207 let v = json!([
2211 {"id": "a", "note": "before\rafter"},
2212 {"id": "b", "note": "one\r\ntwo"}
2213 ]);
2214 let out = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2215 assert!(
2216 !out.contains('\r'),
2217 "no raw carriage return may survive into rendered output:\n{out:?}"
2218 );
2219 assert!(
2220 out.contains("before after") && out.contains("one two"),
2221 "\\r and \\r\\n collapse to spaces inside cells:\n{out}"
2222 );
2223 }
2224
2225 #[test]
2227 fn redundancy_drop_hoist_does_not_overwrite_top_level() {
2228 let v = json!({
2229 "id": "abc",
2230 "status": "active",
2231 "properties": {"status": "pending"}
2232 });
2233 let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2234 assert_eq!(
2235 reduced.get("status").and_then(Value::as_str),
2236 Some("active"),
2237 "existing top-level status must win"
2238 );
2239 assert_eq!(
2240 reduced
2241 .get("properties")
2242 .and_then(|p| p.get("status"))
2243 .and_then(Value::as_str),
2244 Some("pending"),
2245 "conflicting properties value must stay where it was"
2246 );
2247 }
2248
2249 #[test]
2251 fn format_auto_scalar_fallback_compact_json() {
2252 let v = json!(42);
2253 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2254 assert_eq!(rendered, "42");
2255 }
2256
2257 #[test]
2259 fn format_table_forces_markdown_when_array() {
2260 let v = json!({
2261 "items": [
2262 {"name": "A", "score": 1},
2263 {"name": "B", "score": 2}
2264 ]
2265 });
2266 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2267 assert!(
2268 rendered.contains("|"),
2269 "table format must produce markdown table"
2270 );
2271 assert!(rendered.contains("name"), "must have name column");
2272 assert!(rendered.contains("score"), "must have score column");
2273 }
2274
2275 #[test]
2277 fn format_table_falls_back_to_json_when_no_array() {
2278 let v = json!({"single": "value"});
2279 let rendered = render_format(v, OutputFormat::Table, PresentationMode::Agent);
2280 let parsed: Value = serde_json::from_str(&rendered).unwrap();
2282 assert_eq!(parsed["single"], json!("value"));
2283 }
2284
2285 #[test]
2287 fn format_auto_verbose_skips_redundancy_drop() {
2288 let v = json!({
2289 "full_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
2290 "namespace": "local",
2291 "title": "test"
2292 });
2293 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Verbose);
2296 assert!(
2297 rendered.contains("full_id"),
2298 "verbose must preserve full_id"
2299 );
2300 assert!(
2301 rendered.contains("namespace"),
2302 "verbose must preserve namespace"
2303 );
2304 }
2305
2306 #[test]
2310 fn redundancy_drop_does_not_corrupt_error_shape() {
2311 let v = json!({"ok": false, "error": "something failed", "namespace": "local"});
2312 let reduced = apply_redundancy_drop(v.clone(), RedundancyScope::View);
2317 assert!(
2318 reduced.get("error").is_some(),
2319 "redundancy drop must preserve error field"
2320 );
2321 assert_eq!(
2322 reduced.get("ok").and_then(Value::as_bool),
2323 Some(false),
2324 "redundancy drop must preserve ok=false"
2325 );
2326 }
2327
2328 #[test]
2330 fn redundancy_drop_properties_dedup() {
2331 let v = json!({
2332 "id": "abc",
2333 "title": "Same",
2334 "properties": {
2335 "title": "Same", "extra": "unique" }
2338 });
2339 let reduced = apply_redundancy_drop(v, RedundancyScope::View);
2340 let props = reduced.get("properties").expect("properties must remain");
2341 assert!(props.get("extra").is_some(), "unique property must be kept");
2342 assert!(
2343 props.get("title").is_none(),
2344 "duplicate top-level property must be removed"
2345 );
2346 }
2347
2348 #[test]
2350 fn cell_text_truncates_long_values() {
2351 let long = "X".repeat(200);
2352 let v = json!([
2353 {"col": long.clone()},
2354 {"col": "short"}
2355 ]);
2356 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2357 assert!(
2359 rendered.contains("..."),
2360 "long cell must be truncated with ..."
2361 );
2362 assert!(
2363 !rendered.contains(&long),
2364 "full long string must not appear in table"
2365 );
2366 }
2367
2368 #[test]
2374 fn cell_text_truncation_is_utf8_safe() {
2375 let prefix = "a".repeat(119);
2378 let suffix = "中".repeat(10); let long_multibyte = format!("{prefix}{suffix}trailing");
2380 let v = json!([
2381 {"col": long_multibyte.clone()},
2382 {"col": "ok"}
2383 ]);
2384 let rendered = render_format(v, OutputFormat::Auto, PresentationMode::Agent);
2386 assert!(
2387 rendered.contains("..."),
2388 "multibyte cell must be truncated with ..."
2389 );
2390 assert!(
2392 std::str::from_utf8(rendered.as_bytes()).is_ok(),
2393 "rendered output must be valid UTF-8"
2394 );
2395 }
2396
2397 #[test]
2400 fn parse_iso8601_unix_negative_offset_matches_equivalent_utc() {
2401 assert_eq!(
2403 parse_iso8601_unix("2026-07-09T11:55:00-04:00"),
2404 parse_iso8601_unix("2026-07-09T15:55:00Z")
2405 );
2406 }
2407
2408 #[test]
2409 fn parse_iso8601_unix_positive_offset_matches_equivalent_utc() {
2410 assert_eq!(
2412 parse_iso8601_unix("2026-05-23T20:15:00+04:00"),
2413 parse_iso8601_unix("2026-05-23T16:15:00Z")
2414 );
2415 }
2416
2417 #[test]
2418 fn parse_iso8601_unix_zero_offset_matches_z() {
2419 assert_eq!(
2420 parse_iso8601_unix("2026-07-09T15:55:00+00:00"),
2421 parse_iso8601_unix("2026-07-09T15:55:00Z")
2422 );
2423 }
2424
2425 #[test]
2426 fn parse_iso8601_unix_compact_offset_form_matches_colon_form() {
2427 assert_eq!(
2428 parse_iso8601_unix("2026-07-09T11:55:00-0400"),
2429 parse_iso8601_unix("2026-07-09T11:55:00-04:00")
2430 );
2431 }
2432
2433 #[test]
2434 fn parse_iso8601_unix_fractional_seconds_with_offset() {
2435 assert_eq!(
2438 parse_iso8601_unix("2026-07-09T11:55:00.123-04:00"),
2439 parse_iso8601_unix("2026-07-09T15:55:00Z")
2440 );
2441 }
2442
2443 #[test]
2444 fn parse_iso8601_unix_fractional_seconds_with_z() {
2445 assert_eq!(
2446 parse_iso8601_unix("2026-07-09T15:55:00.999Z"),
2447 parse_iso8601_unix("2026-07-09T15:55:00Z")
2448 );
2449 }
2450
2451 #[test]
2452 fn parse_iso8601_unix_rejects_bare_form() {
2453 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00"), None);
2454 }
2455
2456 #[test]
2457 fn parse_iso8601_unix_malformed_tail_returns_none() {
2458 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00X"), None);
2459 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+04"), None);
2460 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00."), None);
2461 }
2462
2463 #[test]
2464 fn parse_iso8601_unix_out_of_range_offset_returns_none() {
2465 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+24:00"), None);
2467 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+2400"), None);
2468 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+01:60"), None);
2470 assert_eq!(parse_iso8601_unix("2026-07-09T15:55:00+0160"), None);
2471 }
2472
2473 #[test]
2474 fn parse_iso8601_unix_max_valid_offset_boundary_is_accepted() {
2475 assert!(parse_iso8601_unix("2026-07-09T15:55:00+23:59").is_some());
2477 assert!(parse_iso8601_unix("2026-07-09T15:55:00-23:59").is_some());
2478 assert!(parse_iso8601_unix("2026-07-09T15:55:00+2359").is_some());
2479 }
2480
2481 #[test]
2482 fn exact_timestamp_converts_future_offset_without_relative_form() {
2483 let out = exact_timestamp("2025-05-23T16:08:00-02:00").unwrap();
2484 assert_eq!(out.0, "2025-05-23T18:08:00Z");
2485 assert!(out.1 > NOW);
2486 }
2487
2488 #[test]
2489 fn exact_timestamp_converts_past_offset_and_retains_fraction() {
2490 let out = exact_timestamp("2025-05-23T20:05:00.123+04:00").unwrap();
2491 assert_eq!(out.0, "2025-05-23T16:05:00.123Z");
2492 assert_eq!(out.1, NOW - 180);
2493 }
2494}
2495
2496#[cfg(test)]
2497mod stream_presentation_tests {
2498 use super::*;
2499 use serde_json::json;
2500
2501 #[test]
2502 fn stream_agent_json_preserves_opaque_record_and_empty_page() {
2503 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"}}]);
2504 for payload in [record, Value::Null, json!([]), json!({}), json!("")] {
2505 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});
2506 let presented = present(page, PresentationMode::Agent, 0);
2507 let out = prepare_format_value(presented, OutputFormat::Json, PresentationMode::Agent);
2508 assert_eq!(out["entries"][0].get("record"), Some(&payload));
2509 assert!(out.get("next_after").is_some_and(Value::is_null));
2510 }
2511 let empty = json!({"entries": [], "head_seq": 0, "next_after": null});
2512 assert_eq!(present(empty.clone(), PresentationMode::Agent, 0), empty);
2513 }
2514}
2515
2516#[cfg(test)]
2517mod issue_2537_standard_tests {
2518 use super::*;
2519 use serde_json::json;
2520
2521 #[test]
2522 fn issue_2537_standard_context_baseline_controls() {
2523 let timestamp = "2026-01-01T00:00:00.123456Z";
2524 let now = 1_767_225_780; let uuid = "aabbccdd-1234-4321-1234-abcdefabcdef";
2526 let payload = json!({"id":uuid,"created_at":timestamp,"empty":[],"null":null});
2527 let value = json!({"tool":"stream.batch","policy":"StreamBatchReceipts","id":uuid,
2528 "updated_at":timestamp,"score":0.123456,"empty":[],"null":null,
2529 "results":[{"id":uuid,"version":1,"updated_at":timestamp,"details":{"updated_at":timestamp}}],
2530 "properties":{"created_at":timestamp,"id":uuid,"empty":""},"trigger_at":timestamp,"due":timestamp,
2531 "entry":{"seq":1,"id":uuid,"created_at":timestamp,"record":payload},
2532 "cursor":{"head_seq":0,"entries":[],"next_after":null}});
2533 let shown = present(value.clone(), PresentationMode::Agent, now);
2534 assert_eq!(shown["updated_at"], timestamp);
2535 assert_eq!(shown["results"][0]["updated_at"], timestamp);
2536 assert_eq!(shown["results"][0]["details"]["updated_at"], timestamp);
2537 assert_eq!(shown["id"], "aabbccdd");
2538 assert_eq!(shown["score"], json!(0.123));
2539 assert!(shown.get("empty").is_none());
2540 assert!(shown.get("null").is_none());
2541 assert_eq!(
2542 shown["properties"],
2543 json!({"created_at":timestamp,"id":"aabbccdd","empty":""})
2544 );
2545 assert_eq!(shown["trigger_at"], timestamp);
2546 assert_eq!(shown["due"], timestamp);
2547 assert_eq!(shown["entry"]["record"], payload);
2548 assert_eq!(shown["entry"]["created_at"], timestamp);
2549 assert_eq!(shown["cursor"], value["cursor"]);
2550 for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2551 assert_eq!(present(value.clone(), mode, now), value);
2552 }
2553 assert_eq!(
2554 present(
2555 json!({"properties":timestamp}),
2556 PresentationMode::Agent,
2557 now
2558 )["properties"],
2559 timestamp
2560 );
2561 assert_eq!(
2562 present(
2563 json!({"properties":[timestamp,{"created_at":timestamp}]}),
2564 PresentationMode::Agent,
2565 now
2566 )["properties"],
2567 json!([timestamp,{"created_at":timestamp}])
2568 );
2569 }
2570}
2571
2572#[cfg(test)]
2573mod issue_2537_receipt_policy_tests {
2574 use super::*;
2575 use serde_json::json;
2576
2577 const TIMESTAMP: &str = "2026-01-01T00:00:00.123456Z";
2578 const NOW: i64 = 1_767_225_780;
2579 const UUID: &str = "aabbccdd-1234-4321-1234-abcdefabcdef";
2580
2581 #[test]
2582 fn issue_2537_receipt_policies_preserve_only_the_named_fields() {
2583 let append = json!({
2584 "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2585 "score": 0.123456, "empty": [], "null": null,
2586 "details": {"created_at": TIMESTAMP},
2587 "ticker": {"last_tick_at": TIMESTAMP},
2588 });
2589 let batch = json!({
2590 "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2591 "results": [{
2592 "id": UUID, "created_at": TIMESTAMP, "updated_at": TIMESTAMP,
2593 "details": {"created_at": TIMESTAMP, "updated_at": TIMESTAMP},
2594 "record": {"updated_at": TIMESTAMP},
2595 "score": 0.123456, "empty": [], "null": null,
2596 }],
2597 "properties": {"created_at": TIMESTAMP, "id": UUID, "empty": ""},
2598 "entry": {"seq": 1, "id": UUID, "created_at": TIMESTAMP,
2599 "record": {"id": UUID, "updated_at": TIMESTAMP, "empty": [], "null": null}},
2600 "trigger_at": TIMESTAMP, "due": TIMESTAMP,
2601 "ticker": {"last_tick_at": TIMESTAMP},
2602 });
2603 for (value, policy) in [
2604 (append, VerbPresentationPolicy::StreamAppendReceipt),
2605 (batch, VerbPresentationPolicy::StreamBatchReceipts),
2606 ] {
2607 let mut expected = present(value.clone(), PresentationMode::Agent, NOW);
2608 assert_eq!(expected["created_at"], TIMESTAMP);
2609 assert_eq!(expected["updated_at"], TIMESTAMP);
2610 assert_eq!(expected["id"], "aabbccdd");
2611 assert_eq!(expected["ticker"]["last_tick_at"], TIMESTAMP);
2612 if policy == VerbPresentationPolicy::StreamAppendReceipt {
2613 expected["created_at"] = json!(TIMESTAMP);
2614 } else {
2615 expected["results"][0]["created_at"] = json!(TIMESTAMP);
2616 expected["results"][0]["updated_at"] = json!(TIMESTAMP);
2617 expected["results"][0]
2618 .as_object_mut()
2619 .unwrap()
2620 .remove("created_at_relative");
2621 }
2622 assert_eq!(
2623 present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2624 expected
2625 );
2626 for mode in [PresentationMode::Verbose, PresentationMode::Human] {
2627 assert_eq!(present_with_policy(value.clone(), mode, NOW, policy), value);
2628 }
2629 assert_eq!(
2630 present_with_policy(
2631 value.clone(),
2632 PresentationMode::Agent,
2633 NOW,
2634 VerbPresentationPolicy::AlwaysVerbose
2635 ),
2636 value
2637 );
2638 }
2639 }
2640
2641 #[test]
2642 fn issue_2537_receipt_paths_do_not_match_other_containers_or_descendants() {
2643 let row = json!({"created_at": TIMESTAMP, "updated_at": TIMESTAMP});
2644 for (policy, cases) in [
2645 (
2646 VerbPresentationPolicy::StreamAppendReceipt,
2647 vec![
2648 json!([row.clone()]),
2649 json!({"details": row.clone()}),
2650 json!({"results": [row.clone()]}),
2651 json!({"created_at": {"created_at": TIMESTAMP}}),
2652 ],
2653 ),
2654 (
2655 VerbPresentationPolicy::StreamBatchReceipts,
2656 vec![
2657 json!([{"results": [row.clone()]}]),
2658 json!({"results": row.clone()}),
2659 json!({"results": [[row.clone()]]}),
2660 json!({"details": {"results": [row.clone()]}}),
2661 json!({"results": [{"details": row.clone(), "record": row.clone(), "results": [row.clone()]}]}),
2662 json!({"results": [{"created_at": {"updated_at": TIMESTAMP}, "updated_at": null}]}),
2663 ],
2664 ),
2665 ] {
2666 for value in cases {
2667 assert_eq!(
2668 present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2669 present(value.clone(), PresentationMode::Agent, NOW),
2670 "container must not acquire receipt protection: {value}"
2671 );
2672 }
2673 }
2674 }
2675
2676 #[test]
2677 fn issue_2537_receipt_strings_are_preserved_without_parsing() {
2678 for text in ["", "not a timestamp", "2026-01-01T05:30:00.123456+05:30"] {
2679 let append = json!({"created_at": text});
2680 assert_eq!(
2681 present_with_policy(
2682 append.clone(),
2683 PresentationMode::Agent,
2684 NOW,
2685 VerbPresentationPolicy::StreamAppendReceipt
2686 ),
2687 append
2688 );
2689 let batch = json!({"results": [{"created_at": text, "updated_at": text}]});
2690 assert_eq!(
2691 present_with_policy(
2692 batch.clone(),
2693 PresentationMode::Agent,
2694 NOW,
2695 VerbPresentationPolicy::StreamBatchReceipts
2696 ),
2697 batch
2698 );
2699 }
2700 for non_string in [Value::Null, json!([]), json!({}), json!(42), json!(true)] {
2701 for (value, policy) in [
2702 (
2703 json!({"created_at": non_string}),
2704 VerbPresentationPolicy::StreamAppendReceipt,
2705 ),
2706 (
2707 json!({"results": [{"created_at": non_string, "updated_at": non_string}]}),
2708 VerbPresentationPolicy::StreamBatchReceipts,
2709 ),
2710 ] {
2711 assert_eq!(
2712 present_with_policy(value.clone(), PresentationMode::Agent, NOW, policy),
2713 present(value, PresentationMode::Agent, NOW)
2714 );
2715 }
2716 }
2717 }
2718}
2719
2720#[cfg(test)]
2721mod parsed_note_content_tests {
2722 use super::*;
2723 use serde_json::json;
2724
2725 #[test]
2726 fn issue2757_content_is_opaque_to_metadata_and_format_reductions() {
2727 let payload = json!([
2728 {"id":"11111111-1111-4111-8111-111111111111", "full_id":"keep", "namespace":"local",
2729 "created_at":"2026-09-15T12:34:56.123456Z", "score":0.123456789,
2730 "nil":null, "empty":"", "array":[], "object":{},
2731 "properties":{"id":"11111111-1111-4111-8111-111111111111"}},
2732 {"status":"pending"}
2733 ]);
2734 for (scope, response, pointer) in [
2735 (
2736 NoteContentScope::Record,
2737 json!({"id":"22222222-2222-4222-8222-222222222222", "content":payload}),
2738 "/content",
2739 ),
2740 (
2741 NoteContentScope::Items,
2742 json!({"items":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "requested_limit":1, "effective_limit":1, "limit_clamped":false}),
2743 "/items/0/content",
2744 ),
2745 (
2746 NoteContentScope::Notes,
2747 json!({"notes":[{"id":"22222222-2222-4222-8222-222222222222", "content":payload}], "next_after":null}),
2748 "/notes/0/content",
2749 ),
2750 ] {
2751 let presented =
2752 scope.protect(response, |value| present(value, PresentationMode::Agent, 0));
2753 for format in [OutputFormat::Json, OutputFormat::Auto, OutputFormat::Table] {
2754 let rendered = render_format_with_note_content(
2755 presented.clone(),
2756 format,
2757 PresentationMode::Agent,
2758 scope,
2759 );
2760 let actual: Value = serde_json::from_str(&rendered)
2761 .expect("single note stays a JSON record, never a table of its body");
2762 let expected = if format == OutputFormat::Table {
2763 Value::String(payload.to_string())
2764 } else {
2765 payload.clone()
2766 };
2767 assert_eq!(actual.pointer(pointer), Some(&expected));
2768 }
2769 }
2770 }
2771}
2772
2773#[cfg(test)]
2774mod adr045_amendment9_tests {
2775 use super::*;
2776 use serde_json::json;
2777
2778 const NOW: i64 = 1_748_016_480; #[test]
2781 fn agent_exact_utc_preserves_fraction_digits_and_instant() {
2782 for (utc_clock, plus_clock, minus_clock) in [
2783 (
2784 "2025-05-23T16:05:00",
2785 "2025-05-23T20:05:00",
2786 "2025-05-23T12:05:00",
2787 ),
2788 (
2789 "2020-01-01T10:30:45",
2790 "2020-01-01T14:30:45",
2791 "2020-01-01T06:30:45",
2792 ),
2793 ] {
2794 for fraction in ["", ".123", ".123456"] {
2795 let expected = format!("{utc_clock}{fraction}Z");
2796 for (clock, offset) in [
2797 (utc_clock, "Z"),
2798 (utc_clock, "+00:00"),
2799 (plus_clock, "+0400"),
2800 (minus_clock, "-04:00"),
2801 ] {
2802 let input = format!("{clock}{fraction}{offset}");
2803 let shown = present(
2804 json!({"updated_at": input.clone()}),
2805 PresentationMode::Agent,
2806 NOW,
2807 );
2808 assert_eq!(shown["updated_at"], expected, "{input}");
2809 assert_eq!(
2810 parse_iso8601_unix(&input),
2811 parse_iso8601_unix(&expected),
2812 "{input}"
2813 );
2814 }
2815 }
2816 }
2817 }
2818
2819 #[test]
2820 fn agent_preserves_timestamp_prefix_body() {
2821 let body = "2025-05-23T16:05:00Z followed by ordinary message text";
2822 let shown = present(json!({"content": body}), PresentationMode::Agent, NOW);
2823 assert_eq!(shown["content"], body);
2824 }
2825
2826 #[test]
2827 fn agent_preserves_offsetless_timestamp() {
2828 let body = "2025-05-23T16:05:00";
2829 let shown = present(json!({"content": body}), PresentationMode::Agent, NOW);
2830 assert_eq!(shown["content"], body);
2831 }
2832
2833 #[test]
2834 fn agent_preserves_malformed_offset() {
2835 let body = "2025-05-23T16:05:00+25:00";
2836 let shown = present(json!({"content": body}), PresentationMode::Agent, NOW);
2837 assert_eq!(shown["content"], body);
2838 }
2839
2840 #[test]
2841 fn agent_list_rows_pair_exact_and_relative_time_in_each_band() {
2842 let bands = [
2843 (42, "42s ago"),
2844 (180, "3m ago"),
2845 (3 * 3600, "3h ago"),
2846 (2 * 86400, "2d ago"),
2847 ];
2848 let rows: Vec<Value> = bands
2849 .into_iter()
2850 .map(|(age, _)| {
2851 let created = chrono::DateTime::<chrono::Utc>::from_timestamp(NOW - age, 0)
2852 .unwrap()
2853 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2854 json!({"created_at": created, "updated_at": created})
2855 })
2856 .collect();
2857 let shown = present(json!({"items": rows.clone()}), PresentationMode::Agent, NOW);
2858 for (index, (_, relative)) in bands.into_iter().enumerate() {
2859 assert_eq!(
2860 shown["items"][index]["created_at"],
2861 rows[index]["created_at"]
2862 );
2863 assert_eq!(
2864 shown["items"][index]["updated_at"],
2865 rows[index]["updated_at"]
2866 );
2867 assert_eq!(shown["items"][index]["created_at_relative"], relative);
2868 assert!(shown["items"][index].get("updated_at_relative").is_none());
2869 }
2870 }
2871
2872 #[test]
2873 fn agent_relative_sibling_obeys_array_properties_existing_and_future_bounds() {
2874 let recent = "2025-05-23T16:05:00+00:00";
2875 let future = "2025-05-23T16:08:10Z";
2876 let shown = present(
2877 json!({
2878 "created_at": recent,
2879 "items": [
2880 {"created_at": future},
2881 {"created_at": recent, "created_at_relative": "canonical"},
2882 {"created_at": "2025-05-23T16:05:00"},
2883 {"updated_at": recent},
2884 {"created_at": "2025-05-23T16:08:00.000001Z"},
2885 {"created_at": "2025-05-23T16:07:59.999999Z"},
2886 {"created_at": "2025-05-23T16:08:00.000000Z"},
2887 {"properties": [{"created_at": recent, "empty": ""}]}
2888 ],
2889 "properties": {"nested": [{"created_at": recent}]}
2890 }),
2891 PresentationMode::Agent,
2892 NOW,
2893 );
2894 assert_eq!(shown["created_at"], "2025-05-23T16:05:00Z");
2895 assert!(shown.get("created_at_relative").is_none());
2896 assert_eq!(shown["items"][0]["created_at"], future);
2897 assert!(shown["items"][0].get("created_at_relative").is_none());
2898 assert_eq!(shown["items"][1]["created_at_relative"], "canonical");
2899 assert_eq!(shown["items"][2]["created_at"], "2025-05-23T16:05:00");
2900 assert!(shown["items"][2].get("created_at_relative").is_none());
2901 assert!(shown["items"][3].get("created_at_relative").is_none());
2902 assert!(shown["items"][4].get("created_at_relative").is_none());
2903 assert_eq!(shown["items"][5]["created_at_relative"], "0s ago");
2904 assert_eq!(shown["items"][6]["created_at_relative"], "0s ago");
2905 assert_eq!(shown["items"][7]["properties"][0]["created_at"], recent);
2906 assert!(shown["items"][7]["properties"][0]
2907 .get("created_at_relative")
2908 .is_none());
2909 assert!(shown["items"][7]["properties"][0].get("empty").is_none());
2910 assert_eq!(shown["properties"]["nested"][0]["created_at"], recent);
2911 assert!(shown["properties"]["nested"][0]
2912 .get("created_at_relative")
2913 .is_none());
2914 }
2915
2916 #[test]
2917 fn agent_relative_sibling_uses_sampled_fraction_within_same_second() {
2918 let sampled = chrono::DateTime::parse_from_rfc3339("2025-05-23T16:08:00.500000000Z")
2919 .unwrap()
2920 .with_timezone(&chrono::Utc);
2921 let shown = present_with_policy_at(
2922 json!({"items": [
2923 {"created_at": "2025-05-23T16:08:00.499999999Z"},
2924 {"created_at": "2025-05-23T16:08:00.500000000Z"},
2925 {"created_at": "2025-05-23T16:08:00.5000000000Z"},
2926 {"created_at": "2025-05-23T16:08:00.5000000001Z"},
2927 {"created_at": "2025-05-23T16:08:00.500000001Z"},
2928 {"created_at": "2025-05-23T16:07:59.500000001Z"},
2929 {"created_at": "2025-05-23T16:07:59.500000000Z"}
2930 ]}),
2931 PresentationMode::Agent,
2932 sampled.into(),
2933 VerbPresentationPolicy::Standard,
2934 );
2935 for index in [0, 1, 2, 5] {
2936 assert_eq!(shown["items"][index]["created_at_relative"], "0s ago");
2937 }
2938 for index in [3, 4] {
2939 assert!(shown["items"][index].get("created_at_relative").is_none());
2940 }
2941 assert_eq!(shown["items"][6]["created_at_relative"], "1s ago");
2942 }
2943}