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, Clone, Default, PartialEq, Serialize, Deserialize)]
121#[serde(tag = "state", rename_all = "snake_case")]
122pub enum RerankerStatus {
123 #[default]
125 Disabled,
126 Active { model_id: String },
128 Failed { reason: String },
131}
132
133#[derive(Debug, Serialize, Deserialize)]
134pub struct StatusResponse {
135 pub is_running: bool,
136 pub files_indexed: u64,
137 pub files_total: u64,
138 pub sources_connected: Vec<String>,
139 #[serde(default)]
142 pub reranker: RerankerStatus,
143 #[serde(default)]
147 pub reranker_light: RerankerStatus,
148 #[serde(default)]
151 pub reranker_mode: String,
152}
153
154#[derive(Debug, Serialize, Deserialize)]
155pub struct SearchResponse {
156 pub results: Vec<SearchResult>,
157 pub took_ms: f64,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub supplemental_pages: Option<Vec<SearchResult>>,
164}
165
166#[doc(hidden)]
167#[derive(Debug, Serialize, Deserialize)]
168pub struct ContextSuggestion {
169 pub content: String,
170 pub score: f32,
171 pub source: String,
172}
173
174#[doc(hidden)]
175#[derive(Debug, Serialize, Deserialize)]
176pub struct ContextResponse {
177 pub suggestions: Vec<ContextSuggestion>,
178 pub took_ms: f64,
179}
180
181#[derive(Debug, Default, Serialize, Deserialize)]
182pub struct TierTokenEstimates {
183 pub tier1_identity: usize,
184 pub tier2_project: usize,
185 pub tier3_relevant: usize,
186 pub total: usize,
187}
188
189#[derive(Debug, Serialize, Deserialize)]
190pub struct ProfileContext {
191 pub narrative: String,
192 pub identity: Vec<String>,
193 pub preferences: Vec<String>,
194 #[deprecated(
198 since = "0.3.2",
199 note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
200 Always empty. Will be removed in 0.4."
201 )]
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub goals: Vec<String>,
204}
205
206#[derive(Debug, Serialize, Deserialize)]
207pub struct KnowledgeContext {
208 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub pages: Vec<String>,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
211 pub decisions: Vec<String>,
212 #[serde(default)]
213 pub relevant_memories: Vec<SearchResult>,
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub graph_context: Vec<String>,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
219pub struct ChatContextResponse {
220 pub context: String,
221 pub profile: ProfileContext,
222 pub knowledge: KnowledgeContext,
223 pub took_ms: f64,
224 pub token_estimates: TierTokenEstimates,
225}
226
227#[derive(Debug, Serialize, Deserialize)]
230pub struct ProfileResponse {
231 pub id: String,
232 pub name: String,
233 pub display_name: Option<String>,
234 pub email: Option<String>,
235 pub bio: Option<String>,
236 pub avatar_path: Option<String>,
237 pub created_at: i64,
238 pub updated_at: i64,
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242pub struct AgentResponse {
243 pub id: String,
244 pub name: String,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub display_name: Option<String>,
247 pub agent_type: String,
248 pub description: Option<String>,
249 pub enabled: bool,
250 pub trust_level: String,
251 pub last_seen_at: Option<i64>,
252 pub memory_count: i64,
253 pub created_at: i64,
254 pub updated_at: i64,
255}
256
257#[derive(Debug, Serialize, Deserialize)]
260pub struct CreateEntityResponse {
261 pub id: String,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub warnings: Vec<String>,
264}
265
266#[doc(hidden)]
267#[derive(Debug, Serialize, Deserialize)]
268pub struct CreateRelationResponse {
269 pub id: String,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub warnings: Vec<String>,
272}
273
274#[derive(Debug, Serialize, Deserialize)]
275pub struct AddObservationResponse {
276 pub id: String,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub warnings: Vec<String>,
279}
280
281#[doc(hidden)]
282#[derive(Debug, Serialize, Deserialize)]
283pub struct CreatePageResponse {
284 pub id: String,
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub warnings: Vec<String>,
287}
288
289#[derive(Debug, Serialize, Deserialize)]
290pub struct ListEntitiesResponse {
291 pub entities: Vec<Entity>,
292}
293
294#[derive(Debug, Serialize, Deserialize)]
295pub struct SearchEntitiesResponse {
296 pub results: Vec<EntitySearchResult>,
297}
298
299#[derive(Debug, Serialize, Deserialize)]
300pub struct SearchPagesResponse {
301 pub pages: Vec<Page>,
302}
303
304#[derive(Debug, Serialize, Deserialize)]
308pub struct PageLinksResponse {
309 pub outbound: Vec<PageLinkOutbound>,
310 pub inbound: Vec<PageLinkInbound>,
311}
312
313#[derive(Debug, Serialize, Deserialize)]
314pub struct PageLinkOutbound {
315 pub label: String,
316 pub target_page_id: Option<String>,
319}
320
321#[derive(Debug, Serialize, Deserialize)]
322pub struct PageLinkInbound {
323 pub source_page_id: String,
324 pub label: String,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
330pub struct ImportMemoriesResponse {
331 pub imported: usize,
332 pub skipped: usize,
333 pub breakdown: HashMap<String, usize>,
334 pub entities_created: usize,
335 pub observations_added: usize,
336 pub relations_created: usize,
337 pub batch_id: String,
338}
339
340#[doc(hidden)]
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345pub enum Nudge {
346 Silent,
347 Ambient,
348 Notable,
349 Wow,
350}
351
352#[doc(hidden)]
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct PhaseResult {
356 pub name: String,
357 pub duration_ms: u64,
358 pub items_processed: usize,
359 pub error: Option<String>,
360 pub nudge: Nudge,
361 pub headline: Option<String>,
362}
363
364#[doc(hidden)]
365#[derive(Debug, Serialize, Deserialize)]
366pub struct SteepResponse {
367 pub memories_decayed: u64,
368 pub recaps_generated: u32,
369 pub distilled: u32,
370 pub pending_remaining: u32,
371 pub phases: Vec<PhaseResult>,
372}
373
374#[derive(Debug, Serialize, Deserialize)]
377pub struct ConfigResponse {
378 pub skip_apps: Vec<String>,
379 pub skip_title_patterns: Vec<String>,
380 pub private_browsing_detection: bool,
381 pub setup_completed: bool,
382 pub clipboard_enabled: bool,
383 pub screen_capture_enabled: bool,
384 pub remote_access_enabled: bool,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub routine_model: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub synthesis_model: Option<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub external_llm_endpoint: Option<String>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub external_llm_model: Option<String>,
397}
398
399#[derive(Debug, Serialize, Deserialize)]
402pub struct IndexedFilesResponse {
403 pub files: Vec<IndexedFileInfo>,
404}
405
406#[derive(Debug, Serialize, Deserialize)]
407pub struct DeleteCountResponse {
408 pub deleted: usize,
409}
410
411#[derive(Debug, Serialize, Deserialize)]
414pub struct SuccessResponse {
415 pub ok: bool,
416}
417
418#[derive(Debug, Serialize, Deserialize)]
421pub struct MemoryDetailResponse {
422 pub memory: Option<MemoryItem>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct MemoryDetail {
428 pub id: String,
429 pub content: String,
430 pub title: String,
431 pub source_id: String,
432 pub chunk_index: i32,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub chunk_type: Option<String>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 pub language: Option<String>,
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub semantic_unit: Option<String>,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub byte_start: Option<i64>,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub byte_end: Option<i64>,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub summary: Option<String>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct PendingRevision {
450 pub source_id: String,
451 pub content: String,
452 pub source_agent: Option<String>,
453}
454
455#[derive(Debug, Serialize, Deserialize)]
456pub struct VersionChainResponse {
457 pub versions: Vec<crate::memory::MemoryVersionItem>,
458}
459
460#[derive(Debug, Serialize, Deserialize)]
463pub struct TagsResponse {
464 pub tags: Vec<String>,
465}
466
467#[derive(Debug, Serialize, Deserialize)]
470pub struct ActivityResponse {
471 pub activities: Vec<crate::memory::AgentActivityRow>,
472}
473
474#[derive(Debug, Serialize, Deserialize)]
477pub struct DecisionsResponse {
478 pub decisions: Vec<MemoryItem>,
479}
480
481#[derive(Debug, Serialize, Deserialize)]
482pub struct DecisionDomainsResponse {
483 pub domains: Vec<String>,
486}
487
488#[derive(Debug, Serialize, Deserialize)]
491pub struct PinnedMemoriesResponse {
492 pub memories: Vec<MemoryItem>,
493}
494
495#[derive(Debug, Serialize, Deserialize)]
498pub struct IngestResponse {
499 pub chunks_created: usize,
500 pub document_id: String,
501}
502
503#[derive(Debug, Default, Serialize, Deserialize)]
510pub struct ExportStats {
511 pub exported: usize,
512 pub skipped: usize,
513 pub failed: usize,
514}
515
516#[derive(Debug, Deserialize, Serialize)]
517pub struct ExportPageResponse {
518 pub path: String,
519}
520
521#[derive(Debug, Deserialize, Serialize)]
524pub struct KnowledgePathResponse {
525 pub path: String,
526}
527
528#[derive(Debug, Deserialize, Serialize)]
529pub struct KnowledgeCountResponse {
530 pub count: u64,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct MemoryRevisionEntry {
542 pub source_id: String,
543 pub depth: i64,
544 pub title: String,
545 pub content_preview: String,
546 pub last_modified: i64,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub source_agent: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 pub supersede_mode: Option<String>,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 pub delta_summary: Option<String>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct ListMemoryRevisionsResponse {
558 pub current_source_id: String,
559 pub chain_depth: i64,
560 pub entries: Vec<MemoryRevisionEntry>,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct PageChangelogEntry {
566 pub version: i64,
567 pub at: i64,
568 pub edited_by: String,
569 #[serde(skip_serializing_if = "Option::is_none")]
570 pub delta_summary: Option<String>,
571 #[serde(skip_serializing_if = "Option::is_none")]
572 pub incoming_source_ids: Option<Vec<String>>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct ListPageRevisionsResponse {
578 pub page_id: String,
579 pub current_version: i64,
580 pub user_edited: bool,
581 #[serde(skip_serializing_if = "Option::is_none")]
582 pub stale_reason: Option<String>,
583 pub entries: Vec<PageChangelogEntry>,
584}
585
586#[doc(hidden)]
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct SyncStatsResponse {
591 pub files_found: usize,
592 pub ingested: usize,
593 pub skipped: usize,
594 pub errors: usize,
595}
596
597#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
603#[serde(rename_all = "snake_case")]
604pub enum ProposalAction {
605 EntityMerge,
606 RelationConflict,
607 DetectContradiction,
608 SuggestEntity,
609 DedupMerge,
610}
611
612#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
618#[serde(tag = "action", rename_all = "snake_case")]
619pub enum RefinementPayload {
620 EntityMerge {
621 existing_id: String,
622 new_id: String,
623 similarity: f64,
624 },
625 RelationConflict {
626 existing_id: String,
627 new_id: String,
628 from: String,
629 to: String,
630 old_type: String,
631 new_type: String,
632 },
633 DetectContradiction,
634 SuggestEntity {
635 #[serde(default, skip_serializing_if = "Option::is_none")]
636 name_hint: Option<String>,
637 },
638 DedupMerge,
639}
640
641#[derive(Debug, Serialize, Deserialize, Clone)]
642pub struct RefinementProposalSummary {
643 pub id: String,
644 pub action: ProposalAction,
645 pub source_ids: Vec<String>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub payload: Option<RefinementPayload>,
648 pub confidence: f64,
649 pub created_at: String,
650}
651
652#[derive(Debug, Serialize, Deserialize, Clone, Default)]
653pub struct ListRefinementsResponse {
654 pub proposals: Vec<RefinementProposalSummary>,
655}
656
657#[derive(Debug, Serialize, Deserialize, Clone)]
658pub struct RejectRefinementResponse {
659 pub id: String,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct AcceptRefinementResponse {
664 pub id: String,
665 pub action_applied: String,
666}
667
668#[cfg(test)]
669mod refinement_wire_tests {
670 use super::*;
671
672 #[test]
673 fn proposal_action_serde_round_trip() {
674 let cases = [
675 ("\"entity_merge\"", ProposalAction::EntityMerge),
676 ("\"relation_conflict\"", ProposalAction::RelationConflict),
677 (
678 "\"detect_contradiction\"",
679 ProposalAction::DetectContradiction,
680 ),
681 ("\"suggest_entity\"", ProposalAction::SuggestEntity),
682 ("\"dedup_merge\"", ProposalAction::DedupMerge),
683 ];
684 for (json, expected) in cases {
685 let parsed: ProposalAction = serde_json::from_str(json).unwrap();
686 assert_eq!(parsed, expected, "deserialize {json}");
687 let back = serde_json::to_string(&expected).unwrap();
688 assert_eq!(back, json, "serialize {expected:?}");
689 }
690 }
691
692 #[test]
693 fn refinement_payload_entity_merge_round_trip() {
694 let json =
695 r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
696 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
697 match parsed {
698 RefinementPayload::EntityMerge {
699 ref existing_id,
700 ref new_id,
701 similarity,
702 } => {
703 assert_eq!(existing_id, "e1");
704 assert_eq!(new_id, "e2");
705 assert!((similarity - 0.87).abs() < 1e-9);
706 }
707 _ => panic!("expected EntityMerge variant"),
708 }
709 let back = serde_json::to_value(&parsed).unwrap();
710 assert_eq!(back["action"], "entity_merge");
711 assert_eq!(back["existing_id"], "e1");
712 }
713
714 #[test]
715 fn refinement_payload_dedup_merge_no_fields() {
716 let json = r#"{"action":"dedup_merge"}"#;
717 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
718 assert!(matches!(parsed, RefinementPayload::DedupMerge));
719 }
720
721 #[test]
722 fn refinement_payload_relation_conflict_round_trip() {
723 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"}"#;
724 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
725 match parsed {
726 RefinementPayload::RelationConflict {
727 ref existing_id,
728 ref new_id,
729 ref from,
730 ref to,
731 ref old_type,
732 ref new_type,
733 } => {
734 assert_eq!(existing_id, "r1");
735 assert_eq!(new_id, "r2");
736 assert_eq!(from, "e_a");
737 assert_eq!(to, "e_b");
738 assert_eq!(old_type, "works_at");
739 assert_eq!(new_type, "founded");
740 }
741 _ => panic!("expected RelationConflict"),
742 }
743 let back = serde_json::to_value(&parsed).unwrap();
744 assert_eq!(back["from"], "e_a");
745 assert_eq!(back["to"], "e_b");
746 }
747
748 #[test]
749 fn refinement_payload_detect_contradiction_unit_variant() {
750 let json = r#"{"action":"detect_contradiction"}"#;
751 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
752 assert!(matches!(parsed, RefinementPayload::DetectContradiction));
753 }
754
755 #[test]
756 fn refinement_payload_suggest_entity_with_name_hint() {
757 let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
758 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
759 match parsed {
760 RefinementPayload::SuggestEntity { ref name_hint } => {
761 assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
762 }
763 _ => panic!("expected SuggestEntity"),
764 }
765 }
766
767 #[test]
768 fn refinement_payload_suggest_entity_without_name_hint() {
769 let json = r#"{"action":"suggest_entity"}"#;
770 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
771 assert!(matches!(
772 parsed,
773 RefinementPayload::SuggestEntity { name_hint: None }
774 ));
775 }
776
777 #[test]
778 fn list_refinements_response_round_trip() {
779 let resp = ListRefinementsResponse {
780 proposals: vec![RefinementProposalSummary {
781 id: "ref_1".into(),
782 action: ProposalAction::EntityMerge,
783 source_ids: vec!["a".into(), "b".into()],
784 payload: Some(RefinementPayload::EntityMerge {
785 existing_id: "a".into(),
786 new_id: "b".into(),
787 similarity: 0.86,
788 }),
789 confidence: 0.86,
790 created_at: "2026-05-12T00:00:00Z".into(),
791 }],
792 };
793 let json = serde_json::to_string(&resp).unwrap();
794 let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
795 assert_eq!(parsed.proposals.len(), 1);
796 assert_eq!(parsed.proposals[0].id, "ref_1");
797 assert!(matches!(
798 parsed.proposals[0].action,
799 ProposalAction::EntityMerge
800 ));
801 }
802
803 #[test]
804 fn reject_refinement_response_round_trip() {
805 let resp = RejectRefinementResponse { id: "ref_x".into() };
806 let json = serde_json::to_string(&resp).unwrap();
807 let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
808 assert_eq!(parsed.id, "ref_x");
809 }
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
817pub struct OrphanLink {
818 pub label: String,
819 pub count: i64,
820}
821
822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
824pub struct OrphanLinksResponse {
825 pub min_count: usize,
826 pub orphan_labels: Vec<OrphanLink>,
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
836pub struct PendingRevisionItem {
837 pub target_source_id: String,
838 pub revision_source_id: String,
839 pub revision_content: String,
840 pub source_agent: Option<String>,
841 pub last_modified: i64,
842}
843
844#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
848pub struct RevisionAcceptResponse {
849 pub target_source_id: String,
850 pub revision_source_id: String,
851 pub wrote: bool,
852}
853
854#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
857pub struct RevisionDismissResponse {
858 pub target_source_id: String,
859 pub wrote: bool,
860}
861
862#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
867pub struct ContradictionDismissResponse {
868 pub source_id: String,
869 pub wrote: bool,
870}
871
872#[cfg(test)]
873mod mutation_response_tests {
874 use super::*;
875
876 #[test]
877 fn revision_accept_response_serializes_byte_identical() {
878 let r = RevisionAcceptResponse {
879 target_source_id: "mem_target".into(),
880 revision_source_id: "mem_rev".into(),
881 wrote: true,
882 };
883 assert_eq!(
884 serde_json::to_string(&r).unwrap(),
885 r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
886 );
887 }
888
889 #[test]
890 fn revision_dismiss_response_serializes_byte_identical() {
891 let r = RevisionDismissResponse {
892 target_source_id: "mem_target".into(),
893 wrote: true,
894 };
895 assert_eq!(
896 serde_json::to_string(&r).unwrap(),
897 r#"{"target_source_id":"mem_target","wrote":true}"#
898 );
899 }
900
901 #[test]
902 fn contradiction_dismiss_response_serializes_byte_identical() {
903 let r = ContradictionDismissResponse {
904 source_id: "mem_abc".into(),
905 wrote: true,
906 };
907 assert_eq!(
908 serde_json::to_string(&r).unwrap(),
909 r#"{"source_id":"mem_abc","wrote":true}"#
910 );
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 #[test]
919 fn store_memory_response_deserializes_without_extraction_method() {
920 let json = r#"{
922 "source_id": "mem_abc",
923 "chunks_created": 3,
924 "memory_type": "fact"
925 }"#;
926 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
927 assert_eq!(parsed.source_id, "mem_abc");
928 assert_eq!(parsed.chunks_created, 3);
929 assert_eq!(parsed.memory_type, "fact");
930 assert_eq!(parsed.extraction_method, "unknown");
931 assert!(parsed.warnings.is_empty());
932 }
933
934 #[test]
935 fn store_memory_response_deserializes_with_all_fields() {
936 let json = r#"{
937 "source_id": "mem_abc",
938 "chunks_created": 3,
939 "memory_type": "fact",
940 "warnings": ["decision memory missing claim"],
941 "extraction_method": "llm"
942 }"#;
943 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
944 assert_eq!(parsed.warnings.len(), 1);
945 assert_eq!(parsed.extraction_method, "llm");
946 }
947
948 #[test]
949 fn store_memory_response_exposes_enrichment_and_hint() {
950 let json = r#"{
953 "source_id": "mem_xyz",
954 "chunks_created": 1,
955 "memory_type": "fact",
956 "warnings": [],
957 "extraction_method": "unknown",
958 "enrichment": "pending",
959 "hint": "Stored. Wenlan is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
960 }"#;
961 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
962 assert_eq!(parsed.enrichment, "pending");
963 assert!(parsed.hint.contains("compiling"));
964 }
965
966 #[test]
967 fn store_memory_response_defaults_enrichment_for_older_responses() {
968 let json = r#"{
971 "source_id": "mem_old",
972 "chunks_created": 1,
973 "memory_type": "fact"
974 }"#;
975 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
976 assert_eq!(parsed.enrichment, ""); assert_eq!(parsed.hint, ""); }
979
980 #[test]
981 fn store_memory_response_roundtrips_not_needed_state() {
982 let response = StoreMemoryResponse {
985 source_id: "mem_no_llm".into(),
986 chunks_created: 1,
987 memory_type: "fact".into(),
988 entity_id: None,
989 quality: None,
990 warnings: vec![],
991 extraction_method: "none".into(),
992 enrichment: "not_needed".into(),
993 hint: String::new(),
994 triggered_revisions: vec![],
995 auto_superseded: vec![],
996 };
997 let json = serde_json::to_string(&response).unwrap();
998 assert!(json.contains("\"enrichment\":\"not_needed\""));
999 assert!(
1000 !json.contains("\"hint\""),
1001 "empty hint must be skipped on the wire, got: {json}"
1002 );
1003 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1004 assert_eq!(parsed.enrichment, "not_needed");
1005 assert_eq!(parsed.hint, "");
1006 }
1007
1008 #[test]
1009 fn store_memory_response_triggered_revisions_serializes_when_non_empty() {
1010 let r = StoreMemoryResponse {
1011 source_id: "mem_new".into(),
1012 chunks_created: 1,
1013 memory_type: "fact".into(),
1014 entity_id: None,
1015 quality: None,
1016 warnings: vec![],
1017 extraction_method: "none".into(),
1018 enrichment: "not_needed".into(),
1019 hint: String::new(),
1020 triggered_revisions: vec!["mem_target_abc".to_string()],
1021 auto_superseded: vec![],
1022 };
1023 let json = serde_json::to_string(&r).unwrap();
1024 assert!(
1025 json.contains("\"triggered_revisions\":[\"mem_target_abc\"]"),
1026 "triggered_revisions must appear in JSON when non-empty, got: {json}"
1027 );
1028 }
1029
1030 #[test]
1031 fn store_memory_response_triggered_revisions_skips_when_empty() {
1032 let r = StoreMemoryResponse {
1033 source_id: "mem_new".into(),
1034 chunks_created: 1,
1035 memory_type: "fact".into(),
1036 entity_id: None,
1037 quality: None,
1038 warnings: vec![],
1039 extraction_method: "none".into(),
1040 enrichment: "not_needed".into(),
1041 hint: String::new(),
1042 triggered_revisions: vec![],
1043 auto_superseded: vec![],
1044 };
1045 let json = serde_json::to_string(&r).unwrap();
1046 assert!(
1047 !json.contains("triggered_revisions"),
1048 "triggered_revisions must be absent from JSON when empty, got: {json}"
1049 );
1050 }
1051
1052 #[test]
1053 fn store_memory_response_auto_superseded_serializes_when_non_empty() {
1054 let r = StoreMemoryResponse {
1055 source_id: "mem_new".into(),
1056 chunks_created: 1,
1057 memory_type: "fact".into(),
1058 entity_id: None,
1059 quality: None,
1060 warnings: vec![],
1061 extraction_method: "none".into(),
1062 enrichment: "not_needed".into(),
1063 hint: String::new(),
1064 triggered_revisions: vec![],
1065 auto_superseded: vec!["mem_old_abc".to_string()],
1066 };
1067 let json = serde_json::to_string(&r).unwrap();
1068 assert!(
1069 json.contains("\"auto_superseded\":[\"mem_old_abc\"]"),
1070 "auto_superseded must appear in JSON when non-empty, got: {json}"
1071 );
1072 }
1073
1074 #[test]
1075 fn store_memory_response_auto_superseded_skips_when_empty() {
1076 let r = StoreMemoryResponse {
1077 source_id: "mem_new".into(),
1078 chunks_created: 1,
1079 memory_type: "fact".into(),
1080 entity_id: None,
1081 quality: None,
1082 warnings: vec![],
1083 extraction_method: "none".into(),
1084 enrichment: "not_needed".into(),
1085 hint: String::new(),
1086 triggered_revisions: vec![],
1087 auto_superseded: vec![],
1088 };
1089 let json = serde_json::to_string(&r).unwrap();
1090 assert!(
1091 !json.contains("auto_superseded"),
1092 "auto_superseded must be absent from JSON when empty, got: {json}"
1093 );
1094 }
1095
1096 #[test]
1097 fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1098 #[allow(deprecated)]
1101 let profile = ProfileContext {
1102 narrative: "n".into(),
1103 identity: vec![],
1104 preferences: vec![],
1105 goals: vec![],
1106 };
1107 let response = ChatContextResponse {
1108 context: "context".into(),
1109 profile,
1110 knowledge: KnowledgeContext {
1111 pages: vec![],
1112 decisions: vec![],
1113 relevant_memories: vec![],
1114 graph_context: vec![],
1115 },
1116 took_ms: 1.0,
1117 token_estimates: TierTokenEstimates {
1118 tier1_identity: 1,
1119 tier2_project: 2,
1120 tier3_relevant: 3,
1121 total: 6,
1122 },
1123 };
1124
1125 let json = serde_json::to_string(&response).unwrap();
1126 let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1127 assert!(parsed.knowledge.pages.is_empty());
1128 assert!(parsed.knowledge.decisions.is_empty());
1129 assert!(parsed.knowledge.relevant_memories.is_empty());
1130 assert!(parsed.knowledge.graph_context.is_empty());
1131 }
1132
1133 #[test]
1134 fn orphan_links_response_golden_string() {
1135 let resp = OrphanLinksResponse {
1136 min_count: 2,
1137 orphan_labels: vec![OrphanLink {
1138 label: "Rust".to_string(),
1139 count: 3,
1140 }],
1141 };
1142 let s = serde_json::to_string(&resp).unwrap();
1143 assert_eq!(
1144 s,
1145 r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1146 );
1147 }
1148
1149 #[test]
1150 fn orphan_links_response_empty_round_trip() {
1151 let resp = OrphanLinksResponse {
1152 min_count: 1,
1153 orphan_labels: vec![],
1154 };
1155 let decoded: OrphanLinksResponse =
1156 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1157 assert_eq!(decoded, resp);
1158 }
1159
1160 #[test]
1161 fn pending_revision_item_round_trip() {
1162 let item = PendingRevisionItem {
1163 target_source_id: "mem_target".into(),
1164 revision_source_id: "mem_rev".into(),
1165 revision_content: "new body".into(),
1166 source_agent: Some("claude-code".into()),
1167 last_modified: 1_715_000_000,
1168 };
1169 let json = serde_json::to_value(&item).unwrap();
1170 assert_eq!(json["target_source_id"], "mem_target");
1171 assert_eq!(json["revision_source_id"], "mem_rev");
1172 assert_eq!(json["revision_content"], "new body");
1173 let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1174 assert_eq!(decoded, item);
1175 }
1176}
1177
1178#[cfg(test)]
1179mod reranker_status_tests {
1180 use super::*;
1181
1182 #[test]
1183 fn status_response_defaults_reranker_to_disabled() {
1184 let json =
1187 r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1188 let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1189 assert_eq!(parsed.reranker, RerankerStatus::Disabled);
1190 assert_eq!(parsed.reranker_light, RerankerStatus::Disabled);
1191 assert_eq!(parsed.reranker_mode, "");
1192 }
1193
1194 #[test]
1195 fn status_response_roundtrips_per_path_reranker() {
1196 let s = StatusResponse {
1197 is_running: true,
1198 files_indexed: 0,
1199 files_total: 0,
1200 sources_connected: vec![],
1201 reranker: RerankerStatus::Active {
1202 model_id: "BGERerankerBase".into(),
1203 },
1204 reranker_light: RerankerStatus::Active {
1205 model_id: "JINARerankerV1TurboEn".into(),
1206 },
1207 reranker_mode: "full".into(),
1208 };
1209 let json = serde_json::to_string(&s).unwrap();
1210 let parsed: StatusResponse = serde_json::from_str(&json).unwrap();
1211 assert_eq!(parsed.reranker, s.reranker);
1212 assert_eq!(parsed.reranker_light, s.reranker_light);
1213 assert_eq!(parsed.reranker_mode, "full");
1214 }
1215
1216 #[test]
1217 fn reranker_status_active_roundtrips() {
1218 let s = RerankerStatus::Active {
1219 model_id: "BGERerankerBase".into(),
1220 };
1221 let json = serde_json::to_string(&s).unwrap();
1222 assert_eq!(serde_json::from_str::<RerankerStatus>(&json).unwrap(), s);
1223 assert!(json.contains("\"state\":\"active\""));
1224 }
1225}
1226
1227#[cfg(test)]
1228mod search_memory_response_tests {
1229 use super::SearchMemoryResponse;
1230
1231 #[test]
1236 fn back_compat_missing_supplemental_pages_is_none() {
1237 let json = r#"{"results":[],"took_ms":1.0}"#;
1238 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1239 assert!(
1240 resp.supplemental_pages.is_none(),
1241 "should be None when key absent"
1242 );
1243 assert_eq!(resp.took_ms, 1.0);
1244 }
1245
1246 #[test]
1249 fn none_supplemental_pages_not_serialized() {
1250 let resp = SearchMemoryResponse {
1251 results: vec![],
1252 took_ms: 2.0,
1253 supplemental_pages: None,
1254 };
1255 let json = serde_json::to_string(&resp).expect("serialize");
1256 assert!(
1257 !json.contains("supplemental_pages"),
1258 "None field must be omitted from wire: {}",
1259 json
1260 );
1261 }
1262
1263 #[test]
1265 fn some_supplemental_pages_round_trips() {
1266 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1267 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1268 assert!(
1269 resp.supplemental_pages.is_some(),
1270 "supplemental_pages should be Some"
1271 );
1272 assert!(
1273 resp.supplemental_pages.unwrap().is_empty(),
1274 "empty array should deserialize to empty vec"
1275 );
1276 }
1277}
1278
1279#[cfg(test)]
1280mod search_response_tests {
1281 use super::SearchResponse;
1282
1283 #[test]
1288 fn back_compat_missing_supplemental_pages_is_none() {
1289 let json = r#"{"results":[],"took_ms":1.0}"#;
1290 let resp: SearchResponse = serde_json::from_str(json).expect("should deserialize");
1291 assert!(
1292 resp.supplemental_pages.is_none(),
1293 "should be None when key absent"
1294 );
1295 assert_eq!(resp.took_ms, 1.0);
1296 }
1297
1298 #[test]
1301 fn none_supplemental_pages_not_serialized() {
1302 let resp = SearchResponse {
1303 results: vec![],
1304 took_ms: 2.0,
1305 supplemental_pages: None,
1306 };
1307 let json = serde_json::to_string(&resp).expect("serialize");
1308 assert!(
1309 !json.contains("supplemental_pages"),
1310 "None field must be omitted from wire: {}",
1311 json
1312 );
1313 }
1314
1315 #[test]
1317 fn some_supplemental_pages_round_trips() {
1318 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1319 let resp: SearchResponse = serde_json::from_str(json).expect("deserialize");
1320 assert!(
1321 resp.supplemental_pages.is_some(),
1322 "supplemental_pages should be Some"
1323 );
1324 assert!(
1325 resp.supplemental_pages.unwrap().is_empty(),
1326 "empty array should deserialize to empty vec"
1327 );
1328 }
1329}