Skip to main content

origin_types/
responses.rs

1// SPDX-License-Identifier: Apache-2.0
2//! API response types for all HTTP endpoints.
3
4use 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// ===== Memory CRUD =====
11
12#[derive(Debug, Serialize, Deserialize)]
13pub struct StoreMemoryResponse {
14    pub source_id: String,
15    pub chunks_created: usize,
16    /// Memory type at the moment of persistence. If caller did not supply
17    /// one and enrichment is pending, this is a placeholder (`"fact"`) —
18    /// check `enrichment` field to know whether to expect it to change.
19    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    /// Schema-validation issues — actionable by the agent.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub warnings: Vec<String>,
27    /// How structured fields were populated. "agent" | "llm" | "none" | "unknown" (forward-compat default).
28    #[serde(default = "default_extraction_method")]
29    pub extraction_method: String,
30    /// Enrichment state for the memory. `"pending"` when background
31    /// classification + entity extraction + concept linking will run;
32    /// `"not_needed"` when no LLM is available and the memory stays as
33    /// caller-supplied. Machine-readable — Tauri app uses this to drive
34    /// polling / live-update UI, MCP callers can choose to relay state.
35    /// Defaulted for backward compatibility with pre-async-enrichment clients.
36    #[serde(default)]
37    pub enrichment: String,
38    /// Prose cue for caller agents — safe to relay to the user verbatim.
39    /// Communicates that Origin is compiling the memory into reusable
40    /// context in the background, so callers don't treat `None` enriched
41    /// fields as failure. Empty when the store completed fully sync.
42    #[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/// Shared wire format for any `deleted: bool` response.
62///
63/// Reused by:
64/// - `DELETE /api/memory/delete/{id}` (server/memory.rs)
65/// - `DELETE /api/documents/{source}/{source_id}` (server/ingest.rs)
66#[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// ===== General search/context =====
93
94#[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: goal taxonomy folded into Identity by migration 45 (Phase 0).
144    /// Always empty — daemon does not emit goal-typed memories. Field stays for
145    /// wire backward compat; will be removed in 0.4.
146    #[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// ===== Profile & Agents =====
177
178#[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// ===== Knowledge graph =====
207
208#[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/// Wikilink graph centered on a single page. Outbound = labels parsed
254/// out of this page's body; `target_page_id` is `None` for orphans.
255/// Inbound = active pages whose body cites this title.
256#[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    /// `None` when the resolver couldn't find a matching active page —
266    /// surfaces in the orphan-by-count feed via /api/pages/orphan-links.
267    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// ===== Import =====
277
278#[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// ===== Steep =====
290
291/// How loud Origin should be about a phase's output.
292#[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/// Result of a single phase within a steep cycle.
302#[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// ===== Config =====
324
325#[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    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub routine_model: Option<String>,
337    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub synthesis_model: Option<String>,
340    /// Base URL for an OpenAI-compatible external LLM endpoint.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub external_llm_endpoint: Option<String>,
343    /// Model identifier to use with the external LLM endpoint.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub external_llm_model: Option<String>,
346}
347
348// ===== Indexed files / chunks =====
349
350#[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// ===== Entity / Observation =====
361
362#[derive(Debug, Serialize, Deserialize)]
363pub struct SuccessResponse {
364    pub ok: bool,
365}
366
367// ===== Memory detail =====
368
369#[derive(Debug, Serialize, Deserialize)]
370pub struct MemoryDetailResponse {
371    pub memory: Option<MemoryItem>,
372}
373
374/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
375#[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/// A pending revision waiting for human approval (Protected tier supersede).
397#[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// ===== Tags =====
410
411#[derive(Debug, Serialize, Deserialize)]
412pub struct TagsResponse {
413    pub tags: Vec<String>,
414}
415
416// ===== Activity =====
417
418#[derive(Debug, Serialize, Deserialize)]
419pub struct ActivityResponse {
420    pub activities: Vec<crate::memory::AgentActivityRow>,
421}
422
423// ===== Decisions =====
424
425#[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// ===== Pinned =====
436
437#[derive(Debug, Serialize, Deserialize)]
438pub struct PinnedMemoriesResponse {
439    pub memories: Vec<MemoryItem>,
440}
441
442// ===== Ingest =====
443
444#[derive(Debug, Serialize, Deserialize)]
445pub struct IngestResponse {
446    pub chunks_created: usize,
447    pub document_id: String,
448}
449
450// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
451// `DeleteResponse { deleted: bool }` defined above — same wire format.
452
453// ===== Concept Export =====
454
455/// Statistics from a bulk page export operation (POST /api/pages/export).
456#[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// ===== Knowledge Directory =====
469
470#[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// ===== Revision history =====
481
482/// One entry in a memory's supersede chain, returned by `/api/memory/{id}/revisions`.
483///
484/// `depth = 0` is the current (most-recent) memory; higher depths are older
485/// predecessors. `delta_summary` is `None` for the deepest entry (no predecessor
486/// to diff against) and computed heuristically for all shallower entries.
487#[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/// Response envelope for `/api/memory/{id}/revisions`.
503#[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/// One entry in a page's version changelog, returned by `/api/pages/{id}/revisions`.
511#[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/// Response envelope for `/api/pages/{id}/revisions`.
523#[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// ===== Sources =====
534
535#[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// ===== Refinement proposals =====
545
546/// The action type for a background-refinery proposal.
547///
548/// Used as the `action` tag in [`RefinementPayload`].
549#[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/// Tagged-union payload emitted by the background refinery.
560///
561/// Each variant carries exactly the fields needed for that action type.
562/// Decoded at the route boundary so downstream consumers (MCP wrappers,
563/// agent skills) can pattern-match instead of inspecting raw JSON strings.
564#[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        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
760        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        // Post-async-refactor shape: the daemon returns immediately after
790        // upsert and reports deferred enrichment via `enrichment` + `hint`.
791        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        // Backward-compat: existing clients (origin-mcp, Tauri app) that
808        // deserialize pre-async-refactor responses must keep working.
809        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, ""); // default
816        assert_eq!(parsed.hint, ""); // default
817    }
818
819    #[test]
820    fn store_memory_response_roundtrips_not_needed_state() {
821        // Daemon reports `not_needed` when no LLM is available. Hint is empty
822        // (skip_serializing_if) so the JSON shrinks accordingly.
823        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        // ProfileContext.goals is deprecated; constructing it directly here
848        // for wire roundtrip coverage until 0.4 drops the field entirely.
849        #[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}