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