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    /// Source IDs of protected memories now flagged for human revision
45    /// because this capture's topic-match upsert fired against them. Empty
46    /// when no contradictions detected. Skills should surface these inline
47    /// to the user with accept/dismiss verbs (see `accept_revision` /
48    /// `dismiss_revision` MCP tools).
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub triggered_revisions: Vec<String>,
51    /// Source IDs of protected memories auto-accepted by the daemon because
52    /// the capture came from a full-trust agent and embedding similarity
53    /// exceeded the auto-supersede threshold. No human action needed.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub auto_superseded: Vec<String>,
56}
57
58fn default_extraction_method() -> String {
59    "unknown".to_string()
60}
61
62#[derive(Debug, Serialize, Deserialize)]
63pub struct SearchMemoryResponse {
64    pub results: Vec<SearchResult>,
65    pub took_ms: f64,
66    /// Distilled pages surfaced by the page channel during reranked search.
67    /// Absent when no page rows were returned (back-compat: old daemons never
68    /// set this field; old consumers that don't read it are unaffected).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub supplemental_pages: Option<Vec<SearchResult>>,
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct ListMemoriesResponse {
75    pub memories: Vec<IndexedFileInfo>,
76}
77
78/// Shared wire format for any `deleted: bool` response.
79///
80/// Reused by:
81/// - `DELETE /api/memory/delete/{id}` (server/memory.rs)
82/// - `DELETE /api/documents/{source}/{source_id}` (server/ingest.rs)
83#[derive(Debug, Serialize, Deserialize)]
84pub struct DeleteResponse {
85    pub deleted: bool,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct ConfirmResponse {
90    pub confirmed: bool,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct ReclassifyMemoryResponse {
95    pub source_id: String,
96    pub memory_type: String,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct MemoryStatsResponse {
101    pub stats: MemoryStats,
102}
103
104#[derive(Debug, Serialize, Deserialize)]
105pub struct NurtureCardsResponse {
106    pub cards: Vec<MemoryItem>,
107}
108
109// ===== General search/context =====
110
111#[derive(Debug, Serialize, Deserialize)]
112pub struct HealthResponse {
113    pub status: String,
114    pub db_initialized: bool,
115    pub version: String,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
119pub struct StatusResponse {
120    pub is_running: bool,
121    pub files_indexed: u64,
122    pub files_total: u64,
123    pub sources_connected: Vec<String>,
124}
125
126#[derive(Debug, Serialize, Deserialize)]
127pub struct SearchResponse {
128    pub results: Vec<SearchResult>,
129    pub took_ms: f64,
130}
131
132#[doc(hidden)]
133#[derive(Debug, Serialize, Deserialize)]
134pub struct ContextSuggestion {
135    pub content: String,
136    pub score: f32,
137    pub source: String,
138}
139
140#[doc(hidden)]
141#[derive(Debug, Serialize, Deserialize)]
142pub struct ContextResponse {
143    pub suggestions: Vec<ContextSuggestion>,
144    pub took_ms: f64,
145}
146
147#[derive(Debug, Default, Serialize, Deserialize)]
148pub struct TierTokenEstimates {
149    pub tier1_identity: usize,
150    pub tier2_project: usize,
151    pub tier3_relevant: usize,
152    pub total: usize,
153}
154
155#[derive(Debug, Serialize, Deserialize)]
156pub struct ProfileContext {
157    pub narrative: String,
158    pub identity: Vec<String>,
159    pub preferences: Vec<String>,
160    /// Deprecated: goal taxonomy folded into Identity by migration 45 (Phase 0).
161    /// Always empty — daemon does not emit goal-typed memories. Field stays for
162    /// wire backward compat; will be removed in 0.4.
163    #[deprecated(
164        since = "0.3.2",
165        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
166                Always empty. Will be removed in 0.4."
167    )]
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub goals: Vec<String>,
170}
171
172#[derive(Debug, Serialize, Deserialize)]
173pub struct KnowledgeContext {
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub pages: Vec<String>,
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub decisions: Vec<String>,
178    #[serde(default)]
179    pub relevant_memories: Vec<SearchResult>,
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub graph_context: Vec<String>,
182}
183
184#[derive(Debug, Serialize, Deserialize)]
185pub struct ChatContextResponse {
186    pub context: String,
187    pub profile: ProfileContext,
188    pub knowledge: KnowledgeContext,
189    pub took_ms: f64,
190    pub token_estimates: TierTokenEstimates,
191}
192
193// ===== Profile & Agents =====
194
195#[derive(Debug, Serialize, Deserialize)]
196pub struct ProfileResponse {
197    pub id: String,
198    pub name: String,
199    pub display_name: Option<String>,
200    pub email: Option<String>,
201    pub bio: Option<String>,
202    pub avatar_path: Option<String>,
203    pub created_at: i64,
204    pub updated_at: i64,
205}
206
207#[derive(Debug, Serialize, Deserialize)]
208pub struct AgentResponse {
209    pub id: String,
210    pub name: String,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub display_name: Option<String>,
213    pub agent_type: String,
214    pub description: Option<String>,
215    pub enabled: bool,
216    pub trust_level: String,
217    pub last_seen_at: Option<i64>,
218    pub memory_count: i64,
219    pub created_at: i64,
220    pub updated_at: i64,
221}
222
223// ===== Knowledge graph =====
224
225#[derive(Debug, Serialize, Deserialize)]
226pub struct CreateEntityResponse {
227    pub id: String,
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub warnings: Vec<String>,
230}
231
232#[doc(hidden)]
233#[derive(Debug, Serialize, Deserialize)]
234pub struct CreateRelationResponse {
235    pub id: String,
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub warnings: Vec<String>,
238}
239
240#[derive(Debug, Serialize, Deserialize)]
241pub struct AddObservationResponse {
242    pub id: String,
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub warnings: Vec<String>,
245}
246
247#[doc(hidden)]
248#[derive(Debug, Serialize, Deserialize)]
249pub struct CreatePageResponse {
250    pub id: String,
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub warnings: Vec<String>,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
256pub struct ListEntitiesResponse {
257    pub entities: Vec<Entity>,
258}
259
260#[derive(Debug, Serialize, Deserialize)]
261pub struct SearchEntitiesResponse {
262    pub results: Vec<EntitySearchResult>,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
266pub struct SearchPagesResponse {
267    pub pages: Vec<Page>,
268}
269
270/// Wikilink graph centered on a single page. Outbound = labels parsed
271/// out of this page's body; `target_page_id` is `None` for orphans.
272/// Inbound = active pages whose body cites this title.
273#[derive(Debug, Serialize, Deserialize)]
274pub struct PageLinksResponse {
275    pub outbound: Vec<PageLinkOutbound>,
276    pub inbound: Vec<PageLinkInbound>,
277}
278
279#[derive(Debug, Serialize, Deserialize)]
280pub struct PageLinkOutbound {
281    pub label: String,
282    /// `None` when the resolver couldn't find a matching active page —
283    /// surfaces in the orphan-by-count feed via /api/pages/orphan-links.
284    pub target_page_id: Option<String>,
285}
286
287#[derive(Debug, Serialize, Deserialize)]
288pub struct PageLinkInbound {
289    pub source_page_id: String,
290    pub label: String,
291}
292
293// ===== Import =====
294
295#[derive(Debug, Serialize, Deserialize)]
296pub struct ImportMemoriesResponse {
297    pub imported: usize,
298    pub skipped: usize,
299    pub breakdown: HashMap<String, usize>,
300    pub entities_created: usize,
301    pub observations_added: usize,
302    pub relations_created: usize,
303    pub batch_id: String,
304}
305
306// ===== Steep =====
307
308/// How loud Origin should be about a phase's output.
309#[doc(hidden)]
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311pub enum Nudge {
312    Silent,
313    Ambient,
314    Notable,
315    Wow,
316}
317
318/// Result of a single phase within a steep cycle.
319#[doc(hidden)]
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct PhaseResult {
322    pub name: String,
323    pub duration_ms: u64,
324    pub items_processed: usize,
325    pub error: Option<String>,
326    pub nudge: Nudge,
327    pub headline: Option<String>,
328}
329
330#[doc(hidden)]
331#[derive(Debug, Serialize, Deserialize)]
332pub struct SteepResponse {
333    pub memories_decayed: u64,
334    pub recaps_generated: u32,
335    pub distilled: u32,
336    pub pending_remaining: u32,
337    pub phases: Vec<PhaseResult>,
338}
339
340// ===== Config =====
341
342#[derive(Debug, Serialize, Deserialize)]
343pub struct ConfigResponse {
344    pub skip_apps: Vec<String>,
345    pub skip_title_patterns: Vec<String>,
346    pub private_browsing_detection: bool,
347    pub setup_completed: bool,
348    pub clipboard_enabled: bool,
349    pub screen_capture_enabled: bool,
350    pub remote_access_enabled: bool,
351    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub routine_model: Option<String>,
354    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub synthesis_model: Option<String>,
357    /// Base URL for an OpenAI-compatible external LLM endpoint.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub external_llm_endpoint: Option<String>,
360    /// Model identifier to use with the external LLM endpoint.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub external_llm_model: Option<String>,
363}
364
365// ===== Indexed files / chunks =====
366
367#[derive(Debug, Serialize, Deserialize)]
368pub struct IndexedFilesResponse {
369    pub files: Vec<IndexedFileInfo>,
370}
371
372#[derive(Debug, Serialize, Deserialize)]
373pub struct DeleteCountResponse {
374    pub deleted: usize,
375}
376
377// ===== Entity / Observation =====
378
379#[derive(Debug, Serialize, Deserialize)]
380pub struct SuccessResponse {
381    pub ok: bool,
382}
383
384// ===== Memory detail =====
385
386#[derive(Debug, Serialize, Deserialize)]
387pub struct MemoryDetailResponse {
388    pub memory: Option<MemoryItem>,
389}
390
391/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct MemoryDetail {
394    pub id: String,
395    pub content: String,
396    pub title: String,
397    pub source_id: String,
398    pub chunk_index: i32,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub chunk_type: Option<String>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub language: Option<String>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub semantic_unit: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub byte_start: Option<i64>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub byte_end: Option<i64>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub summary: Option<String>,
411}
412
413/// A pending revision waiting for human approval (Protected tier supersede).
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct PendingRevision {
416    pub source_id: String,
417    pub content: String,
418    pub source_agent: Option<String>,
419}
420
421#[derive(Debug, Serialize, Deserialize)]
422pub struct VersionChainResponse {
423    pub versions: Vec<crate::memory::MemoryVersionItem>,
424}
425
426// ===== Tags =====
427
428#[derive(Debug, Serialize, Deserialize)]
429pub struct TagsResponse {
430    pub tags: Vec<String>,
431}
432
433// ===== Activity =====
434
435#[derive(Debug, Serialize, Deserialize)]
436pub struct ActivityResponse {
437    pub activities: Vec<crate::memory::AgentActivityRow>,
438}
439
440// ===== Decisions =====
441
442#[derive(Debug, Serialize, Deserialize)]
443pub struct DecisionsResponse {
444    pub decisions: Vec<MemoryItem>,
445}
446
447#[derive(Debug, Serialize, Deserialize)]
448pub struct DecisionDomainsResponse {
449    /// Kept as `domains` for one-release back-compat with callers of
450    /// `/api/decisions/domains`; rename to `spaces` in PR-A+1.
451    pub domains: Vec<String>,
452}
453
454// ===== Pinned =====
455
456#[derive(Debug, Serialize, Deserialize)]
457pub struct PinnedMemoriesResponse {
458    pub memories: Vec<MemoryItem>,
459}
460
461// ===== Ingest =====
462
463#[derive(Debug, Serialize, Deserialize)]
464pub struct IngestResponse {
465    pub chunks_created: usize,
466    pub document_id: String,
467}
468
469// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
470// `DeleteResponse { deleted: bool }` defined above — same wire format.
471
472// ===== Concept Export =====
473
474/// Statistics from a bulk page export operation (POST /api/pages/export).
475#[derive(Debug, Default, Serialize, Deserialize)]
476pub struct ExportStats {
477    pub exported: usize,
478    pub skipped: usize,
479    pub failed: usize,
480}
481
482#[derive(Debug, Deserialize, Serialize)]
483pub struct ExportPageResponse {
484    pub path: String,
485}
486
487// ===== Knowledge Directory =====
488
489#[derive(Debug, Deserialize, Serialize)]
490pub struct KnowledgePathResponse {
491    pub path: String,
492}
493
494#[derive(Debug, Deserialize, Serialize)]
495pub struct KnowledgeCountResponse {
496    pub count: u64,
497}
498
499// ===== Revision history =====
500
501/// One entry in a memory's supersede chain, returned by `/api/memory/{id}/revisions`.
502///
503/// `depth = 0` is the current (most-recent) memory; higher depths are older
504/// predecessors. `delta_summary` is `None` for the deepest entry (no predecessor
505/// to diff against) and computed heuristically for all shallower entries.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct MemoryRevisionEntry {
508    pub source_id: String,
509    pub depth: i64,
510    pub title: String,
511    pub content_preview: String,
512    pub last_modified: i64,
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub source_agent: Option<String>,
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub supersede_mode: Option<String>,
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub delta_summary: Option<String>,
519}
520
521/// Response envelope for `/api/memory/{id}/revisions`.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct ListMemoryRevisionsResponse {
524    pub current_source_id: String,
525    pub chain_depth: i64,
526    pub entries: Vec<MemoryRevisionEntry>,
527}
528
529/// One entry in a page's version changelog, returned by `/api/pages/{id}/revisions`.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct PageChangelogEntry {
532    pub version: i64,
533    pub at: i64,
534    pub edited_by: String,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub delta_summary: Option<String>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub incoming_source_ids: Option<Vec<String>>,
539}
540
541/// Response envelope for `/api/pages/{id}/revisions`.
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct ListPageRevisionsResponse {
544    pub page_id: String,
545    pub current_version: i64,
546    pub user_edited: bool,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub stale_reason: Option<String>,
549    pub entries: Vec<PageChangelogEntry>,
550}
551
552// ===== Sources =====
553
554#[doc(hidden)]
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct SyncStatsResponse {
557    pub files_found: usize,
558    pub ingested: usize,
559    pub skipped: usize,
560    pub errors: usize,
561}
562
563// ===== Refinement proposals =====
564
565/// The action type for a background-refinery proposal.
566///
567/// Used as the `action` tag in [`RefinementPayload`].
568#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
569#[serde(rename_all = "snake_case")]
570pub enum ProposalAction {
571    EntityMerge,
572    RelationConflict,
573    DetectContradiction,
574    SuggestEntity,
575    DedupMerge,
576}
577
578/// Tagged-union payload emitted by the background refinery.
579///
580/// Each variant carries exactly the fields needed for that action type.
581/// Decoded at the route boundary so downstream consumers (MCP wrappers,
582/// agent skills) can pattern-match instead of inspecting raw JSON strings.
583#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
584#[serde(tag = "action", rename_all = "snake_case")]
585pub enum RefinementPayload {
586    EntityMerge {
587        existing_id: String,
588        new_id: String,
589        similarity: f64,
590    },
591    RelationConflict {
592        existing_id: String,
593        new_id: String,
594        from: String,
595        to: String,
596        old_type: String,
597        new_type: String,
598    },
599    DetectContradiction,
600    SuggestEntity {
601        #[serde(default, skip_serializing_if = "Option::is_none")]
602        name_hint: Option<String>,
603    },
604    DedupMerge,
605}
606
607#[derive(Debug, Serialize, Deserialize, Clone)]
608pub struct RefinementProposalSummary {
609    pub id: String,
610    pub action: ProposalAction,
611    pub source_ids: Vec<String>,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub payload: Option<RefinementPayload>,
614    pub confidence: f64,
615    pub created_at: String,
616}
617
618#[derive(Debug, Serialize, Deserialize, Clone, Default)]
619pub struct ListRefinementsResponse {
620    pub proposals: Vec<RefinementProposalSummary>,
621}
622
623#[derive(Debug, Serialize, Deserialize, Clone)]
624pub struct RejectRefinementResponse {
625    pub id: String,
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct AcceptRefinementResponse {
630    pub id: String,
631    pub action_applied: String,
632}
633
634#[cfg(test)]
635mod refinement_wire_tests {
636    use super::*;
637
638    #[test]
639    fn proposal_action_serde_round_trip() {
640        let cases = [
641            ("\"entity_merge\"", ProposalAction::EntityMerge),
642            ("\"relation_conflict\"", ProposalAction::RelationConflict),
643            (
644                "\"detect_contradiction\"",
645                ProposalAction::DetectContradiction,
646            ),
647            ("\"suggest_entity\"", ProposalAction::SuggestEntity),
648            ("\"dedup_merge\"", ProposalAction::DedupMerge),
649        ];
650        for (json, expected) in cases {
651            let parsed: ProposalAction = serde_json::from_str(json).unwrap();
652            assert_eq!(parsed, expected, "deserialize {json}");
653            let back = serde_json::to_string(&expected).unwrap();
654            assert_eq!(back, json, "serialize {expected:?}");
655        }
656    }
657
658    #[test]
659    fn refinement_payload_entity_merge_round_trip() {
660        let json =
661            r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
662        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
663        match parsed {
664            RefinementPayload::EntityMerge {
665                ref existing_id,
666                ref new_id,
667                similarity,
668            } => {
669                assert_eq!(existing_id, "e1");
670                assert_eq!(new_id, "e2");
671                assert!((similarity - 0.87).abs() < 1e-9);
672            }
673            _ => panic!("expected EntityMerge variant"),
674        }
675        let back = serde_json::to_value(&parsed).unwrap();
676        assert_eq!(back["action"], "entity_merge");
677        assert_eq!(back["existing_id"], "e1");
678    }
679
680    #[test]
681    fn refinement_payload_dedup_merge_no_fields() {
682        let json = r#"{"action":"dedup_merge"}"#;
683        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
684        assert!(matches!(parsed, RefinementPayload::DedupMerge));
685    }
686
687    #[test]
688    fn refinement_payload_relation_conflict_round_trip() {
689        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"}"#;
690        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
691        match parsed {
692            RefinementPayload::RelationConflict {
693                ref existing_id,
694                ref new_id,
695                ref from,
696                ref to,
697                ref old_type,
698                ref new_type,
699            } => {
700                assert_eq!(existing_id, "r1");
701                assert_eq!(new_id, "r2");
702                assert_eq!(from, "e_a");
703                assert_eq!(to, "e_b");
704                assert_eq!(old_type, "works_at");
705                assert_eq!(new_type, "founded");
706            }
707            _ => panic!("expected RelationConflict"),
708        }
709        let back = serde_json::to_value(&parsed).unwrap();
710        assert_eq!(back["from"], "e_a");
711        assert_eq!(back["to"], "e_b");
712    }
713
714    #[test]
715    fn refinement_payload_detect_contradiction_unit_variant() {
716        let json = r#"{"action":"detect_contradiction"}"#;
717        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
718        assert!(matches!(parsed, RefinementPayload::DetectContradiction));
719    }
720
721    #[test]
722    fn refinement_payload_suggest_entity_with_name_hint() {
723        let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
724        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
725        match parsed {
726            RefinementPayload::SuggestEntity { ref name_hint } => {
727                assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
728            }
729            _ => panic!("expected SuggestEntity"),
730        }
731    }
732
733    #[test]
734    fn refinement_payload_suggest_entity_without_name_hint() {
735        let json = r#"{"action":"suggest_entity"}"#;
736        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
737        assert!(matches!(
738            parsed,
739            RefinementPayload::SuggestEntity { name_hint: None }
740        ));
741    }
742
743    #[test]
744    fn list_refinements_response_round_trip() {
745        let resp = ListRefinementsResponse {
746            proposals: vec![RefinementProposalSummary {
747                id: "ref_1".into(),
748                action: ProposalAction::EntityMerge,
749                source_ids: vec!["a".into(), "b".into()],
750                payload: Some(RefinementPayload::EntityMerge {
751                    existing_id: "a".into(),
752                    new_id: "b".into(),
753                    similarity: 0.86,
754                }),
755                confidence: 0.86,
756                created_at: "2026-05-12T00:00:00Z".into(),
757            }],
758        };
759        let json = serde_json::to_string(&resp).unwrap();
760        let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
761        assert_eq!(parsed.proposals.len(), 1);
762        assert_eq!(parsed.proposals[0].id, "ref_1");
763        assert!(matches!(
764            parsed.proposals[0].action,
765            ProposalAction::EntityMerge
766        ));
767    }
768
769    #[test]
770    fn reject_refinement_response_round_trip() {
771        let resp = RejectRefinementResponse { id: "ref_x".into() };
772        let json = serde_json::to_string(&resp).unwrap();
773        let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
774        assert_eq!(parsed.id, "ref_x");
775    }
776}
777
778/// One orphaned page link label aggregated across sources.
779///
780/// `count` is how many distinct source pages reference this label
781/// without a matching target page existing.
782#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
783pub struct OrphanLink {
784    pub label: String,
785    pub count: i64,
786}
787
788/// Response for `GET /api/pages/orphan-links`.
789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
790pub struct OrphanLinksResponse {
791    pub min_count: usize,
792    pub orphan_labels: Vec<OrphanLink>,
793}
794
795/// One pending revision awaiting human accept/dismiss.
796///
797/// `target_source_id` is the memory being revised; pass it to
798/// `accept_pending_revision` or `dismiss_pending_revision`.
799/// `revision_source_id` is the staged revision row itself, exposed
800/// for diagnostics and round-tripping (not for the action call).
801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
802pub struct PendingRevisionItem {
803    pub target_source_id: String,
804    pub revision_source_id: String,
805    pub revision_content: String,
806    pub source_agent: Option<String>,
807    pub last_modified: i64,
808}
809
810/// Response returned by `POST /api/memory/revision/{id}/accept`.
811/// Carries the now-consumed revision row id so agents can correlate with
812/// their `list_pending_revisions` cache.
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814pub struct RevisionAcceptResponse {
815    pub target_source_id: String,
816    pub revision_source_id: String,
817    pub wrote: bool,
818}
819
820/// Response returned by `POST /api/memory/revision/{id}/dismiss`.
821/// `wrote: true` always (404 on missing).
822#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
823pub struct RevisionDismissResponse {
824    pub target_source_id: String,
825    pub wrote: bool,
826}
827
828/// Response returned by `POST /api/memory/contradiction/{source_id}/dismiss`.
829/// `wrote: true` is best-effort: the daemon's underlying DB method silently
830/// no-ops when no rows match. Wrapper cannot distinguish dismiss-of-existing
831/// from dismiss-of-nothing without an extra SELECT (out of scope).
832#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
833pub struct ContradictionDismissResponse {
834    pub source_id: String,
835    pub wrote: bool,
836}
837
838#[cfg(test)]
839mod mutation_response_tests {
840    use super::*;
841
842    #[test]
843    fn revision_accept_response_serializes_byte_identical() {
844        let r = RevisionAcceptResponse {
845            target_source_id: "mem_target".into(),
846            revision_source_id: "mem_rev".into(),
847            wrote: true,
848        };
849        assert_eq!(
850            serde_json::to_string(&r).unwrap(),
851            r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
852        );
853    }
854
855    #[test]
856    fn revision_dismiss_response_serializes_byte_identical() {
857        let r = RevisionDismissResponse {
858            target_source_id: "mem_target".into(),
859            wrote: true,
860        };
861        assert_eq!(
862            serde_json::to_string(&r).unwrap(),
863            r#"{"target_source_id":"mem_target","wrote":true}"#
864        );
865    }
866
867    #[test]
868    fn contradiction_dismiss_response_serializes_byte_identical() {
869        let r = ContradictionDismissResponse {
870            source_id: "mem_abc".into(),
871            wrote: true,
872        };
873        assert_eq!(
874            serde_json::to_string(&r).unwrap(),
875            r#"{"source_id":"mem_abc","wrote":true}"#
876        );
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    #[test]
885    fn store_memory_response_deserializes_without_extraction_method() {
886        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
887        let json = r#"{
888            "source_id": "mem_abc",
889            "chunks_created": 3,
890            "memory_type": "fact"
891        }"#;
892        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
893        assert_eq!(parsed.source_id, "mem_abc");
894        assert_eq!(parsed.chunks_created, 3);
895        assert_eq!(parsed.memory_type, "fact");
896        assert_eq!(parsed.extraction_method, "unknown");
897        assert!(parsed.warnings.is_empty());
898    }
899
900    #[test]
901    fn store_memory_response_deserializes_with_all_fields() {
902        let json = r#"{
903            "source_id": "mem_abc",
904            "chunks_created": 3,
905            "memory_type": "fact",
906            "warnings": ["decision memory missing claim"],
907            "extraction_method": "llm"
908        }"#;
909        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
910        assert_eq!(parsed.warnings.len(), 1);
911        assert_eq!(parsed.extraction_method, "llm");
912    }
913
914    #[test]
915    fn store_memory_response_exposes_enrichment_and_hint() {
916        // Post-async-refactor shape: the daemon returns immediately after
917        // upsert and reports deferred enrichment via `enrichment` + `hint`.
918        let json = r#"{
919            "source_id": "mem_xyz",
920            "chunks_created": 1,
921            "memory_type": "fact",
922            "warnings": [],
923            "extraction_method": "unknown",
924            "enrichment": "pending",
925            "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
926        }"#;
927        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
928        assert_eq!(parsed.enrichment, "pending");
929        assert!(parsed.hint.contains("compiling"));
930    }
931
932    #[test]
933    fn store_memory_response_defaults_enrichment_for_older_responses() {
934        // Backward-compat: existing clients (origin-mcp, Tauri app) that
935        // deserialize pre-async-refactor responses must keep working.
936        let json = r#"{
937            "source_id": "mem_old",
938            "chunks_created": 1,
939            "memory_type": "fact"
940        }"#;
941        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
942        assert_eq!(parsed.enrichment, ""); // default
943        assert_eq!(parsed.hint, ""); // default
944    }
945
946    #[test]
947    fn store_memory_response_roundtrips_not_needed_state() {
948        // Daemon reports `not_needed` when no LLM is available. Hint is empty
949        // (skip_serializing_if) so the JSON shrinks accordingly.
950        let response = StoreMemoryResponse {
951            source_id: "mem_no_llm".into(),
952            chunks_created: 1,
953            memory_type: "fact".into(),
954            entity_id: None,
955            quality: None,
956            warnings: vec![],
957            extraction_method: "none".into(),
958            enrichment: "not_needed".into(),
959            hint: String::new(),
960            triggered_revisions: vec![],
961            auto_superseded: vec![],
962        };
963        let json = serde_json::to_string(&response).unwrap();
964        assert!(json.contains("\"enrichment\":\"not_needed\""));
965        assert!(
966            !json.contains("\"hint\""),
967            "empty hint must be skipped on the wire, got: {json}"
968        );
969        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
970        assert_eq!(parsed.enrichment, "not_needed");
971        assert_eq!(parsed.hint, "");
972    }
973
974    #[test]
975    fn store_memory_response_triggered_revisions_serializes_when_non_empty() {
976        let r = StoreMemoryResponse {
977            source_id: "mem_new".into(),
978            chunks_created: 1,
979            memory_type: "fact".into(),
980            entity_id: None,
981            quality: None,
982            warnings: vec![],
983            extraction_method: "none".into(),
984            enrichment: "not_needed".into(),
985            hint: String::new(),
986            triggered_revisions: vec!["mem_target_abc".to_string()],
987            auto_superseded: vec![],
988        };
989        let json = serde_json::to_string(&r).unwrap();
990        assert!(
991            json.contains("\"triggered_revisions\":[\"mem_target_abc\"]"),
992            "triggered_revisions must appear in JSON when non-empty, got: {json}"
993        );
994    }
995
996    #[test]
997    fn store_memory_response_triggered_revisions_skips_when_empty() {
998        let r = StoreMemoryResponse {
999            source_id: "mem_new".into(),
1000            chunks_created: 1,
1001            memory_type: "fact".into(),
1002            entity_id: None,
1003            quality: None,
1004            warnings: vec![],
1005            extraction_method: "none".into(),
1006            enrichment: "not_needed".into(),
1007            hint: String::new(),
1008            triggered_revisions: vec![],
1009            auto_superseded: vec![],
1010        };
1011        let json = serde_json::to_string(&r).unwrap();
1012        assert!(
1013            !json.contains("triggered_revisions"),
1014            "triggered_revisions must be absent from JSON when empty, got: {json}"
1015        );
1016    }
1017
1018    #[test]
1019    fn store_memory_response_auto_superseded_serializes_when_non_empty() {
1020        let r = StoreMemoryResponse {
1021            source_id: "mem_new".into(),
1022            chunks_created: 1,
1023            memory_type: "fact".into(),
1024            entity_id: None,
1025            quality: None,
1026            warnings: vec![],
1027            extraction_method: "none".into(),
1028            enrichment: "not_needed".into(),
1029            hint: String::new(),
1030            triggered_revisions: vec![],
1031            auto_superseded: vec!["mem_old_abc".to_string()],
1032        };
1033        let json = serde_json::to_string(&r).unwrap();
1034        assert!(
1035            json.contains("\"auto_superseded\":[\"mem_old_abc\"]"),
1036            "auto_superseded must appear in JSON when non-empty, got: {json}"
1037        );
1038    }
1039
1040    #[test]
1041    fn store_memory_response_auto_superseded_skips_when_empty() {
1042        let r = StoreMemoryResponse {
1043            source_id: "mem_new".into(),
1044            chunks_created: 1,
1045            memory_type: "fact".into(),
1046            entity_id: None,
1047            quality: None,
1048            warnings: vec![],
1049            extraction_method: "none".into(),
1050            enrichment: "not_needed".into(),
1051            hint: String::new(),
1052            triggered_revisions: vec![],
1053            auto_superseded: vec![],
1054        };
1055        let json = serde_json::to_string(&r).unwrap();
1056        assert!(
1057            !json.contains("auto_superseded"),
1058            "auto_superseded must be absent from JSON when empty, got: {json}"
1059        );
1060    }
1061
1062    #[test]
1063    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1064        // ProfileContext.goals is deprecated; constructing it directly here
1065        // for wire roundtrip coverage until 0.4 drops the field entirely.
1066        #[allow(deprecated)]
1067        let profile = ProfileContext {
1068            narrative: "n".into(),
1069            identity: vec![],
1070            preferences: vec![],
1071            goals: vec![],
1072        };
1073        let response = ChatContextResponse {
1074            context: "context".into(),
1075            profile,
1076            knowledge: KnowledgeContext {
1077                pages: vec![],
1078                decisions: vec![],
1079                relevant_memories: vec![],
1080                graph_context: vec![],
1081            },
1082            took_ms: 1.0,
1083            token_estimates: TierTokenEstimates {
1084                tier1_identity: 1,
1085                tier2_project: 2,
1086                tier3_relevant: 3,
1087                total: 6,
1088            },
1089        };
1090
1091        let json = serde_json::to_string(&response).unwrap();
1092        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1093        assert!(parsed.knowledge.pages.is_empty());
1094        assert!(parsed.knowledge.decisions.is_empty());
1095        assert!(parsed.knowledge.relevant_memories.is_empty());
1096        assert!(parsed.knowledge.graph_context.is_empty());
1097    }
1098
1099    #[test]
1100    fn orphan_links_response_golden_string() {
1101        let resp = OrphanLinksResponse {
1102            min_count: 2,
1103            orphan_labels: vec![OrphanLink {
1104                label: "Rust".to_string(),
1105                count: 3,
1106            }],
1107        };
1108        let s = serde_json::to_string(&resp).unwrap();
1109        assert_eq!(
1110            s,
1111            r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1112        );
1113    }
1114
1115    #[test]
1116    fn orphan_links_response_empty_round_trip() {
1117        let resp = OrphanLinksResponse {
1118            min_count: 1,
1119            orphan_labels: vec![],
1120        };
1121        let decoded: OrphanLinksResponse =
1122            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1123        assert_eq!(decoded, resp);
1124    }
1125
1126    #[test]
1127    fn pending_revision_item_round_trip() {
1128        let item = PendingRevisionItem {
1129            target_source_id: "mem_target".into(),
1130            revision_source_id: "mem_rev".into(),
1131            revision_content: "new body".into(),
1132            source_agent: Some("claude-code".into()),
1133            last_modified: 1_715_000_000,
1134        };
1135        let json = serde_json::to_value(&item).unwrap();
1136        assert_eq!(json["target_source_id"], "mem_target");
1137        assert_eq!(json["revision_source_id"], "mem_rev");
1138        assert_eq!(json["revision_content"], "new body");
1139        let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1140        assert_eq!(decoded, item);
1141    }
1142}
1143
1144#[cfg(test)]
1145mod search_memory_response_tests {
1146    use super::SearchMemoryResponse;
1147
1148    /// Old daemon responses (no `supplemental_pages` key) must deserialize
1149    /// successfully with `supplemental_pages == None`.  This locks in the
1150    /// back-compat guarantee: clients talking to an older daemon never see a
1151    /// deserialization error.
1152    #[test]
1153    fn back_compat_missing_supplemental_pages_is_none() {
1154        let json = r#"{"results":[],"took_ms":1.0}"#;
1155        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1156        assert!(
1157            resp.supplemental_pages.is_none(),
1158            "should be None when key absent"
1159        );
1160        assert_eq!(resp.took_ms, 1.0);
1161    }
1162
1163    /// `supplemental_pages` absent means the field is omitted on the wire
1164    /// (skip_serializing_if = "Option::is_none").
1165    #[test]
1166    fn none_supplemental_pages_not_serialized() {
1167        let resp = SearchMemoryResponse {
1168            results: vec![],
1169            took_ms: 2.0,
1170            supplemental_pages: None,
1171        };
1172        let json = serde_json::to_string(&resp).expect("serialize");
1173        assert!(
1174            !json.contains("supplemental_pages"),
1175            "None field must be omitted from wire: {}",
1176            json
1177        );
1178    }
1179
1180    /// When pages are present they round-trip correctly.
1181    #[test]
1182    fn some_supplemental_pages_round_trips() {
1183        let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1184        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1185        assert!(
1186            resp.supplemental_pages.is_some(),
1187            "supplemental_pages should be Some"
1188        );
1189        assert!(
1190            resp.supplemental_pages.unwrap().is_empty(),
1191            "empty array should deserialize to empty vec"
1192        );
1193    }
1194}