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}
45
46fn default_extraction_method() -> String {
47 "unknown".to_string()
48}
49
50#[derive(Debug, Serialize, Deserialize)]
51pub struct SearchMemoryResponse {
52 pub results: Vec<SearchResult>,
53 pub took_ms: f64,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct ListMemoriesResponse {
58 pub memories: Vec<IndexedFileInfo>,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
67pub struct DeleteResponse {
68 pub deleted: bool,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72pub struct ConfirmResponse {
73 pub confirmed: bool,
74}
75
76#[derive(Debug, Serialize, Deserialize)]
77pub struct ReclassifyMemoryResponse {
78 pub source_id: String,
79 pub memory_type: String,
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83pub struct MemoryStatsResponse {
84 pub stats: MemoryStats,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88pub struct NurtureCardsResponse {
89 pub cards: Vec<MemoryItem>,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
95pub struct HealthResponse {
96 pub status: String,
97 pub db_initialized: bool,
98 pub version: String,
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct StatusResponse {
103 pub is_running: bool,
104 pub files_indexed: u64,
105 pub files_total: u64,
106 pub sources_connected: Vec<String>,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
110pub struct SearchResponse {
111 pub results: Vec<SearchResult>,
112 pub took_ms: f64,
113}
114
115#[doc(hidden)]
116#[derive(Debug, Serialize, Deserialize)]
117pub struct ContextSuggestion {
118 pub content: String,
119 pub score: f32,
120 pub source: String,
121}
122
123#[doc(hidden)]
124#[derive(Debug, Serialize, Deserialize)]
125pub struct ContextResponse {
126 pub suggestions: Vec<ContextSuggestion>,
127 pub took_ms: f64,
128}
129
130#[derive(Debug, Default, Serialize, Deserialize)]
131pub struct TierTokenEstimates {
132 pub tier1_identity: usize,
133 pub tier2_project: usize,
134 pub tier3_relevant: usize,
135 pub total: usize,
136}
137
138#[derive(Debug, Serialize, Deserialize)]
139pub struct ProfileContext {
140 pub narrative: String,
141 pub identity: Vec<String>,
142 pub preferences: Vec<String>,
143 #[deprecated(
147 since = "0.3.2",
148 note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
149 Always empty. Will be removed in 0.4."
150 )]
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub goals: Vec<String>,
153}
154
155#[derive(Debug, Serialize, Deserialize)]
156pub struct KnowledgeContext {
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub pages: Vec<String>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub decisions: Vec<String>,
161 #[serde(default)]
162 pub relevant_memories: Vec<SearchResult>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub graph_context: Vec<String>,
165}
166
167#[derive(Debug, Serialize, Deserialize)]
168pub struct ChatContextResponse {
169 pub context: String,
170 pub profile: ProfileContext,
171 pub knowledge: KnowledgeContext,
172 pub took_ms: f64,
173 pub token_estimates: TierTokenEstimates,
174}
175
176#[derive(Debug, Serialize, Deserialize)]
179pub struct ProfileResponse {
180 pub id: String,
181 pub name: String,
182 pub display_name: Option<String>,
183 pub email: Option<String>,
184 pub bio: Option<String>,
185 pub avatar_path: Option<String>,
186 pub created_at: i64,
187 pub updated_at: i64,
188}
189
190#[derive(Debug, Serialize, Deserialize)]
191pub struct AgentResponse {
192 pub id: String,
193 pub name: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub display_name: Option<String>,
196 pub agent_type: String,
197 pub description: Option<String>,
198 pub enabled: bool,
199 pub trust_level: String,
200 pub last_seen_at: Option<i64>,
201 pub memory_count: i64,
202 pub created_at: i64,
203 pub updated_at: i64,
204}
205
206#[derive(Debug, Serialize, Deserialize)]
209pub struct CreateEntityResponse {
210 pub id: String,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub warnings: Vec<String>,
213}
214
215#[doc(hidden)]
216#[derive(Debug, Serialize, Deserialize)]
217pub struct CreateRelationResponse {
218 pub id: String,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
220 pub warnings: Vec<String>,
221}
222
223#[derive(Debug, Serialize, Deserialize)]
224pub struct AddObservationResponse {
225 pub id: String,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub warnings: Vec<String>,
228}
229
230#[doc(hidden)]
231#[derive(Debug, Serialize, Deserialize)]
232pub struct CreatePageResponse {
233 pub id: String,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub warnings: Vec<String>,
236}
237
238#[derive(Debug, Serialize, Deserialize)]
239pub struct ListEntitiesResponse {
240 pub entities: Vec<Entity>,
241}
242
243#[derive(Debug, Serialize, Deserialize)]
244pub struct SearchEntitiesResponse {
245 pub results: Vec<EntitySearchResult>,
246}
247
248#[derive(Debug, Serialize, Deserialize)]
249pub struct SearchPagesResponse {
250 pub pages: Vec<Page>,
251}
252
253#[derive(Debug, Serialize, Deserialize)]
257pub struct PageLinksResponse {
258 pub outbound: Vec<PageLinkOutbound>,
259 pub inbound: Vec<PageLinkInbound>,
260}
261
262#[derive(Debug, Serialize, Deserialize)]
263pub struct PageLinkOutbound {
264 pub label: String,
265 pub target_page_id: Option<String>,
268}
269
270#[derive(Debug, Serialize, Deserialize)]
271pub struct PageLinkInbound {
272 pub source_page_id: String,
273 pub label: String,
274}
275
276#[derive(Debug, Serialize, Deserialize)]
279pub struct ImportMemoriesResponse {
280 pub imported: usize,
281 pub skipped: usize,
282 pub breakdown: HashMap<String, usize>,
283 pub entities_created: usize,
284 pub observations_added: usize,
285 pub relations_created: usize,
286 pub batch_id: String,
287}
288
289#[doc(hidden)]
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294pub enum Nudge {
295 Silent,
296 Ambient,
297 Notable,
298 Wow,
299}
300
301#[doc(hidden)]
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct PhaseResult {
305 pub name: String,
306 pub duration_ms: u64,
307 pub items_processed: usize,
308 pub error: Option<String>,
309 pub nudge: Nudge,
310 pub headline: Option<String>,
311}
312
313#[doc(hidden)]
314#[derive(Debug, Serialize, Deserialize)]
315pub struct SteepResponse {
316 pub memories_decayed: u64,
317 pub recaps_generated: u32,
318 pub distilled: u32,
319 pub pending_remaining: u32,
320 pub phases: Vec<PhaseResult>,
321}
322
323#[derive(Debug, Serialize, Deserialize)]
326pub struct ConfigResponse {
327 pub skip_apps: Vec<String>,
328 pub skip_title_patterns: Vec<String>,
329 pub private_browsing_detection: bool,
330 pub setup_completed: bool,
331 pub clipboard_enabled: bool,
332 pub screen_capture_enabled: bool,
333 pub remote_access_enabled: bool,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub routine_model: Option<String>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub synthesis_model: Option<String>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub external_llm_endpoint: Option<String>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub external_llm_model: Option<String>,
346}
347
348#[derive(Debug, Serialize, Deserialize)]
351pub struct IndexedFilesResponse {
352 pub files: Vec<IndexedFileInfo>,
353}
354
355#[derive(Debug, Serialize, Deserialize)]
356pub struct DeleteCountResponse {
357 pub deleted: usize,
358}
359
360#[derive(Debug, Serialize, Deserialize)]
363pub struct SuccessResponse {
364 pub ok: bool,
365}
366
367#[derive(Debug, Serialize, Deserialize)]
370pub struct MemoryDetailResponse {
371 pub memory: Option<MemoryItem>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct MemoryDetail {
377 pub id: String,
378 pub content: String,
379 pub title: String,
380 pub source_id: String,
381 pub chunk_index: i32,
382 #[serde(skip_serializing_if = "Option::is_none")]
383 pub chunk_type: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub language: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub semantic_unit: Option<String>,
388 #[serde(skip_serializing_if = "Option::is_none")]
389 pub byte_start: Option<i64>,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 pub byte_end: Option<i64>,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 pub summary: Option<String>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct PendingRevision {
399 pub source_id: String,
400 pub content: String,
401 pub source_agent: Option<String>,
402}
403
404#[derive(Debug, Serialize, Deserialize)]
405pub struct VersionChainResponse {
406 pub versions: Vec<crate::memory::MemoryVersionItem>,
407}
408
409#[derive(Debug, Serialize, Deserialize)]
412pub struct TagsResponse {
413 pub tags: Vec<String>,
414}
415
416#[derive(Debug, Serialize, Deserialize)]
419pub struct ActivityResponse {
420 pub activities: Vec<crate::memory::AgentActivityRow>,
421}
422
423#[derive(Debug, Serialize, Deserialize)]
426pub struct DecisionsResponse {
427 pub decisions: Vec<MemoryItem>,
428}
429
430#[derive(Debug, Serialize, Deserialize)]
431pub struct DecisionDomainsResponse {
432 pub domains: Vec<String>,
433}
434
435#[derive(Debug, Serialize, Deserialize)]
438pub struct PinnedMemoriesResponse {
439 pub memories: Vec<MemoryItem>,
440}
441
442#[derive(Debug, Serialize, Deserialize)]
445pub struct IngestResponse {
446 pub chunks_created: usize,
447 pub document_id: String,
448}
449
450#[derive(Debug, Default, Serialize, Deserialize)]
457pub struct ExportStats {
458 pub exported: usize,
459 pub skipped: usize,
460 pub failed: usize,
461}
462
463#[derive(Debug, Deserialize, Serialize)]
464pub struct ExportPageResponse {
465 pub path: String,
466}
467
468#[derive(Debug, Deserialize, Serialize)]
471pub struct KnowledgePathResponse {
472 pub path: String,
473}
474
475#[derive(Debug, Deserialize, Serialize)]
476pub struct KnowledgeCountResponse {
477 pub count: u64,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct MemoryRevisionEntry {
489 pub source_id: String,
490 pub depth: i64,
491 pub title: String,
492 pub content_preview: String,
493 pub last_modified: i64,
494 #[serde(skip_serializing_if = "Option::is_none")]
495 pub source_agent: Option<String>,
496 #[serde(skip_serializing_if = "Option::is_none")]
497 pub supersede_mode: Option<String>,
498 #[serde(skip_serializing_if = "Option::is_none")]
499 pub delta_summary: Option<String>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct ListMemoryRevisionsResponse {
505 pub current_source_id: String,
506 pub chain_depth: i64,
507 pub entries: Vec<MemoryRevisionEntry>,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct PageChangelogEntry {
513 pub version: i64,
514 pub at: i64,
515 pub edited_by: String,
516 #[serde(skip_serializing_if = "Option::is_none")]
517 pub delta_summary: Option<String>,
518 #[serde(skip_serializing_if = "Option::is_none")]
519 pub incoming_source_ids: Option<Vec<String>>,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct ListPageRevisionsResponse {
525 pub page_id: String,
526 pub current_version: i64,
527 pub user_edited: bool,
528 #[serde(skip_serializing_if = "Option::is_none")]
529 pub stale_reason: Option<String>,
530 pub entries: Vec<PageChangelogEntry>,
531}
532
533#[doc(hidden)]
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct SyncStatsResponse {
538 pub files_found: usize,
539 pub ingested: usize,
540 pub skipped: usize,
541 pub errors: usize,
542}
543
544#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
550#[serde(rename_all = "snake_case")]
551pub enum ProposalAction {
552 EntityMerge,
553 RelationConflict,
554 DetectContradiction,
555 SuggestEntity,
556 DedupMerge,
557}
558
559#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
565#[serde(tag = "action", rename_all = "snake_case")]
566pub enum RefinementPayload {
567 EntityMerge {
568 existing_id: String,
569 new_id: String,
570 similarity: f64,
571 },
572 RelationConflict {
573 existing_id: String,
574 new_id: String,
575 from: String,
576 to: String,
577 old_type: String,
578 new_type: String,
579 },
580 DetectContradiction,
581 SuggestEntity {
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 name_hint: Option<String>,
584 },
585 DedupMerge,
586}
587
588#[derive(Debug, Serialize, Deserialize, Clone)]
589pub struct RefinementProposalSummary {
590 pub id: String,
591 pub action: ProposalAction,
592 pub source_ids: Vec<String>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub payload: Option<RefinementPayload>,
595 pub confidence: f64,
596 pub created_at: String,
597}
598
599#[derive(Debug, Serialize, Deserialize, Clone, Default)]
600pub struct ListRefinementsResponse {
601 pub proposals: Vec<RefinementProposalSummary>,
602}
603
604#[derive(Debug, Serialize, Deserialize, Clone)]
605pub struct RejectRefinementResponse {
606 pub id: String,
607}
608
609#[cfg(test)]
610mod refinement_wire_tests {
611 use super::*;
612
613 #[test]
614 fn proposal_action_serde_round_trip() {
615 let cases = [
616 ("\"entity_merge\"", ProposalAction::EntityMerge),
617 ("\"relation_conflict\"", ProposalAction::RelationConflict),
618 (
619 "\"detect_contradiction\"",
620 ProposalAction::DetectContradiction,
621 ),
622 ("\"suggest_entity\"", ProposalAction::SuggestEntity),
623 ("\"dedup_merge\"", ProposalAction::DedupMerge),
624 ];
625 for (json, expected) in cases {
626 let parsed: ProposalAction = serde_json::from_str(json).unwrap();
627 assert_eq!(parsed, expected, "deserialize {json}");
628 let back = serde_json::to_string(&expected).unwrap();
629 assert_eq!(back, json, "serialize {expected:?}");
630 }
631 }
632
633 #[test]
634 fn refinement_payload_entity_merge_round_trip() {
635 let json =
636 r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
637 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
638 match parsed {
639 RefinementPayload::EntityMerge {
640 ref existing_id,
641 ref new_id,
642 similarity,
643 } => {
644 assert_eq!(existing_id, "e1");
645 assert_eq!(new_id, "e2");
646 assert!((similarity - 0.87).abs() < 1e-9);
647 }
648 _ => panic!("expected EntityMerge variant"),
649 }
650 let back = serde_json::to_value(&parsed).unwrap();
651 assert_eq!(back["action"], "entity_merge");
652 assert_eq!(back["existing_id"], "e1");
653 }
654
655 #[test]
656 fn refinement_payload_dedup_merge_no_fields() {
657 let json = r#"{"action":"dedup_merge"}"#;
658 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
659 assert!(matches!(parsed, RefinementPayload::DedupMerge));
660 }
661
662 #[test]
663 fn refinement_payload_relation_conflict_round_trip() {
664 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"}"#;
665 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
666 match parsed {
667 RefinementPayload::RelationConflict {
668 ref existing_id,
669 ref new_id,
670 ref from,
671 ref to,
672 ref old_type,
673 ref new_type,
674 } => {
675 assert_eq!(existing_id, "r1");
676 assert_eq!(new_id, "r2");
677 assert_eq!(from, "e_a");
678 assert_eq!(to, "e_b");
679 assert_eq!(old_type, "works_at");
680 assert_eq!(new_type, "founded");
681 }
682 _ => panic!("expected RelationConflict"),
683 }
684 let back = serde_json::to_value(&parsed).unwrap();
685 assert_eq!(back["from"], "e_a");
686 assert_eq!(back["to"], "e_b");
687 }
688
689 #[test]
690 fn refinement_payload_detect_contradiction_unit_variant() {
691 let json = r#"{"action":"detect_contradiction"}"#;
692 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
693 assert!(matches!(parsed, RefinementPayload::DetectContradiction));
694 }
695
696 #[test]
697 fn refinement_payload_suggest_entity_with_name_hint() {
698 let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
699 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
700 match parsed {
701 RefinementPayload::SuggestEntity { ref name_hint } => {
702 assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
703 }
704 _ => panic!("expected SuggestEntity"),
705 }
706 }
707
708 #[test]
709 fn refinement_payload_suggest_entity_without_name_hint() {
710 let json = r#"{"action":"suggest_entity"}"#;
711 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
712 assert!(matches!(
713 parsed,
714 RefinementPayload::SuggestEntity { name_hint: None }
715 ));
716 }
717
718 #[test]
719 fn list_refinements_response_round_trip() {
720 let resp = ListRefinementsResponse {
721 proposals: vec![RefinementProposalSummary {
722 id: "ref_1".into(),
723 action: ProposalAction::EntityMerge,
724 source_ids: vec!["a".into(), "b".into()],
725 payload: Some(RefinementPayload::EntityMerge {
726 existing_id: "a".into(),
727 new_id: "b".into(),
728 similarity: 0.86,
729 }),
730 confidence: 0.86,
731 created_at: "2026-05-12T00:00:00Z".into(),
732 }],
733 };
734 let json = serde_json::to_string(&resp).unwrap();
735 let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
736 assert_eq!(parsed.proposals.len(), 1);
737 assert_eq!(parsed.proposals[0].id, "ref_1");
738 assert!(matches!(
739 parsed.proposals[0].action,
740 ProposalAction::EntityMerge
741 ));
742 }
743
744 #[test]
745 fn reject_refinement_response_round_trip() {
746 let resp = RejectRefinementResponse { id: "ref_x".into() };
747 let json = serde_json::to_string(&resp).unwrap();
748 let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
749 assert_eq!(parsed.id, "ref_x");
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn store_memory_response_deserializes_without_extraction_method() {
759 let json = r#"{
761 "source_id": "mem_abc",
762 "chunks_created": 3,
763 "memory_type": "fact"
764 }"#;
765 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
766 assert_eq!(parsed.source_id, "mem_abc");
767 assert_eq!(parsed.chunks_created, 3);
768 assert_eq!(parsed.memory_type, "fact");
769 assert_eq!(parsed.extraction_method, "unknown");
770 assert!(parsed.warnings.is_empty());
771 }
772
773 #[test]
774 fn store_memory_response_deserializes_with_all_fields() {
775 let json = r#"{
776 "source_id": "mem_abc",
777 "chunks_created": 3,
778 "memory_type": "fact",
779 "warnings": ["decision memory missing claim"],
780 "extraction_method": "llm"
781 }"#;
782 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
783 assert_eq!(parsed.warnings.len(), 1);
784 assert_eq!(parsed.extraction_method, "llm");
785 }
786
787 #[test]
788 fn store_memory_response_exposes_enrichment_and_hint() {
789 let json = r#"{
792 "source_id": "mem_xyz",
793 "chunks_created": 1,
794 "memory_type": "fact",
795 "warnings": [],
796 "extraction_method": "unknown",
797 "enrichment": "pending",
798 "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
799 }"#;
800 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
801 assert_eq!(parsed.enrichment, "pending");
802 assert!(parsed.hint.contains("compiling"));
803 }
804
805 #[test]
806 fn store_memory_response_defaults_enrichment_for_older_responses() {
807 let json = r#"{
810 "source_id": "mem_old",
811 "chunks_created": 1,
812 "memory_type": "fact"
813 }"#;
814 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
815 assert_eq!(parsed.enrichment, ""); assert_eq!(parsed.hint, ""); }
818
819 #[test]
820 fn store_memory_response_roundtrips_not_needed_state() {
821 let response = StoreMemoryResponse {
824 source_id: "mem_no_llm".into(),
825 chunks_created: 1,
826 memory_type: "fact".into(),
827 entity_id: None,
828 quality: None,
829 warnings: vec![],
830 extraction_method: "none".into(),
831 enrichment: "not_needed".into(),
832 hint: String::new(),
833 };
834 let json = serde_json::to_string(&response).unwrap();
835 assert!(json.contains("\"enrichment\":\"not_needed\""));
836 assert!(
837 !json.contains("\"hint\""),
838 "empty hint must be skipped on the wire, got: {json}"
839 );
840 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
841 assert_eq!(parsed.enrichment, "not_needed");
842 assert_eq!(parsed.hint, "");
843 }
844
845 #[test]
846 fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
847 #[allow(deprecated)]
850 let profile = ProfileContext {
851 narrative: "n".into(),
852 identity: vec![],
853 preferences: vec![],
854 goals: vec![],
855 };
856 let response = ChatContextResponse {
857 context: "context".into(),
858 profile,
859 knowledge: KnowledgeContext {
860 pages: vec![],
861 decisions: vec![],
862 relevant_memories: vec![],
863 graph_context: vec![],
864 },
865 took_ms: 1.0,
866 token_estimates: TierTokenEstimates {
867 tier1_identity: 1,
868 tier2_project: 2,
869 tier3_relevant: 3,
870 total: 6,
871 },
872 };
873
874 let json = serde_json::to_string(&response).unwrap();
875 let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
876 assert!(parsed.knowledge.pages.is_empty());
877 assert!(parsed.knowledge.decisions.is_empty());
878 assert!(parsed.knowledge.relevant_memories.is_empty());
879 assert!(parsed.knowledge.graph_context.is_empty());
880 }
881}