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