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