Skip to main content

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