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