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