1use crate::entities::{Entity, EntitySearchResult};
5use crate::memory::{IndexedFileInfo, MemoryItem, MemoryStats, SearchResult};
6use crate::pages::Page;
7use crate::repair::RepairDigest;
8use crate::{Space, WriteOutcome, WriteSpaceSource};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct NearDuplicate {
16 pub source_id: String,
17 pub similarity: f64,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct StoreMemoryResponse {
22 pub source_id: String,
23 pub chunks_created: usize,
24 pub memory_type: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub entity_id: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub quality: Option<String>,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub warnings: Vec<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub near_duplicate: Option<NearDuplicate>,
37 #[serde(default, skip_serializing_if = "is_false")]
42 pub gated: bool,
43 #[serde(default = "default_extraction_method")]
45 pub extraction_method: String,
46 #[serde(default)]
54 pub enrichment: String,
55 #[serde(default, skip_serializing_if = "String::is_empty")]
60 pub hint: String,
61 #[serde(default)]
62 pub space: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub space_source: Option<WriteSpaceSource>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub write_outcome: Option<WriteOutcome>,
67}
68
69fn default_extraction_method() -> String {
70 "unknown".to_string()
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct SearchMemoryResponse {
75 pub results: Vec<SearchResult>,
76 pub took_ms: f64,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub supplemental_pages: Option<Vec<SearchResult>>,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85pub struct ListMemoriesResponse {
86 pub memories: Vec<IndexedFileInfo>,
87}
88
89#[derive(Debug, Serialize, Deserialize)]
95pub struct DeleteResponse {
96 pub deleted: bool,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct ConfirmResponse {
101 pub confirmed: bool,
102 #[serde(default = "crate::requests::default_true")]
105 pub updated: bool,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109pub struct ReclassifyMemoryResponse {
110 pub source_id: String,
111 pub memory_type: String,
112}
113
114#[derive(Debug, Serialize, Deserialize)]
115pub struct MemoryStatsResponse {
116 pub stats: MemoryStats,
117}
118
119#[derive(Debug, Serialize, Deserialize)]
120pub struct NurtureCardsResponse {
121 pub cards: Vec<MemoryItem>,
122}
123
124#[derive(Debug, Serialize, Deserialize)]
127pub struct HealthResponse {
128 pub status: String,
129 pub db_initialized: bool,
130 pub version: String,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "state", rename_all = "snake_case")]
137pub enum RerankerStatus {
138 #[default]
140 Disabled,
141 Active { model_id: String },
143 Failed { reason: String },
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "state", rename_all = "snake_case")]
154pub enum QueueStatus {
155 #[default]
157 Idle,
158 Active { pending: u64 },
160 Paused {
165 reason: String,
166 pending: u64,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 next_retry_at: Option<i64>,
169 },
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct OnDeviceInferenceStatus {
179 #[serde(default = "default_disabled_backend")]
180 pub backend: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub device: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub device_index: Option<usize>,
185 #[serde(default)]
186 pub gpu_layers: u32,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub fallback_reason: Option<String>,
189}
190
191fn default_disabled_backend() -> String {
192 "disabled".to_string()
193}
194
195impl Default for OnDeviceInferenceStatus {
196 fn default() -> Self {
197 Self {
198 backend: default_disabled_backend(),
199 device: None,
200 device_index: None,
201 gpu_layers: 0,
202 fallback_reason: None,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226pub struct TruthStatus {
227 pub cutover_generation: i64,
231 pub contract_version: u32,
235}
236
237impl TruthStatus {
238 pub const fn cutover_live(&self) -> bool {
243 self.cutover_generation > 0
244 }
245}
246
247#[derive(Debug, Serialize, Deserialize)]
248pub struct StatusResponse {
249 pub is_running: bool,
250 pub files_indexed: u64,
251 pub files_total: u64,
252 pub sources_connected: Vec<String>,
253 #[serde(default)]
256 pub queue: QueueStatus,
257 #[serde(default)]
263 pub compile_queue: QueueStatus,
264 #[serde(default)]
267 pub reranker: RerankerStatus,
268 #[serde(default)]
272 pub reranker_light: RerankerStatus,
273 #[serde(default)]
276 pub reranker_mode: String,
277 #[serde(default)]
281 pub on_device_inference: OnDeviceInferenceStatus,
282 #[serde(default)]
285 pub capabilities: Vec<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub truth: Option<TruthStatus>,
290}
291
292#[derive(Debug, Serialize, Deserialize)]
293pub struct SearchResponse {
294 pub results: Vec<SearchResult>,
295 pub took_ms: f64,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub supplemental_pages: Option<Vec<SearchResult>>,
302}
303
304#[doc(hidden)]
305#[derive(Debug, Serialize, Deserialize)]
306pub struct ContextSuggestion {
307 pub content: String,
308 pub score: f32,
309 pub source: String,
310}
311
312#[doc(hidden)]
313#[derive(Debug, Serialize, Deserialize)]
314pub struct ContextResponse {
315 pub suggestions: Vec<ContextSuggestion>,
316 pub took_ms: f64,
317}
318
319#[derive(Debug, Default, Serialize, Deserialize)]
320pub struct TierTokenEstimates {
321 pub tier1_identity: usize,
322 pub tier2_project: usize,
323 pub tier3_relevant: usize,
324 pub total: usize,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328pub struct ProfileContext {
329 pub narrative: String,
330 pub identity: Vec<String>,
331 pub preferences: Vec<String>,
332 #[deprecated(
336 since = "0.3.2",
337 note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
338 Always empty. Will be removed in 0.4."
339 )]
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub goals: Vec<String>,
342}
343
344#[derive(Debug, Serialize, Deserialize)]
345pub struct KnowledgeContext {
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 pub pages: Vec<String>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub decisions: Vec<String>,
350 #[serde(default)]
351 pub relevant_memories: Vec<SearchResult>,
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub graph_context: Vec<String>,
354}
355
356#[derive(Debug, Serialize, Deserialize)]
357pub struct ChatContextResponse {
358 pub context: String,
359 pub profile: ProfileContext,
360 pub knowledge: KnowledgeContext,
361 pub took_ms: f64,
362 pub token_estimates: TierTokenEstimates,
363}
364
365#[derive(Debug, Serialize, Deserialize)]
368pub struct ProfileResponse {
369 pub id: String,
370 pub name: String,
371 pub display_name: Option<String>,
372 pub email: Option<String>,
373 pub bio: Option<String>,
374 pub avatar_path: Option<String>,
375 pub created_at: i64,
376 pub updated_at: i64,
377}
378
379#[derive(Debug, Serialize, Deserialize)]
380pub struct AgentResponse {
381 pub id: String,
382 pub name: String,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub display_name: Option<String>,
385 pub agent_type: String,
386 pub description: Option<String>,
387 pub enabled: bool,
388 pub trust_level: String,
389 pub last_seen_at: Option<i64>,
390 pub memory_count: i64,
391 pub created_at: i64,
392 pub updated_at: i64,
393}
394
395#[derive(Debug, Serialize, Deserialize)]
398pub struct CreateEntityResponse {
399 pub id: String,
400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub warnings: Vec<String>,
402 #[serde(default)]
403 pub space: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub space_source: Option<WriteSpaceSource>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub write_outcome: Option<WriteOutcome>,
408}
409
410#[doc(hidden)]
411#[derive(Debug, Serialize, Deserialize)]
412pub struct CreateRelationResponse {
413 pub id: String,
414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
415 pub warnings: Vec<String>,
416}
417
418#[derive(Debug, Serialize, Deserialize)]
419pub struct AddObservationResponse {
420 pub id: String,
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
422 pub warnings: Vec<String>,
423}
424
425#[doc(hidden)]
426#[derive(Debug, Serialize, Deserialize)]
427pub struct CreatePageResponse {
428 pub id: String,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub attached_to: Option<String>,
431 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub warnings: Vec<String>,
433 #[serde(default)]
434 pub space: Option<String>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub space_source: Option<WriteSpaceSource>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub write_outcome: Option<WriteOutcome>,
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442pub struct ListEntitiesResponse {
443 pub entities: Vec<Entity>,
444}
445
446#[derive(Debug, Serialize, Deserialize)]
447pub struct SearchEntitiesResponse {
448 pub results: Vec<EntitySearchResult>,
449}
450
451#[derive(Debug, Serialize, Deserialize)]
454pub struct MergeEntityResponse {
455 pub canonical_id: String,
456 pub canonical_name: String,
457 pub loser_id: String,
458 pub loser_name: String,
459 pub memory_links: u64,
463 pub observations: u64,
464 pub edges: u64,
468 pub aliases_added: Vec<String>,
469 pub applied: bool,
470}
471
472#[derive(Debug, Serialize, Deserialize)]
474pub struct EntityAliasesResponse {
475 pub entity_id: String,
476 pub aliases: Vec<String>,
477}
478
479#[derive(Debug, Serialize, Deserialize)]
480pub struct SearchPagesResponse {
481 pub pages: Vec<Page>,
482}
483
484#[derive(Debug, Serialize, Deserialize)]
488pub struct PageLinksResponse {
489 pub outbound: Vec<PageLinkOutbound>,
490 pub inbound: Vec<PageLinkInbound>,
491}
492
493#[derive(Debug, Serialize, Deserialize)]
494pub struct PageLinkOutbound {
495 pub label: String,
496 pub target_page_id: Option<String>,
499}
500
501#[derive(Debug, Serialize, Deserialize)]
502pub struct PageLinkInbound {
503 pub source_page_id: String,
504 pub label: String,
505}
506
507#[derive(Debug, Serialize, Deserialize)]
510pub struct ImportMemoriesResponse {
511 pub imported: usize,
512 pub skipped: usize,
513 pub breakdown: HashMap<String, usize>,
514 pub entities_created: usize,
515 pub observations_added: usize,
516 pub relations_created: usize,
517 pub batch_id: String,
518 #[serde(default)]
519 pub space: Option<String>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub space_source: Option<WriteSpaceSource>,
522}
523
524#[derive(Debug, Serialize, Deserialize)]
525pub struct DefaultSpaceResponse {
526 pub space: Option<Space>,
527}
528
529#[doc(hidden)]
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
534pub enum Nudge {
535 Silent,
536 Ambient,
537 Notable,
538 Wow,
539}
540
541#[doc(hidden)]
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct PhaseResult {
545 pub name: String,
546 pub duration_ms: u64,
547 pub items_processed: usize,
548 pub error: Option<String>,
549 pub nudge: Nudge,
550 pub headline: Option<String>,
551}
552
553#[doc(hidden)]
554#[derive(Debug, Serialize, Deserialize)]
555pub struct SteepResponse {
556 pub memories_decayed: u64,
557 pub recaps_generated: u32,
558 pub distilled: u32,
559 pub pending_remaining: u32,
560 pub phases: Vec<PhaseResult>,
561}
562
563#[derive(Debug, Serialize, Deserialize)]
566pub struct ConfigResponse {
567 pub skip_apps: Vec<String>,
568 pub skip_title_patterns: Vec<String>,
569 pub private_browsing_detection: bool,
570 pub setup_completed: bool,
571 pub clipboard_enabled: bool,
572 pub screen_capture_enabled: bool,
573 pub remote_access_enabled: bool,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub routine_model: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub synthesis_model: Option<String>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub external_llm_endpoint: Option<String>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub external_llm_model: Option<String>,
586 #[serde(default)]
589 pub external_llm_api_key_configured: bool,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub everyday_source: Option<String>,
595 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub synthesis_source: Option<String>,
599 #[serde(default)]
601 pub page_map_auto_suggest: bool,
602}
603
604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
607pub struct OnDeviceModelEntry {
608 pub id: String,
609 pub display_name: String,
610 pub param_count: String,
611 pub ram_required_gb: f64,
612 pub file_size_gb: f64,
613 pub cached: bool,
614}
615
616#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618pub struct OnDeviceModelResponse {
619 pub loaded: Option<String>,
621 pub selected: Option<String>,
624 pub models: Vec<OnDeviceModelEntry>,
626}
627
628#[derive(Debug, Serialize, Deserialize)]
631pub struct IndexedFilesResponse {
632 pub files: Vec<IndexedFileInfo>,
633}
634
635#[derive(Debug, Serialize, Deserialize)]
636pub struct DeleteCountResponse {
637 pub deleted: usize,
638}
639
640#[derive(Debug, Serialize, Deserialize)]
643pub struct SuccessResponse {
644 pub ok: bool,
645}
646
647fn is_false(value: &bool) -> bool {
648 !*value
649}
650
651#[derive(Debug, Serialize, Deserialize)]
652pub struct PageWriteResponse {
653 pub ok: bool,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub revision_card_id: Option<String>,
656 #[serde(default, skip_serializing_if = "is_false")]
657 pub gated: bool,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct PageDraftResponse {
663 pub page: Page,
664}
665
666#[derive(Debug, Serialize, Deserialize)]
669pub struct MemoryDetailResponse {
670 pub memory: Option<MemoryItem>,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct MemoryDetail {
676 pub id: String,
677 pub content: String,
678 pub title: String,
679 pub source_id: String,
680 pub chunk_index: i32,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 pub chunk_type: Option<String>,
683 #[serde(skip_serializing_if = "Option::is_none")]
684 pub language: Option<String>,
685 #[serde(skip_serializing_if = "Option::is_none")]
686 pub semantic_unit: Option<String>,
687 #[serde(skip_serializing_if = "Option::is_none")]
688 pub byte_start: Option<i64>,
689 #[serde(skip_serializing_if = "Option::is_none")]
690 pub byte_end: Option<i64>,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 pub summary: Option<String>,
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct PendingRevision {
698 pub source_id: String,
699 pub content: String,
700 pub source_agent: Option<String>,
701}
702
703#[derive(Debug, Serialize, Deserialize)]
704pub struct VersionChainResponse {
705 pub versions: Vec<crate::memory::MemoryVersionItem>,
706}
707
708#[derive(Debug, Serialize, Deserialize)]
711pub struct TagsResponse {
712 pub tags: Vec<String>,
713 #[serde(default)]
714 pub document_tags: HashMap<String, Vec<String>>,
715}
716
717#[derive(Debug, Serialize, Deserialize)]
720pub struct ActivityResponse {
721 pub activities: Vec<crate::memory::AgentActivityRow>,
722}
723
724#[derive(Debug, Serialize, Deserialize)]
727pub struct DecisionsResponse {
728 pub decisions: Vec<MemoryItem>,
729}
730
731#[derive(Debug, Serialize, Deserialize)]
732pub struct DecisionDomainsResponse {
733 pub domains: Vec<String>,
736}
737
738#[derive(Debug, Serialize, Deserialize)]
741pub struct PinnedMemoriesResponse {
742 pub memories: Vec<MemoryItem>,
743}
744
745#[derive(Debug, Serialize, Deserialize)]
748pub struct IngestResponse {
749 pub chunks_created: usize,
750 pub document_id: String,
751}
752
753#[derive(Debug, Default, Serialize, Deserialize)]
760pub struct ExportStats {
761 pub exported: usize,
762 pub skipped: usize,
763 pub failed: usize,
764}
765
766#[derive(Debug, Deserialize, Serialize)]
767pub struct ExportPageResponse {
768 pub path: String,
769}
770
771#[derive(Debug, Deserialize, Serialize)]
774pub struct KnowledgePathResponse {
775 pub path: String,
776}
777
778#[derive(Debug, Deserialize, Serialize)]
779pub struct KnowledgeCountResponse {
780 pub count: u64,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct MemoryRevisionEntry {
792 pub source_id: String,
793 pub depth: i64,
794 pub title: String,
795 pub content_preview: String,
796 pub last_modified: i64,
797 #[serde(skip_serializing_if = "Option::is_none")]
798 pub source_agent: Option<String>,
799 #[serde(skip_serializing_if = "Option::is_none")]
800 pub supersede_mode: Option<String>,
801 #[serde(skip_serializing_if = "Option::is_none")]
802 pub delta_summary: Option<String>,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct ListMemoryRevisionsResponse {
808 pub current_source_id: String,
809 pub chain_depth: i64,
810 pub entries: Vec<MemoryRevisionEntry>,
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct PageChangelogEntry {
816 pub version: i64,
817 pub at: i64,
818 pub edited_by: String,
819 #[serde(skip_serializing_if = "Option::is_none")]
820 pub delta_summary: Option<String>,
821 #[serde(skip_serializing_if = "Option::is_none")]
822 pub incoming_source_ids: Option<Vec<String>>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub citations_summary: Option<String>,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct ListPageRevisionsResponse {
833 pub page_id: String,
834 pub current_version: i64,
835 pub user_edited: bool,
836 #[serde(skip_serializing_if = "Option::is_none")]
837 pub stale_reason: Option<String>,
838 pub entries: Vec<PageChangelogEntry>,
839}
840
841#[doc(hidden)]
844#[derive(Debug, Clone, Serialize, Deserialize)]
845pub struct SyncStatsResponse {
846 pub files_found: usize,
847 pub ingested: usize,
848 pub skipped: usize,
849 pub errors: usize,
850}
851
852#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
858#[serde(rename_all = "snake_case")]
859pub enum ProposalAction {
860 EntityMerge,
861 RelationConflict,
862 DetectContradiction,
863 SuggestEntity,
864 DedupMerge,
865 PageMerge,
866 CrossSpaceDiscovery,
867 PageKeepOrArchive,
868 LintRepairReview,
869 VocabPromote,
870 #[serde(other)]
875 Unknown,
876}
877
878#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
879#[serde(rename_all = "snake_case")]
880pub enum RefinementCardAction {
881 Accept,
882 Dismiss,
883 PickSpace,
884}
885
886#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
892#[serde(tag = "action", rename_all = "snake_case")]
893pub enum RefinementPayload {
894 EntityMerge {
895 existing_id: String,
896 new_id: String,
897 similarity: f64,
898 },
899 RelationConflict {
900 existing_id: String,
901 new_id: String,
902 from: String,
903 to: String,
904 old_type: String,
905 new_type: String,
906 },
907 DetectContradiction,
908 SuggestEntity {
909 #[serde(default, skip_serializing_if = "Option::is_none")]
910 name_hint: Option<String>,
911 },
912 DedupMerge,
913 PageMerge {
914 left_page_id: String,
915 right_page_id: String,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
917 similarity: Option<f64>,
918 source_overlap: usize,
919 source_overlap_ratio: f64,
920 },
921 CrossSpaceDiscovery {
922 memory_count: usize,
923 spaces: Vec<String>,
924 allowed_actions: Vec<RefinementCardAction>,
925 },
926 PageKeepOrArchive {
927 page_id: String,
928 source_count: usize,
929 allowed_actions: Vec<RefinementCardAction>,
930 },
931 LintRepairReview {
932 check_id: String,
933 occurrence_digest: RepairDigest,
934 owner_binding_digest: RepairDigest,
935 issue: String,
936 choices: Vec<String>,
937 suggested_research_queries: Vec<String>,
938 },
939 VocabPromote {
940 kind: String,
941 old_value: String,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
943 category: Option<String>,
944 },
945}
946
947#[derive(Debug, Serialize, Deserialize, Clone)]
948pub struct RefinementProposalSummary {
949 pub id: String,
950 pub action: ProposalAction,
951 pub source_ids: Vec<String>,
952 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub payload: Option<RefinementPayload>,
954 pub confidence: f64,
955 pub created_at: String,
956}
957
958#[derive(Debug, Serialize, Deserialize, Clone, Default)]
959pub struct ListRefinementsResponse {
960 pub proposals: Vec<RefinementProposalSummary>,
961}
962
963#[derive(Debug, Serialize, Deserialize, Clone)]
964pub struct RejectRefinementResponse {
965 pub id: String,
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize)]
969pub struct AcceptRefinementResponse {
970 pub id: String,
971 pub action_applied: String,
972}
973
974#[cfg(test)]
975mod refinement_wire_tests {
976 use super::*;
977
978 #[test]
979 fn proposal_action_serde_round_trip() {
980 let cases = [
981 ("\"entity_merge\"", ProposalAction::EntityMerge),
982 ("\"relation_conflict\"", ProposalAction::RelationConflict),
983 (
984 "\"detect_contradiction\"",
985 ProposalAction::DetectContradiction,
986 ),
987 ("\"suggest_entity\"", ProposalAction::SuggestEntity),
988 ("\"dedup_merge\"", ProposalAction::DedupMerge),
989 (
990 "\"cross_space_discovery\"",
991 ProposalAction::CrossSpaceDiscovery,
992 ),
993 (
994 "\"page_keep_or_archive\"",
995 ProposalAction::PageKeepOrArchive,
996 ),
997 ("\"vocab_promote\"", ProposalAction::VocabPromote),
998 ];
999 for (json, expected) in cases {
1000 let parsed: ProposalAction = serde_json::from_str(json).unwrap();
1001 assert_eq!(parsed, expected, "deserialize {json}");
1002 let back = serde_json::to_string(&expected).unwrap();
1003 assert_eq!(back, json, "serialize {expected:?}");
1004 }
1005 }
1006
1007 #[test]
1008 fn vocab_promote_payload_round_trips() {
1009 let p = RefinementPayload::VocabPromote {
1010 kind: "relation".into(),
1011 old_value: "design_inspiration".into(),
1012 category: None,
1013 };
1014 let json = serde_json::to_string(&p).unwrap();
1015 let back: RefinementPayload = serde_json::from_str(&json).unwrap();
1016 assert_eq!(p, back);
1017 assert!(json.contains("\"action\":\"vocab_promote\""));
1018 }
1019
1020 #[test]
1021 fn proposal_action_unknown_future_variant_deserializes() {
1022 let parsed: ProposalAction = serde_json::from_str("\"future_unshipped_action\"").unwrap();
1025 assert_eq!(parsed, ProposalAction::Unknown);
1026 let parsed2: ProposalAction = serde_json::from_str("\"totally_new_action\"").unwrap();
1027 assert_eq!(parsed2, ProposalAction::Unknown);
1028 }
1029
1030 #[test]
1031 fn refinement_payload_entity_merge_round_trip() {
1032 let json =
1033 r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
1034 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1035 match parsed {
1036 RefinementPayload::EntityMerge {
1037 ref existing_id,
1038 ref new_id,
1039 similarity,
1040 } => {
1041 assert_eq!(existing_id, "e1");
1042 assert_eq!(new_id, "e2");
1043 assert!((similarity - 0.87).abs() < 1e-9);
1044 }
1045 _ => panic!("expected EntityMerge variant"),
1046 }
1047 let back = serde_json::to_value(&parsed).unwrap();
1048 assert_eq!(back["action"], "entity_merge");
1049 assert_eq!(back["existing_id"], "e1");
1050 }
1051
1052 #[test]
1053 fn refinement_payload_dedup_merge_no_fields() {
1054 let json = r#"{"action":"dedup_merge"}"#;
1055 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1056 assert!(matches!(parsed, RefinementPayload::DedupMerge));
1057 }
1058
1059 #[test]
1060 fn refinement_payload_cross_space_discovery_round_trip() {
1061 let json = r#"{"action":"cross_space_discovery","memory_count":3,"spaces":["personal","work"],"allowed_actions":["dismiss","pick_space"]}"#;
1062 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1063 match parsed {
1064 RefinementPayload::CrossSpaceDiscovery {
1065 memory_count,
1066 ref spaces,
1067 ref allowed_actions,
1068 } => {
1069 assert_eq!(memory_count, 3);
1070 assert_eq!(spaces, &vec!["personal".to_string(), "work".to_string()]);
1071 assert_eq!(
1072 allowed_actions,
1073 &vec![
1074 RefinementCardAction::Dismiss,
1075 RefinementCardAction::PickSpace
1076 ]
1077 );
1078 }
1079 _ => panic!("expected CrossSpaceDiscovery"),
1080 }
1081 let back = serde_json::to_value(&parsed).unwrap();
1082 assert_eq!(back["action"], "cross_space_discovery");
1083 assert_eq!(back["memory_count"], 3);
1084 }
1085
1086 #[test]
1087 fn refinement_payload_page_keep_or_archive_round_trip() {
1088 let json = r#"{"action":"page_keep_or_archive","page_id":"page_stub","source_count":1,"allowed_actions":["dismiss","accept"]}"#;
1089 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1090 match parsed {
1091 RefinementPayload::PageKeepOrArchive {
1092 ref page_id,
1093 source_count,
1094 ref allowed_actions,
1095 } => {
1096 assert_eq!(page_id, "page_stub");
1097 assert_eq!(source_count, 1);
1098 assert_eq!(
1099 allowed_actions,
1100 &vec![RefinementCardAction::Dismiss, RefinementCardAction::Accept]
1101 );
1102 }
1103 _ => panic!("expected PageKeepOrArchive"),
1104 }
1105 let back = serde_json::to_value(&parsed).unwrap();
1106 assert_eq!(back["action"], "page_keep_or_archive");
1107 assert_eq!(back["source_count"], 1);
1108 }
1109
1110 #[test]
1111 fn refinement_payload_relation_conflict_round_trip() {
1112 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"}"#;
1113 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1114 match parsed {
1115 RefinementPayload::RelationConflict {
1116 ref existing_id,
1117 ref new_id,
1118 ref from,
1119 ref to,
1120 ref old_type,
1121 ref new_type,
1122 } => {
1123 assert_eq!(existing_id, "r1");
1124 assert_eq!(new_id, "r2");
1125 assert_eq!(from, "e_a");
1126 assert_eq!(to, "e_b");
1127 assert_eq!(old_type, "works_at");
1128 assert_eq!(new_type, "founded");
1129 }
1130 _ => panic!("expected RelationConflict"),
1131 }
1132 let back = serde_json::to_value(&parsed).unwrap();
1133 assert_eq!(back["from"], "e_a");
1134 assert_eq!(back["to"], "e_b");
1135 }
1136
1137 #[test]
1138 fn refinement_payload_detect_contradiction_unit_variant() {
1139 let json = r#"{"action":"detect_contradiction"}"#;
1140 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1141 assert!(matches!(parsed, RefinementPayload::DetectContradiction));
1142 }
1143
1144 #[test]
1145 fn refinement_payload_suggest_entity_with_name_hint() {
1146 let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
1147 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1148 match parsed {
1149 RefinementPayload::SuggestEntity { ref name_hint } => {
1150 assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
1151 }
1152 _ => panic!("expected SuggestEntity"),
1153 }
1154 }
1155
1156 #[test]
1157 fn refinement_payload_suggest_entity_without_name_hint() {
1158 let json = r#"{"action":"suggest_entity"}"#;
1159 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
1160 assert!(matches!(
1161 parsed,
1162 RefinementPayload::SuggestEntity { name_hint: None }
1163 ));
1164 }
1165
1166 #[test]
1167 fn list_refinements_response_round_trip() {
1168 let resp = ListRefinementsResponse {
1169 proposals: vec![RefinementProposalSummary {
1170 id: "ref_1".into(),
1171 action: ProposalAction::EntityMerge,
1172 source_ids: vec!["a".into(), "b".into()],
1173 payload: Some(RefinementPayload::EntityMerge {
1174 existing_id: "a".into(),
1175 new_id: "b".into(),
1176 similarity: 0.86,
1177 }),
1178 confidence: 0.86,
1179 created_at: "2026-05-12T00:00:00Z".into(),
1180 }],
1181 };
1182 let json = serde_json::to_string(&resp).unwrap();
1183 let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
1184 assert_eq!(parsed.proposals.len(), 1);
1185 assert_eq!(parsed.proposals[0].id, "ref_1");
1186 assert!(matches!(
1187 parsed.proposals[0].action,
1188 ProposalAction::EntityMerge
1189 ));
1190 }
1191
1192 #[test]
1193 fn reject_refinement_response_round_trip() {
1194 let resp = RejectRefinementResponse { id: "ref_x".into() };
1195 let json = serde_json::to_string(&resp).unwrap();
1196 let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
1197 assert_eq!(parsed.id, "ref_x");
1198 }
1199}
1200
1201#[cfg(test)]
1202mod on_device_model_response_tests {
1203 use super::*;
1204
1205 #[test]
1206 fn on_device_model_response_preserves_selected_loaded_and_models() {
1207 let response = OnDeviceModelResponse {
1208 loaded: Some("qwen3-4b".to_string()),
1209 selected: Some("qwen3-4b".to_string()),
1210 models: vec![OnDeviceModelEntry {
1211 id: "qwen3-4b".to_string(),
1212 display_name: "Qwen3 4B".to_string(),
1213 param_count: "4B".to_string(),
1214 ram_required_gb: 6.0,
1215 file_size_gb: 2.7,
1216 cached: true,
1217 }],
1218 };
1219
1220 let value = serde_json::to_value(&response).unwrap();
1221
1222 assert_eq!(value["loaded"], "qwen3-4b");
1223 assert_eq!(value["selected"], "qwen3-4b");
1224 assert_eq!(value["models"][0]["id"], "qwen3-4b");
1225 assert_eq!(value["models"][0]["cached"], true);
1226
1227 let parsed: OnDeviceModelResponse = serde_json::from_value(value).unwrap();
1228 assert_eq!(parsed.loaded.as_deref(), Some("qwen3-4b"));
1229 assert_eq!(parsed.selected.as_deref(), Some("qwen3-4b"));
1230 assert_eq!(parsed.models.len(), 1);
1231 assert!(parsed.models[0].cached);
1232 }
1233
1234 #[test]
1235 fn on_device_model_response_allows_null_loaded_and_selected() {
1236 let parsed: OnDeviceModelResponse =
1237 serde_json::from_str(r#"{"loaded":null,"selected":null,"models":[]}"#).unwrap();
1238
1239 assert!(parsed.loaded.is_none());
1240 assert!(parsed.selected.is_none());
1241 assert!(parsed.models.is_empty());
1242 }
1243}
1244
1245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1250pub struct OrphanLink {
1251 pub label: String,
1252 pub count: i64,
1253}
1254
1255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1257pub struct OrphanLinksResponse {
1258 pub min_count: usize,
1259 pub orphan_labels: Vec<OrphanLink>,
1260}
1261
1262#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1269#[serde(rename_all = "lowercase")]
1270pub enum RevisionTargetKind {
1271 #[default]
1273 Memory,
1274 Page,
1276}
1277
1278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1287pub struct PendingRevisionItem {
1288 pub target_source_id: String,
1289 pub revision_source_id: String,
1290 pub revision_content: String,
1291 pub source_agent: Option<String>,
1292 pub last_modified: i64,
1293 #[serde(default)]
1297 pub target_kind: RevisionTargetKind,
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1301 pub grounded_in: Option<String>,
1302}
1303
1304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1308pub struct RevisionAcceptResponse {
1309 pub target_source_id: String,
1310 pub revision_source_id: String,
1311 pub wrote: bool,
1312}
1313
1314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1317pub struct RevisionDismissResponse {
1318 pub target_source_id: String,
1319 pub wrote: bool,
1320}
1321
1322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1327pub struct ContradictionDismissResponse {
1328 pub source_id: String,
1329 pub wrote: bool,
1330}
1331
1332#[cfg(test)]
1333mod mutation_response_tests {
1334 use super::*;
1335
1336 #[test]
1337 fn revision_accept_response_serializes_byte_identical() {
1338 let r = RevisionAcceptResponse {
1339 target_source_id: "mem_target".into(),
1340 revision_source_id: "mem_rev".into(),
1341 wrote: true,
1342 };
1343 assert_eq!(
1344 serde_json::to_string(&r).unwrap(),
1345 r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
1346 );
1347 }
1348
1349 #[test]
1350 fn revision_dismiss_response_serializes_byte_identical() {
1351 let r = RevisionDismissResponse {
1352 target_source_id: "mem_target".into(),
1353 wrote: true,
1354 };
1355 assert_eq!(
1356 serde_json::to_string(&r).unwrap(),
1357 r#"{"target_source_id":"mem_target","wrote":true}"#
1358 );
1359 }
1360
1361 #[test]
1362 fn contradiction_dismiss_response_serializes_byte_identical() {
1363 let r = ContradictionDismissResponse {
1364 source_id: "mem_abc".into(),
1365 wrote: true,
1366 };
1367 assert_eq!(
1368 serde_json::to_string(&r).unwrap(),
1369 r#"{"source_id":"mem_abc","wrote":true}"#
1370 );
1371 }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 use super::*;
1377
1378 #[test]
1379 fn store_memory_response_deserializes_without_extraction_method() {
1380 let json = r#"{
1382 "source_id": "mem_abc",
1383 "chunks_created": 3,
1384 "memory_type": "fact"
1385 }"#;
1386 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1387 assert_eq!(parsed.source_id, "mem_abc");
1388 assert_eq!(parsed.chunks_created, 3);
1389 assert_eq!(parsed.memory_type, "fact");
1390 assert_eq!(parsed.extraction_method, "unknown");
1391 assert!(parsed.warnings.is_empty());
1392 }
1393
1394 #[test]
1395 fn store_memory_response_deserializes_with_all_fields() {
1396 let json = r#"{
1397 "source_id": "mem_abc",
1398 "chunks_created": 3,
1399 "memory_type": "fact",
1400 "warnings": ["decision memory missing claim"],
1401 "extraction_method": "llm"
1402 }"#;
1403 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1404 assert_eq!(parsed.warnings.len(), 1);
1405 assert_eq!(parsed.extraction_method, "llm");
1406 }
1407
1408 #[test]
1409 fn store_memory_response_exposes_enrichment_and_hint() {
1410 let json = r#"{
1413 "source_id": "mem_xyz",
1414 "chunks_created": 1,
1415 "memory_type": "fact",
1416 "warnings": [],
1417 "extraction_method": "unknown",
1418 "enrichment": "pending",
1419 "hint": "Stored. Recall is available now; Wenlan will quietly enrich classification and page links in the background."
1420 }"#;
1421 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1422 assert_eq!(parsed.enrichment, "pending");
1423 assert!(parsed.hint.contains("quietly enrich"));
1424 }
1425
1426 #[test]
1427 fn store_memory_response_defaults_enrichment_for_older_responses() {
1428 let json = r#"{
1431 "source_id": "mem_old",
1432 "chunks_created": 1,
1433 "memory_type": "fact"
1434 }"#;
1435 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1436 assert_eq!(parsed.enrichment, ""); assert_eq!(parsed.hint, ""); }
1439
1440 #[test]
1441 fn tags_response_defaults_document_tags_for_older_responses() {
1442 let json = r#"{"tags":["rust","tauri"]}"#;
1443 let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1444
1445 assert_eq!(parsed.tags, vec!["rust", "tauri"]);
1446 assert!(parsed.document_tags.is_empty());
1447 }
1448
1449 #[test]
1450 fn tags_response_deserializes_document_tag_map() {
1451 let json = r#"{
1452 "tags":["rust","tauri"],
1453 "document_tags":{"memory::mem1":["rust"],"page::page1":["tauri"]}
1454 }"#;
1455 let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1456
1457 assert_eq!(
1458 parsed.document_tags.get("memory::mem1"),
1459 Some(&vec!["rust".to_string()])
1460 );
1461 assert_eq!(
1462 parsed.document_tags.get("page::page1"),
1463 Some(&vec!["tauri".to_string()])
1464 );
1465 }
1466
1467 #[test]
1468 fn store_memory_response_roundtrips_not_needed_state() {
1469 let response = StoreMemoryResponse {
1471 source_id: "mem_no_llm".into(),
1472 chunks_created: 1,
1473 memory_type: "fact".into(),
1474 entity_id: None,
1475 quality: None,
1476 warnings: vec![],
1477 near_duplicate: None,
1478 gated: false,
1479 extraction_method: "none".into(),
1480 enrichment: "not_needed".into(),
1481 hint: String::new(),
1482 space: None,
1483 space_source: None,
1484 write_outcome: None,
1485 };
1486 let json = serde_json::to_string(&response).unwrap();
1487 assert!(json.contains("\"enrichment\":\"not_needed\""));
1488 assert!(
1489 !json.contains("\"hint\""),
1490 "empty hint must be skipped on the wire, got: {json}"
1491 );
1492 assert!(
1493 !json.contains("\"near_duplicate\""),
1494 "near_duplicate: None must be skipped on the wire, got: {json}"
1495 );
1496 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1497 assert_eq!(parsed.enrichment, "not_needed");
1498 assert_eq!(parsed.hint, "");
1499 }
1500
1501 #[test]
1502 fn gated_flag_is_absent_from_the_wire_when_false() {
1503 let response = StoreMemoryResponse {
1504 source_id: "mem_not_gated".into(),
1505 chunks_created: 1,
1506 memory_type: "fact".into(),
1507 entity_id: None,
1508 quality: None,
1509 warnings: vec![],
1510 near_duplicate: None,
1511 gated: false,
1512 extraction_method: "none".into(),
1513 enrichment: String::new(),
1514 hint: String::new(),
1515 space: None,
1516 space_source: None,
1517 write_outcome: None,
1518 };
1519 let json = serde_json::to_string(&response).unwrap();
1520 assert!(
1521 !json.contains("\"gated\""),
1522 "gated: false must be skipped on the wire, got: {json}"
1523 );
1524 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1525 assert!(!parsed.gated);
1526 }
1527
1528 #[test]
1529 fn store_memory_response_roundtrips_paused_state_with_hint() {
1530 let response = StoreMemoryResponse {
1531 source_id: "mem_paused".into(),
1532 chunks_created: 1,
1533 memory_type: "fact".into(),
1534 entity_id: None,
1535 quality: None,
1536 warnings: vec![],
1537 near_duplicate: None,
1538 gated: false,
1539 extraction_method: "none".into(),
1540 enrichment: "paused".into(),
1541 hint: "Stored; choose a model source to enable enrichment.".into(),
1542 space: None,
1543 space_source: None,
1544 write_outcome: None,
1545 };
1546 let json = serde_json::to_string(&response).unwrap();
1547 assert!(json.contains("\"enrichment\":\"paused\""));
1548 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1549 assert_eq!(parsed.enrichment, "paused");
1550 assert!(parsed.hint.contains("choose a model source"));
1551 }
1552
1553 #[test]
1554 fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1555 #[allow(deprecated)]
1558 let profile = ProfileContext {
1559 narrative: "n".into(),
1560 identity: vec![],
1561 preferences: vec![],
1562 goals: vec![],
1563 };
1564 let response = ChatContextResponse {
1565 context: "context".into(),
1566 profile,
1567 knowledge: KnowledgeContext {
1568 pages: vec![],
1569 decisions: vec![],
1570 relevant_memories: vec![],
1571 graph_context: vec![],
1572 },
1573 took_ms: 1.0,
1574 token_estimates: TierTokenEstimates {
1575 tier1_identity: 1,
1576 tier2_project: 2,
1577 tier3_relevant: 3,
1578 total: 6,
1579 },
1580 };
1581
1582 let json = serde_json::to_string(&response).unwrap();
1583 let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1584 assert!(parsed.knowledge.pages.is_empty());
1585 assert!(parsed.knowledge.decisions.is_empty());
1586 assert!(parsed.knowledge.relevant_memories.is_empty());
1587 assert!(parsed.knowledge.graph_context.is_empty());
1588 }
1589
1590 #[test]
1591 fn orphan_links_response_golden_string() {
1592 let resp = OrphanLinksResponse {
1593 min_count: 2,
1594 orphan_labels: vec![OrphanLink {
1595 label: "Rust".to_string(),
1596 count: 3,
1597 }],
1598 };
1599 let s = serde_json::to_string(&resp).unwrap();
1600 assert_eq!(
1601 s,
1602 r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1603 );
1604 }
1605
1606 #[test]
1607 fn orphan_links_response_empty_round_trip() {
1608 let resp = OrphanLinksResponse {
1609 min_count: 1,
1610 orphan_labels: vec![],
1611 };
1612 let decoded: OrphanLinksResponse =
1613 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1614 assert_eq!(decoded, resp);
1615 }
1616
1617 #[test]
1618 fn pending_revision_item_round_trip() {
1619 let item = PendingRevisionItem {
1620 target_source_id: "mem_target".into(),
1621 revision_source_id: "mem_rev".into(),
1622 revision_content: "new body".into(),
1623 source_agent: Some("claude-code".into()),
1624 last_modified: 1_715_000_000,
1625 target_kind: RevisionTargetKind::Memory,
1626 grounded_in: None,
1627 };
1628 let json = serde_json::to_value(&item).unwrap();
1629 assert_eq!(json["target_source_id"], "mem_target");
1630 assert_eq!(json["revision_source_id"], "mem_rev");
1631 assert_eq!(json["revision_content"], "new body");
1632 assert_eq!(json["target_kind"], "memory");
1633 let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1634 assert_eq!(decoded, item);
1635 }
1636
1637 #[test]
1641 fn pending_revision_item_carries_the_target_kind() {
1642 let page_item = PendingRevisionItem {
1643 target_source_id: "page_abc".into(),
1644 revision_source_id: "mem_rev".into(),
1645 revision_content: "new page body".into(),
1646 source_agent: Some("page_write".into()),
1647 last_modified: 1_715_000_000,
1648 target_kind: RevisionTargetKind::Page,
1649 grounded_in: None,
1650 };
1651 let json = serde_json::to_value(&page_item).unwrap();
1652 assert_eq!(json["target_kind"], "page");
1653 let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1654 assert_eq!(decoded, page_item);
1655
1656 let legacy = serde_json::json!({
1657 "target_source_id": "mem_target",
1658 "revision_source_id": "mem_rev",
1659 "revision_content": "new body",
1660 "source_agent": null,
1661 "last_modified": 1_715_000_000i64,
1662 });
1663 let decoded: PendingRevisionItem = serde_json::from_value(legacy).unwrap();
1664 assert_eq!(decoded.target_kind, RevisionTargetKind::Memory);
1665 }
1666}
1667
1668#[cfg(test)]
1669mod queue_status_tests {
1670 use super::*;
1671
1672 #[test]
1673 fn status_response_defaults_queue_to_idle_when_absent() {
1674 let json =
1677 r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1678 let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1679 assert_eq!(parsed.queue, QueueStatus::Idle);
1680 assert_eq!(
1681 parsed.on_device_inference,
1682 OnDeviceInferenceStatus::default()
1683 );
1684 }
1685
1686 #[test]
1687 fn on_device_inference_status_round_trips_vulkan_device_and_fallback() {
1688 let status = OnDeviceInferenceStatus {
1689 backend: "vulkan".into(),
1690 device: Some("NVIDIA GeForce RTX 3060 Laptop GPU".into()),
1691 device_index: Some(2),
1692 gpu_layers: 99,
1693 fallback_reason: None,
1694 };
1695 let json = serde_json::to_string(&status).unwrap();
1696 let parsed: OnDeviceInferenceStatus = serde_json::from_str(&json).unwrap();
1697
1698 assert_eq!(parsed, status);
1699 assert!(json.contains("\"backend\":\"vulkan\""));
1700 assert!(json.contains("RTX 3060"));
1701
1702 let fallback: OnDeviceInferenceStatus = serde_json::from_str(
1703 r#"{"backend":"cpu","gpu_layers":0,"fallback_reason":"Vulkan context creation failed"}"#,
1704 )
1705 .unwrap();
1706 assert_eq!(fallback.backend, "cpu");
1707 assert_eq!(
1708 fallback.fallback_reason.as_deref(),
1709 Some("Vulkan context creation failed")
1710 );
1711 }
1712
1713 #[test]
1714 fn queue_status_paused_round_trips_with_reason_and_retry() {
1715 let s = QueueStatus::Paused {
1716 reason: "analysis LLM failed".into(),
1717 pending: 2,
1718 next_retry_at: Some(1_712_678_400),
1719 };
1720 let json = serde_json::to_string(&s).unwrap();
1721 assert!(json.contains("\"state\":\"paused\""), "got: {json}");
1722 assert!(
1723 json.contains("\"reason\":\"analysis LLM failed\""),
1724 "got: {json}"
1725 );
1726 assert!(json.contains("\"next_retry_at\":1712678400"), "got: {json}");
1727 assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1728 }
1729
1730 #[test]
1731 fn queue_status_active_round_trips() {
1732 let s = QueueStatus::Active { pending: 3 };
1733 let json = serde_json::to_string(&s).unwrap();
1734 assert!(json.contains("\"state\":\"active\""), "got: {json}");
1735 assert!(json.contains("\"pending\":3"), "got: {json}");
1736 assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1737 }
1738
1739 #[test]
1740 fn queue_status_idle_serializes_state_only() {
1741 let json = serde_json::to_string(&QueueStatus::Idle).unwrap();
1742 assert_eq!(json, r#"{"state":"idle"}"#);
1743 }
1744}
1745
1746#[cfg(test)]
1747mod reranker_status_tests {
1748 use super::*;
1749
1750 #[test]
1751 fn status_response_defaults_reranker_to_disabled() {
1752 let json =
1755 r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1756 let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1757 assert_eq!(parsed.reranker, RerankerStatus::Disabled);
1758 assert_eq!(parsed.reranker_light, RerankerStatus::Disabled);
1759 assert_eq!(parsed.reranker_mode, "");
1760 }
1761
1762 #[test]
1763 fn status_response_roundtrips_per_path_reranker() {
1764 let s = StatusResponse {
1765 is_running: true,
1766 files_indexed: 0,
1767 files_total: 0,
1768 sources_connected: vec![],
1769 queue: QueueStatus::Idle,
1770 compile_queue: QueueStatus::Idle,
1771 reranker: RerankerStatus::Active {
1772 model_id: "BGERerankerBase".into(),
1773 },
1774 reranker_light: RerankerStatus::Active {
1775 model_id: "JINARerankerV1TurboEn".into(),
1776 },
1777 reranker_mode: "full".into(),
1778 on_device_inference: OnDeviceInferenceStatus::default(),
1779 capabilities: vec!["default_save_space".into()],
1780 truth: None,
1781 };
1782 let json = serde_json::to_string(&s).unwrap();
1783 let parsed: StatusResponse = serde_json::from_str(&json).unwrap();
1784 assert_eq!(parsed.reranker, s.reranker);
1785 assert_eq!(parsed.reranker_light, s.reranker_light);
1786 assert_eq!(parsed.reranker_mode, "full");
1787 }
1788
1789 #[test]
1790 fn reranker_status_active_roundtrips() {
1791 let s = RerankerStatus::Active {
1792 model_id: "BGERerankerBase".into(),
1793 };
1794 let json = serde_json::to_string(&s).unwrap();
1795 assert_eq!(serde_json::from_str::<RerankerStatus>(&json).unwrap(), s);
1796 assert!(json.contains("\"state\":\"active\""));
1797 }
1798}
1799
1800#[cfg(test)]
1801mod search_memory_response_tests {
1802 use super::SearchMemoryResponse;
1803
1804 #[test]
1809 fn back_compat_missing_supplemental_pages_is_none() {
1810 let json = r#"{"results":[],"took_ms":1.0}"#;
1811 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1812 assert!(
1813 resp.supplemental_pages.is_none(),
1814 "should be None when key absent"
1815 );
1816 assert_eq!(resp.took_ms, 1.0);
1817 }
1818
1819 #[test]
1822 fn none_supplemental_pages_not_serialized() {
1823 let resp = SearchMemoryResponse {
1824 results: vec![],
1825 took_ms: 2.0,
1826 supplemental_pages: None,
1827 };
1828 let json = serde_json::to_string(&resp).expect("serialize");
1829 assert!(
1830 !json.contains("supplemental_pages"),
1831 "None field must be omitted from wire: {}",
1832 json
1833 );
1834 }
1835
1836 #[test]
1838 fn some_supplemental_pages_round_trips() {
1839 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1840 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1841 assert!(
1842 resp.supplemental_pages.is_some(),
1843 "supplemental_pages should be Some"
1844 );
1845 assert!(
1846 resp.supplemental_pages.unwrap().is_empty(),
1847 "empty array should deserialize to empty vec"
1848 );
1849 }
1850}
1851
1852#[cfg(test)]
1853mod search_response_tests {
1854 use super::SearchResponse;
1855
1856 #[test]
1861 fn back_compat_missing_supplemental_pages_is_none() {
1862 let json = r#"{"results":[],"took_ms":1.0}"#;
1863 let resp: SearchResponse = serde_json::from_str(json).expect("should deserialize");
1864 assert!(
1865 resp.supplemental_pages.is_none(),
1866 "should be None when key absent"
1867 );
1868 assert_eq!(resp.took_ms, 1.0);
1869 }
1870
1871 #[test]
1874 fn none_supplemental_pages_not_serialized() {
1875 let resp = SearchResponse {
1876 results: vec![],
1877 took_ms: 2.0,
1878 supplemental_pages: None,
1879 };
1880 let json = serde_json::to_string(&resp).expect("serialize");
1881 assert!(
1882 !json.contains("supplemental_pages"),
1883 "None field must be omitted from wire: {}",
1884 json
1885 );
1886 }
1887
1888 #[test]
1890 fn some_supplemental_pages_round_trips() {
1891 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1892 let resp: SearchResponse = serde_json::from_str(json).expect("deserialize");
1893 assert!(
1894 resp.supplemental_pages.is_some(),
1895 "supplemental_pages should be Some"
1896 );
1897 assert!(
1898 resp.supplemental_pages.unwrap().is_empty(),
1899 "empty array should deserialize to empty vec"
1900 );
1901 }
1902}
1903
1904#[derive(Debug, Clone, Serialize, Deserialize)]
1912pub struct PageReviewReceipt {
1913 pub page_id: String,
1914 pub human_reviewed: bool,
1915 pub reviewed_page_version: i64,
1918 pub reviewed_page_digest: String,
1919 pub protocol_version: u32,
1920 pub nonce_digest: String,
1921 pub verified_at: i64,
1922 pub caller_id: String,
1923 pub operation_id: String,
1924}