1use crate::entities::{Entity, EntitySearchResult};
5use crate::memory::{IndexedFileInfo, MemoryItem, MemoryStats, SearchResult};
6use crate::pages::Page;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Serialize, Deserialize)]
13pub struct StoreMemoryResponse {
14 pub source_id: String,
15 pub chunks_created: usize,
16 pub memory_type: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub entity_id: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub quality: Option<String>,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub warnings: Vec<String>,
27 #[serde(default = "default_extraction_method")]
29 pub extraction_method: String,
30 #[serde(default)]
37 pub enrichment: String,
38 #[serde(default, skip_serializing_if = "String::is_empty")]
43 pub hint: String,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub triggered_revisions: Vec<String>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub auto_superseded: Vec<String>,
56}
57
58fn default_extraction_method() -> String {
59 "unknown".to_string()
60}
61
62#[derive(Debug, Serialize, Deserialize)]
63pub struct SearchMemoryResponse {
64 pub results: Vec<SearchResult>,
65 pub took_ms: f64,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub supplemental_pages: Option<Vec<SearchResult>>,
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct ListMemoriesResponse {
75 pub memories: Vec<IndexedFileInfo>,
76}
77
78#[derive(Debug, Serialize, Deserialize)]
84pub struct DeleteResponse {
85 pub deleted: bool,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct ConfirmResponse {
90 pub confirmed: bool,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct ReclassifyMemoryResponse {
95 pub source_id: String,
96 pub memory_type: String,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct MemoryStatsResponse {
101 pub stats: MemoryStats,
102}
103
104#[derive(Debug, Serialize, Deserialize)]
105pub struct NurtureCardsResponse {
106 pub cards: Vec<MemoryItem>,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
112pub struct HealthResponse {
113 pub status: String,
114 pub db_initialized: bool,
115 pub version: String,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
119pub struct StatusResponse {
120 pub is_running: bool,
121 pub files_indexed: u64,
122 pub files_total: u64,
123 pub sources_connected: Vec<String>,
124}
125
126#[derive(Debug, Serialize, Deserialize)]
127pub struct SearchResponse {
128 pub results: Vec<SearchResult>,
129 pub took_ms: f64,
130}
131
132#[doc(hidden)]
133#[derive(Debug, Serialize, Deserialize)]
134pub struct ContextSuggestion {
135 pub content: String,
136 pub score: f32,
137 pub source: String,
138}
139
140#[doc(hidden)]
141#[derive(Debug, Serialize, Deserialize)]
142pub struct ContextResponse {
143 pub suggestions: Vec<ContextSuggestion>,
144 pub took_ms: f64,
145}
146
147#[derive(Debug, Default, Serialize, Deserialize)]
148pub struct TierTokenEstimates {
149 pub tier1_identity: usize,
150 pub tier2_project: usize,
151 pub tier3_relevant: usize,
152 pub total: usize,
153}
154
155#[derive(Debug, Serialize, Deserialize)]
156pub struct ProfileContext {
157 pub narrative: String,
158 pub identity: Vec<String>,
159 pub preferences: Vec<String>,
160 #[deprecated(
164 since = "0.3.2",
165 note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
166 Always empty. Will be removed in 0.4."
167 )]
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub goals: Vec<String>,
170}
171
172#[derive(Debug, Serialize, Deserialize)]
173pub struct KnowledgeContext {
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub pages: Vec<String>,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub decisions: Vec<String>,
178 #[serde(default)]
179 pub relevant_memories: Vec<SearchResult>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub graph_context: Vec<String>,
182}
183
184#[derive(Debug, Serialize, Deserialize)]
185pub struct ChatContextResponse {
186 pub context: String,
187 pub profile: ProfileContext,
188 pub knowledge: KnowledgeContext,
189 pub took_ms: f64,
190 pub token_estimates: TierTokenEstimates,
191}
192
193#[derive(Debug, Serialize, Deserialize)]
196pub struct ProfileResponse {
197 pub id: String,
198 pub name: String,
199 pub display_name: Option<String>,
200 pub email: Option<String>,
201 pub bio: Option<String>,
202 pub avatar_path: Option<String>,
203 pub created_at: i64,
204 pub updated_at: i64,
205}
206
207#[derive(Debug, Serialize, Deserialize)]
208pub struct AgentResponse {
209 pub id: String,
210 pub name: String,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub display_name: Option<String>,
213 pub agent_type: String,
214 pub description: Option<String>,
215 pub enabled: bool,
216 pub trust_level: String,
217 pub last_seen_at: Option<i64>,
218 pub memory_count: i64,
219 pub created_at: i64,
220 pub updated_at: i64,
221}
222
223#[derive(Debug, Serialize, Deserialize)]
226pub struct CreateEntityResponse {
227 pub id: String,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub warnings: Vec<String>,
230}
231
232#[doc(hidden)]
233#[derive(Debug, Serialize, Deserialize)]
234pub struct CreateRelationResponse {
235 pub id: String,
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub warnings: Vec<String>,
238}
239
240#[derive(Debug, Serialize, Deserialize)]
241pub struct AddObservationResponse {
242 pub id: String,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub warnings: Vec<String>,
245}
246
247#[doc(hidden)]
248#[derive(Debug, Serialize, Deserialize)]
249pub struct CreatePageResponse {
250 pub id: String,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub warnings: Vec<String>,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
256pub struct ListEntitiesResponse {
257 pub entities: Vec<Entity>,
258}
259
260#[derive(Debug, Serialize, Deserialize)]
261pub struct SearchEntitiesResponse {
262 pub results: Vec<EntitySearchResult>,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
266pub struct SearchPagesResponse {
267 pub pages: Vec<Page>,
268}
269
270#[derive(Debug, Serialize, Deserialize)]
274pub struct PageLinksResponse {
275 pub outbound: Vec<PageLinkOutbound>,
276 pub inbound: Vec<PageLinkInbound>,
277}
278
279#[derive(Debug, Serialize, Deserialize)]
280pub struct PageLinkOutbound {
281 pub label: String,
282 pub target_page_id: Option<String>,
285}
286
287#[derive(Debug, Serialize, Deserialize)]
288pub struct PageLinkInbound {
289 pub source_page_id: String,
290 pub label: String,
291}
292
293#[derive(Debug, Serialize, Deserialize)]
296pub struct ImportMemoriesResponse {
297 pub imported: usize,
298 pub skipped: usize,
299 pub breakdown: HashMap<String, usize>,
300 pub entities_created: usize,
301 pub observations_added: usize,
302 pub relations_created: usize,
303 pub batch_id: String,
304}
305
306#[doc(hidden)]
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311pub enum Nudge {
312 Silent,
313 Ambient,
314 Notable,
315 Wow,
316}
317
318#[doc(hidden)]
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct PhaseResult {
322 pub name: String,
323 pub duration_ms: u64,
324 pub items_processed: usize,
325 pub error: Option<String>,
326 pub nudge: Nudge,
327 pub headline: Option<String>,
328}
329
330#[doc(hidden)]
331#[derive(Debug, Serialize, Deserialize)]
332pub struct SteepResponse {
333 pub memories_decayed: u64,
334 pub recaps_generated: u32,
335 pub distilled: u32,
336 pub pending_remaining: u32,
337 pub phases: Vec<PhaseResult>,
338}
339
340#[derive(Debug, Serialize, Deserialize)]
343pub struct ConfigResponse {
344 pub skip_apps: Vec<String>,
345 pub skip_title_patterns: Vec<String>,
346 pub private_browsing_detection: bool,
347 pub setup_completed: bool,
348 pub clipboard_enabled: bool,
349 pub screen_capture_enabled: bool,
350 pub remote_access_enabled: bool,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub routine_model: Option<String>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub synthesis_model: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub external_llm_endpoint: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub external_llm_model: Option<String>,
363}
364
365#[derive(Debug, Serialize, Deserialize)]
368pub struct IndexedFilesResponse {
369 pub files: Vec<IndexedFileInfo>,
370}
371
372#[derive(Debug, Serialize, Deserialize)]
373pub struct DeleteCountResponse {
374 pub deleted: usize,
375}
376
377#[derive(Debug, Serialize, Deserialize)]
380pub struct SuccessResponse {
381 pub ok: bool,
382}
383
384#[derive(Debug, Serialize, Deserialize)]
387pub struct MemoryDetailResponse {
388 pub memory: Option<MemoryItem>,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct MemoryDetail {
394 pub id: String,
395 pub content: String,
396 pub title: String,
397 pub source_id: String,
398 pub chunk_index: i32,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub chunk_type: Option<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 pub language: Option<String>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub semantic_unit: Option<String>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub byte_start: Option<i64>,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub byte_end: Option<i64>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub summary: Option<String>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct PendingRevision {
416 pub source_id: String,
417 pub content: String,
418 pub source_agent: Option<String>,
419}
420
421#[derive(Debug, Serialize, Deserialize)]
422pub struct VersionChainResponse {
423 pub versions: Vec<crate::memory::MemoryVersionItem>,
424}
425
426#[derive(Debug, Serialize, Deserialize)]
429pub struct TagsResponse {
430 pub tags: Vec<String>,
431}
432
433#[derive(Debug, Serialize, Deserialize)]
436pub struct ActivityResponse {
437 pub activities: Vec<crate::memory::AgentActivityRow>,
438}
439
440#[derive(Debug, Serialize, Deserialize)]
443pub struct DecisionsResponse {
444 pub decisions: Vec<MemoryItem>,
445}
446
447#[derive(Debug, Serialize, Deserialize)]
448pub struct DecisionDomainsResponse {
449 pub domains: Vec<String>,
452}
453
454#[derive(Debug, Serialize, Deserialize)]
457pub struct PinnedMemoriesResponse {
458 pub memories: Vec<MemoryItem>,
459}
460
461#[derive(Debug, Serialize, Deserialize)]
464pub struct IngestResponse {
465 pub chunks_created: usize,
466 pub document_id: String,
467}
468
469#[derive(Debug, Default, Serialize, Deserialize)]
476pub struct ExportStats {
477 pub exported: usize,
478 pub skipped: usize,
479 pub failed: usize,
480}
481
482#[derive(Debug, Deserialize, Serialize)]
483pub struct ExportPageResponse {
484 pub path: String,
485}
486
487#[derive(Debug, Deserialize, Serialize)]
490pub struct KnowledgePathResponse {
491 pub path: String,
492}
493
494#[derive(Debug, Deserialize, Serialize)]
495pub struct KnowledgeCountResponse {
496 pub count: u64,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct MemoryRevisionEntry {
508 pub source_id: String,
509 pub depth: i64,
510 pub title: String,
511 pub content_preview: String,
512 pub last_modified: i64,
513 #[serde(skip_serializing_if = "Option::is_none")]
514 pub source_agent: Option<String>,
515 #[serde(skip_serializing_if = "Option::is_none")]
516 pub supersede_mode: Option<String>,
517 #[serde(skip_serializing_if = "Option::is_none")]
518 pub delta_summary: Option<String>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct ListMemoryRevisionsResponse {
524 pub current_source_id: String,
525 pub chain_depth: i64,
526 pub entries: Vec<MemoryRevisionEntry>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct PageChangelogEntry {
532 pub version: i64,
533 pub at: i64,
534 pub edited_by: String,
535 #[serde(skip_serializing_if = "Option::is_none")]
536 pub delta_summary: Option<String>,
537 #[serde(skip_serializing_if = "Option::is_none")]
538 pub incoming_source_ids: Option<Vec<String>>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct ListPageRevisionsResponse {
544 pub page_id: String,
545 pub current_version: i64,
546 pub user_edited: bool,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub stale_reason: Option<String>,
549 pub entries: Vec<PageChangelogEntry>,
550}
551
552#[doc(hidden)]
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct SyncStatsResponse {
557 pub files_found: usize,
558 pub ingested: usize,
559 pub skipped: usize,
560 pub errors: usize,
561}
562
563#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
569#[serde(rename_all = "snake_case")]
570pub enum ProposalAction {
571 EntityMerge,
572 RelationConflict,
573 DetectContradiction,
574 SuggestEntity,
575 DedupMerge,
576}
577
578#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
584#[serde(tag = "action", rename_all = "snake_case")]
585pub enum RefinementPayload {
586 EntityMerge {
587 existing_id: String,
588 new_id: String,
589 similarity: f64,
590 },
591 RelationConflict {
592 existing_id: String,
593 new_id: String,
594 from: String,
595 to: String,
596 old_type: String,
597 new_type: String,
598 },
599 DetectContradiction,
600 SuggestEntity {
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 name_hint: Option<String>,
603 },
604 DedupMerge,
605}
606
607#[derive(Debug, Serialize, Deserialize, Clone)]
608pub struct RefinementProposalSummary {
609 pub id: String,
610 pub action: ProposalAction,
611 pub source_ids: Vec<String>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub payload: Option<RefinementPayload>,
614 pub confidence: f64,
615 pub created_at: String,
616}
617
618#[derive(Debug, Serialize, Deserialize, Clone, Default)]
619pub struct ListRefinementsResponse {
620 pub proposals: Vec<RefinementProposalSummary>,
621}
622
623#[derive(Debug, Serialize, Deserialize, Clone)]
624pub struct RejectRefinementResponse {
625 pub id: String,
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct AcceptRefinementResponse {
630 pub id: String,
631 pub action_applied: String,
632}
633
634#[cfg(test)]
635mod refinement_wire_tests {
636 use super::*;
637
638 #[test]
639 fn proposal_action_serde_round_trip() {
640 let cases = [
641 ("\"entity_merge\"", ProposalAction::EntityMerge),
642 ("\"relation_conflict\"", ProposalAction::RelationConflict),
643 (
644 "\"detect_contradiction\"",
645 ProposalAction::DetectContradiction,
646 ),
647 ("\"suggest_entity\"", ProposalAction::SuggestEntity),
648 ("\"dedup_merge\"", ProposalAction::DedupMerge),
649 ];
650 for (json, expected) in cases {
651 let parsed: ProposalAction = serde_json::from_str(json).unwrap();
652 assert_eq!(parsed, expected, "deserialize {json}");
653 let back = serde_json::to_string(&expected).unwrap();
654 assert_eq!(back, json, "serialize {expected:?}");
655 }
656 }
657
658 #[test]
659 fn refinement_payload_entity_merge_round_trip() {
660 let json =
661 r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
662 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
663 match parsed {
664 RefinementPayload::EntityMerge {
665 ref existing_id,
666 ref new_id,
667 similarity,
668 } => {
669 assert_eq!(existing_id, "e1");
670 assert_eq!(new_id, "e2");
671 assert!((similarity - 0.87).abs() < 1e-9);
672 }
673 _ => panic!("expected EntityMerge variant"),
674 }
675 let back = serde_json::to_value(&parsed).unwrap();
676 assert_eq!(back["action"], "entity_merge");
677 assert_eq!(back["existing_id"], "e1");
678 }
679
680 #[test]
681 fn refinement_payload_dedup_merge_no_fields() {
682 let json = r#"{"action":"dedup_merge"}"#;
683 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
684 assert!(matches!(parsed, RefinementPayload::DedupMerge));
685 }
686
687 #[test]
688 fn refinement_payload_relation_conflict_round_trip() {
689 let json = r#"{"action":"relation_conflict","existing_id":"r1","new_id":"r2","from":"e_a","to":"e_b","old_type":"works_at","new_type":"founded"}"#;
690 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
691 match parsed {
692 RefinementPayload::RelationConflict {
693 ref existing_id,
694 ref new_id,
695 ref from,
696 ref to,
697 ref old_type,
698 ref new_type,
699 } => {
700 assert_eq!(existing_id, "r1");
701 assert_eq!(new_id, "r2");
702 assert_eq!(from, "e_a");
703 assert_eq!(to, "e_b");
704 assert_eq!(old_type, "works_at");
705 assert_eq!(new_type, "founded");
706 }
707 _ => panic!("expected RelationConflict"),
708 }
709 let back = serde_json::to_value(&parsed).unwrap();
710 assert_eq!(back["from"], "e_a");
711 assert_eq!(back["to"], "e_b");
712 }
713
714 #[test]
715 fn refinement_payload_detect_contradiction_unit_variant() {
716 let json = r#"{"action":"detect_contradiction"}"#;
717 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
718 assert!(matches!(parsed, RefinementPayload::DetectContradiction));
719 }
720
721 #[test]
722 fn refinement_payload_suggest_entity_with_name_hint() {
723 let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
724 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
725 match parsed {
726 RefinementPayload::SuggestEntity { ref name_hint } => {
727 assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
728 }
729 _ => panic!("expected SuggestEntity"),
730 }
731 }
732
733 #[test]
734 fn refinement_payload_suggest_entity_without_name_hint() {
735 let json = r#"{"action":"suggest_entity"}"#;
736 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
737 assert!(matches!(
738 parsed,
739 RefinementPayload::SuggestEntity { name_hint: None }
740 ));
741 }
742
743 #[test]
744 fn list_refinements_response_round_trip() {
745 let resp = ListRefinementsResponse {
746 proposals: vec![RefinementProposalSummary {
747 id: "ref_1".into(),
748 action: ProposalAction::EntityMerge,
749 source_ids: vec!["a".into(), "b".into()],
750 payload: Some(RefinementPayload::EntityMerge {
751 existing_id: "a".into(),
752 new_id: "b".into(),
753 similarity: 0.86,
754 }),
755 confidence: 0.86,
756 created_at: "2026-05-12T00:00:00Z".into(),
757 }],
758 };
759 let json = serde_json::to_string(&resp).unwrap();
760 let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
761 assert_eq!(parsed.proposals.len(), 1);
762 assert_eq!(parsed.proposals[0].id, "ref_1");
763 assert!(matches!(
764 parsed.proposals[0].action,
765 ProposalAction::EntityMerge
766 ));
767 }
768
769 #[test]
770 fn reject_refinement_response_round_trip() {
771 let resp = RejectRefinementResponse { id: "ref_x".into() };
772 let json = serde_json::to_string(&resp).unwrap();
773 let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
774 assert_eq!(parsed.id, "ref_x");
775 }
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
783pub struct OrphanLink {
784 pub label: String,
785 pub count: i64,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
790pub struct OrphanLinksResponse {
791 pub min_count: usize,
792 pub orphan_labels: Vec<OrphanLink>,
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
802pub struct PendingRevisionItem {
803 pub target_source_id: String,
804 pub revision_source_id: String,
805 pub revision_content: String,
806 pub source_agent: Option<String>,
807 pub last_modified: i64,
808}
809
810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814pub struct RevisionAcceptResponse {
815 pub target_source_id: String,
816 pub revision_source_id: String,
817 pub wrote: bool,
818}
819
820#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
823pub struct RevisionDismissResponse {
824 pub target_source_id: String,
825 pub wrote: bool,
826}
827
828#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
833pub struct ContradictionDismissResponse {
834 pub source_id: String,
835 pub wrote: bool,
836}
837
838#[cfg(test)]
839mod mutation_response_tests {
840 use super::*;
841
842 #[test]
843 fn revision_accept_response_serializes_byte_identical() {
844 let r = RevisionAcceptResponse {
845 target_source_id: "mem_target".into(),
846 revision_source_id: "mem_rev".into(),
847 wrote: true,
848 };
849 assert_eq!(
850 serde_json::to_string(&r).unwrap(),
851 r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
852 );
853 }
854
855 #[test]
856 fn revision_dismiss_response_serializes_byte_identical() {
857 let r = RevisionDismissResponse {
858 target_source_id: "mem_target".into(),
859 wrote: true,
860 };
861 assert_eq!(
862 serde_json::to_string(&r).unwrap(),
863 r#"{"target_source_id":"mem_target","wrote":true}"#
864 );
865 }
866
867 #[test]
868 fn contradiction_dismiss_response_serializes_byte_identical() {
869 let r = ContradictionDismissResponse {
870 source_id: "mem_abc".into(),
871 wrote: true,
872 };
873 assert_eq!(
874 serde_json::to_string(&r).unwrap(),
875 r#"{"source_id":"mem_abc","wrote":true}"#
876 );
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883
884 #[test]
885 fn store_memory_response_deserializes_without_extraction_method() {
886 let json = r#"{
888 "source_id": "mem_abc",
889 "chunks_created": 3,
890 "memory_type": "fact"
891 }"#;
892 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
893 assert_eq!(parsed.source_id, "mem_abc");
894 assert_eq!(parsed.chunks_created, 3);
895 assert_eq!(parsed.memory_type, "fact");
896 assert_eq!(parsed.extraction_method, "unknown");
897 assert!(parsed.warnings.is_empty());
898 }
899
900 #[test]
901 fn store_memory_response_deserializes_with_all_fields() {
902 let json = r#"{
903 "source_id": "mem_abc",
904 "chunks_created": 3,
905 "memory_type": "fact",
906 "warnings": ["decision memory missing claim"],
907 "extraction_method": "llm"
908 }"#;
909 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
910 assert_eq!(parsed.warnings.len(), 1);
911 assert_eq!(parsed.extraction_method, "llm");
912 }
913
914 #[test]
915 fn store_memory_response_exposes_enrichment_and_hint() {
916 let json = r#"{
919 "source_id": "mem_xyz",
920 "chunks_created": 1,
921 "memory_type": "fact",
922 "warnings": [],
923 "extraction_method": "unknown",
924 "enrichment": "pending",
925 "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
926 }"#;
927 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
928 assert_eq!(parsed.enrichment, "pending");
929 assert!(parsed.hint.contains("compiling"));
930 }
931
932 #[test]
933 fn store_memory_response_defaults_enrichment_for_older_responses() {
934 let json = r#"{
937 "source_id": "mem_old",
938 "chunks_created": 1,
939 "memory_type": "fact"
940 }"#;
941 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
942 assert_eq!(parsed.enrichment, ""); assert_eq!(parsed.hint, ""); }
945
946 #[test]
947 fn store_memory_response_roundtrips_not_needed_state() {
948 let response = StoreMemoryResponse {
951 source_id: "mem_no_llm".into(),
952 chunks_created: 1,
953 memory_type: "fact".into(),
954 entity_id: None,
955 quality: None,
956 warnings: vec![],
957 extraction_method: "none".into(),
958 enrichment: "not_needed".into(),
959 hint: String::new(),
960 triggered_revisions: vec![],
961 auto_superseded: vec![],
962 };
963 let json = serde_json::to_string(&response).unwrap();
964 assert!(json.contains("\"enrichment\":\"not_needed\""));
965 assert!(
966 !json.contains("\"hint\""),
967 "empty hint must be skipped on the wire, got: {json}"
968 );
969 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
970 assert_eq!(parsed.enrichment, "not_needed");
971 assert_eq!(parsed.hint, "");
972 }
973
974 #[test]
975 fn store_memory_response_triggered_revisions_serializes_when_non_empty() {
976 let r = StoreMemoryResponse {
977 source_id: "mem_new".into(),
978 chunks_created: 1,
979 memory_type: "fact".into(),
980 entity_id: None,
981 quality: None,
982 warnings: vec![],
983 extraction_method: "none".into(),
984 enrichment: "not_needed".into(),
985 hint: String::new(),
986 triggered_revisions: vec!["mem_target_abc".to_string()],
987 auto_superseded: vec![],
988 };
989 let json = serde_json::to_string(&r).unwrap();
990 assert!(
991 json.contains("\"triggered_revisions\":[\"mem_target_abc\"]"),
992 "triggered_revisions must appear in JSON when non-empty, got: {json}"
993 );
994 }
995
996 #[test]
997 fn store_memory_response_triggered_revisions_skips_when_empty() {
998 let r = StoreMemoryResponse {
999 source_id: "mem_new".into(),
1000 chunks_created: 1,
1001 memory_type: "fact".into(),
1002 entity_id: None,
1003 quality: None,
1004 warnings: vec![],
1005 extraction_method: "none".into(),
1006 enrichment: "not_needed".into(),
1007 hint: String::new(),
1008 triggered_revisions: vec![],
1009 auto_superseded: vec![],
1010 };
1011 let json = serde_json::to_string(&r).unwrap();
1012 assert!(
1013 !json.contains("triggered_revisions"),
1014 "triggered_revisions must be absent from JSON when empty, got: {json}"
1015 );
1016 }
1017
1018 #[test]
1019 fn store_memory_response_auto_superseded_serializes_when_non_empty() {
1020 let r = StoreMemoryResponse {
1021 source_id: "mem_new".into(),
1022 chunks_created: 1,
1023 memory_type: "fact".into(),
1024 entity_id: None,
1025 quality: None,
1026 warnings: vec![],
1027 extraction_method: "none".into(),
1028 enrichment: "not_needed".into(),
1029 hint: String::new(),
1030 triggered_revisions: vec![],
1031 auto_superseded: vec!["mem_old_abc".to_string()],
1032 };
1033 let json = serde_json::to_string(&r).unwrap();
1034 assert!(
1035 json.contains("\"auto_superseded\":[\"mem_old_abc\"]"),
1036 "auto_superseded must appear in JSON when non-empty, got: {json}"
1037 );
1038 }
1039
1040 #[test]
1041 fn store_memory_response_auto_superseded_skips_when_empty() {
1042 let r = StoreMemoryResponse {
1043 source_id: "mem_new".into(),
1044 chunks_created: 1,
1045 memory_type: "fact".into(),
1046 entity_id: None,
1047 quality: None,
1048 warnings: vec![],
1049 extraction_method: "none".into(),
1050 enrichment: "not_needed".into(),
1051 hint: String::new(),
1052 triggered_revisions: vec![],
1053 auto_superseded: vec![],
1054 };
1055 let json = serde_json::to_string(&r).unwrap();
1056 assert!(
1057 !json.contains("auto_superseded"),
1058 "auto_superseded must be absent from JSON when empty, got: {json}"
1059 );
1060 }
1061
1062 #[test]
1063 fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1064 #[allow(deprecated)]
1067 let profile = ProfileContext {
1068 narrative: "n".into(),
1069 identity: vec![],
1070 preferences: vec![],
1071 goals: vec![],
1072 };
1073 let response = ChatContextResponse {
1074 context: "context".into(),
1075 profile,
1076 knowledge: KnowledgeContext {
1077 pages: vec![],
1078 decisions: vec![],
1079 relevant_memories: vec![],
1080 graph_context: vec![],
1081 },
1082 took_ms: 1.0,
1083 token_estimates: TierTokenEstimates {
1084 tier1_identity: 1,
1085 tier2_project: 2,
1086 tier3_relevant: 3,
1087 total: 6,
1088 },
1089 };
1090
1091 let json = serde_json::to_string(&response).unwrap();
1092 let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1093 assert!(parsed.knowledge.pages.is_empty());
1094 assert!(parsed.knowledge.decisions.is_empty());
1095 assert!(parsed.knowledge.relevant_memories.is_empty());
1096 assert!(parsed.knowledge.graph_context.is_empty());
1097 }
1098
1099 #[test]
1100 fn orphan_links_response_golden_string() {
1101 let resp = OrphanLinksResponse {
1102 min_count: 2,
1103 orphan_labels: vec![OrphanLink {
1104 label: "Rust".to_string(),
1105 count: 3,
1106 }],
1107 };
1108 let s = serde_json::to_string(&resp).unwrap();
1109 assert_eq!(
1110 s,
1111 r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1112 );
1113 }
1114
1115 #[test]
1116 fn orphan_links_response_empty_round_trip() {
1117 let resp = OrphanLinksResponse {
1118 min_count: 1,
1119 orphan_labels: vec![],
1120 };
1121 let decoded: OrphanLinksResponse =
1122 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1123 assert_eq!(decoded, resp);
1124 }
1125
1126 #[test]
1127 fn pending_revision_item_round_trip() {
1128 let item = PendingRevisionItem {
1129 target_source_id: "mem_target".into(),
1130 revision_source_id: "mem_rev".into(),
1131 revision_content: "new body".into(),
1132 source_agent: Some("claude-code".into()),
1133 last_modified: 1_715_000_000,
1134 };
1135 let json = serde_json::to_value(&item).unwrap();
1136 assert_eq!(json["target_source_id"], "mem_target");
1137 assert_eq!(json["revision_source_id"], "mem_rev");
1138 assert_eq!(json["revision_content"], "new body");
1139 let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1140 assert_eq!(decoded, item);
1141 }
1142}
1143
1144#[cfg(test)]
1145mod search_memory_response_tests {
1146 use super::SearchMemoryResponse;
1147
1148 #[test]
1153 fn back_compat_missing_supplemental_pages_is_none() {
1154 let json = r#"{"results":[],"took_ms":1.0}"#;
1155 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1156 assert!(
1157 resp.supplemental_pages.is_none(),
1158 "should be None when key absent"
1159 );
1160 assert_eq!(resp.took_ms, 1.0);
1161 }
1162
1163 #[test]
1166 fn none_supplemental_pages_not_serialized() {
1167 let resp = SearchMemoryResponse {
1168 results: vec![],
1169 took_ms: 2.0,
1170 supplemental_pages: None,
1171 };
1172 let json = serde_json::to_string(&resp).expect("serialize");
1173 assert!(
1174 !json.contains("supplemental_pages"),
1175 "None field must be omitted from wire: {}",
1176 json
1177 );
1178 }
1179
1180 #[test]
1182 fn some_supplemental_pages_round_trips() {
1183 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1184 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1185 assert!(
1186 resp.supplemental_pages.is_some(),
1187 "supplemental_pages should be Some"
1188 );
1189 assert!(
1190 resp.supplemental_pages.unwrap().is_empty(),
1191 "empty array should deserialize to empty vec"
1192 );
1193 }
1194}