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 crate::repair::RepairDigest;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11// ===== Memory CRUD =====
12
13#[derive(Debug, Serialize, Deserialize)]
14pub struct StoreMemoryResponse {
15    pub source_id: String,
16    pub chunks_created: usize,
17    /// Memory type at the moment of persistence. If caller did not supply
18    /// one and enrichment is pending, this is a placeholder (`"fact"`) —
19    /// check `enrichment` field to know whether to expect it to change.
20    pub memory_type: String,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub entity_id: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub quality: Option<String>,
25    /// Schema-validation issues — actionable by the agent.
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub warnings: Vec<String>,
28    /// How structured fields were populated. "agent" | "llm" | "none" | "unknown" (forward-compat default).
29    #[serde(default = "default_extraction_method")]
30    pub extraction_method: String,
31    /// Enrichment state for the memory. `"pending"` when background
32    /// classification + entity extraction + concept linking will run;
33    /// `"not_needed"` when no LLM is available and the memory stays as
34    /// caller-supplied. Machine-readable — Tauri app uses this to drive
35    /// polling / live-update UI, MCP callers can choose to relay state.
36    /// Defaulted for backward compatibility with pre-async-enrichment clients.
37    #[serde(default)]
38    pub enrichment: String,
39    /// Prose cue for caller agents — safe to relay to the user verbatim.
40    /// Communicates that Wenlan is compiling the memory into reusable
41    /// context in the background, so callers don't treat `None` enriched
42    /// fields as failure. Empty when the store completed fully sync.
43    #[serde(default, skip_serializing_if = "String::is_empty")]
44    pub hint: String,
45}
46
47fn default_extraction_method() -> String {
48    "unknown".to_string()
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52pub struct SearchMemoryResponse {
53    pub results: Vec<SearchResult>,
54    pub took_ms: f64,
55    /// Distilled pages surfaced by the page channel during reranked search.
56    /// Absent when no page rows were returned (back-compat: old daemons never
57    /// set this field; old consumers that don't read it are unaffected).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub supplemental_pages: Option<Vec<SearchResult>>,
60}
61
62#[derive(Debug, Serialize, Deserialize)]
63pub struct ListMemoriesResponse {
64    pub memories: Vec<IndexedFileInfo>,
65}
66
67/// Shared wire format for any `deleted: bool` response.
68///
69/// Reused by:
70/// - `DELETE /api/memory/delete/{id}` (server/memory.rs)
71/// - `DELETE /api/documents/{source}/{source_id}` (server/ingest.rs)
72#[derive(Debug, Serialize, Deserialize)]
73pub struct DeleteResponse {
74    pub deleted: bool,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78pub struct ConfirmResponse {
79    pub confirmed: bool,
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83pub struct ReclassifyMemoryResponse {
84    pub source_id: String,
85    pub memory_type: String,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct MemoryStatsResponse {
90    pub stats: MemoryStats,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct NurtureCardsResponse {
95    pub cards: Vec<MemoryItem>,
96}
97
98// ===== General search/context =====
99
100#[derive(Debug, Serialize, Deserialize)]
101pub struct HealthResponse {
102    pub status: String,
103    pub db_initialized: bool,
104    pub version: String,
105}
106
107/// Cross-encoder reranker state, surfaced on `/api/status` so operators can see
108/// whether an opt-in reranker is actually wired vs. silently degraded.
109#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110#[serde(tag = "state", rename_all = "snake_case")]
111pub enum RerankerStatus {
112    /// `WENLAN_RERANKER_ENABLED` was not `1` — no reranker requested.
113    #[default]
114    Disabled,
115    /// Reranker initialized and wired.
116    Active { model_id: String },
117    /// Reranker was requested but init failed (e.g. model download error);
118    /// search silently falls back to embedding+FTS ordering.
119    Failed { reason: String },
120}
121
122/// Background document-enrichment queue state, surfaced on `/api/status` so
123/// operators can see whether folder-ingest enrichment is progressing, idle, or
124/// paused on an LLM failure (waiting for a backoff retry). Mirrors the
125/// [`RerankerStatus`] tagged-enum shape.
126#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127#[serde(tag = "state", rename_all = "snake_case")]
128pub enum QueueStatus {
129    /// No documents are queued for enrichment (nothing pending, in-progress, or paused).
130    #[default]
131    Idle,
132    /// Documents are queued or being enriched. `pending` counts rows not yet done.
133    Active { pending: u64 },
134    /// At least one document is paused after an LLM failure. `reason` is that
135    /// pause's failure reason, `next_retry_at` the earliest Unix-secs retry time
136    /// (the scheduler auto-resumes once it elapses — no daemon restart needed),
137    /// and `pending` counts rows not yet done.
138    Paused {
139        reason: String,
140        pending: u64,
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        next_retry_at: Option<i64>,
143    },
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147pub struct StatusResponse {
148    pub is_running: bool,
149    pub files_indexed: u64,
150    pub files_total: u64,
151    pub sources_connected: Vec<String>,
152    /// Background document-enrichment queue state (folder-ingest). Additive:
153    /// defaults to `Idle` so older daemons (which omit it) deserialize cleanly.
154    #[serde(default)]
155    pub queue: QueueStatus,
156    /// Compile-routing queue depth (spec §3.1/§7): clusters the last routed
157    /// compile left pending because no lane (cloud or healthy on-device) was
158    /// available. `Active { pending }` when nonzero, `Idle` otherwise; never
159    /// `Paused` (no retry/backoff concept for this gauge). Additive: defaults
160    /// to `Idle` so older daemons (which omit it) deserialize cleanly.
161    #[serde(default)]
162    pub compile_queue: QueueStatus,
163    /// Reranker on the DEEP path (`/api/memory/search` with `rerank=true`). Legacy
164    /// field — for `WENLAN_RERANKER_ENABLED=1` it is the configured model, exactly as before.
165    #[serde(default)]
166    pub reranker: RerankerStatus,
167    /// Reranker on the LIGHT paths — quick (`/api/search`) + context
168    /// (`/api/context`). Populated when `WENLAN_RERANKER_MODE` is `lite`/`full`.
169    /// Additive: defaults to `Disabled` so older daemons (which omit it) deserialize cleanly.
170    #[serde(default)]
171    pub reranker_light: RerankerStatus,
172    /// Resolved reranker mode: `"off"` | `"lite"` | `"full"`. Empty string for older
173    /// daemons that predate `WENLAN_RERANKER_MODE`.
174    #[serde(default)]
175    pub reranker_mode: String,
176}
177
178#[derive(Debug, Serialize, Deserialize)]
179pub struct SearchResponse {
180    pub results: Vec<SearchResult>,
181    pub took_ms: f64,
182    /// Distilled pages surfaced by the shared visibility gate on `/api/search`.
183    /// Absent when no page rows passed the space-scope + effective-tier gate
184    /// (back-compat: old daemons never set this field; old consumers that
185    /// don't read it are unaffected). Mirrors `SearchMemoryResponse`.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub supplemental_pages: Option<Vec<SearchResult>>,
188}
189
190#[doc(hidden)]
191#[derive(Debug, Serialize, Deserialize)]
192pub struct ContextSuggestion {
193    pub content: String,
194    pub score: f32,
195    pub source: String,
196}
197
198#[doc(hidden)]
199#[derive(Debug, Serialize, Deserialize)]
200pub struct ContextResponse {
201    pub suggestions: Vec<ContextSuggestion>,
202    pub took_ms: f64,
203}
204
205#[derive(Debug, Default, Serialize, Deserialize)]
206pub struct TierTokenEstimates {
207    pub tier1_identity: usize,
208    pub tier2_project: usize,
209    pub tier3_relevant: usize,
210    pub total: usize,
211}
212
213#[derive(Debug, Serialize, Deserialize)]
214pub struct ProfileContext {
215    pub narrative: String,
216    pub identity: Vec<String>,
217    pub preferences: Vec<String>,
218    /// Deprecated: goal taxonomy folded into Identity by migration 45 (Phase 0).
219    /// Always empty — daemon does not emit goal-typed memories. Field stays for
220    /// wire backward compat; will be removed in 0.4.
221    #[deprecated(
222        since = "0.3.2",
223        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
224                Always empty. Will be removed in 0.4."
225    )]
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub goals: Vec<String>,
228}
229
230#[derive(Debug, Serialize, Deserialize)]
231pub struct KnowledgeContext {
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub pages: Vec<String>,
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub decisions: Vec<String>,
236    #[serde(default)]
237    pub relevant_memories: Vec<SearchResult>,
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub graph_context: Vec<String>,
240}
241
242#[derive(Debug, Serialize, Deserialize)]
243pub struct ChatContextResponse {
244    pub context: String,
245    pub profile: ProfileContext,
246    pub knowledge: KnowledgeContext,
247    pub took_ms: f64,
248    pub token_estimates: TierTokenEstimates,
249}
250
251// ===== Profile & Agents =====
252
253#[derive(Debug, Serialize, Deserialize)]
254pub struct ProfileResponse {
255    pub id: String,
256    pub name: String,
257    pub display_name: Option<String>,
258    pub email: Option<String>,
259    pub bio: Option<String>,
260    pub avatar_path: Option<String>,
261    pub created_at: i64,
262    pub updated_at: i64,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
266pub struct AgentResponse {
267    pub id: String,
268    pub name: String,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub display_name: Option<String>,
271    pub agent_type: String,
272    pub description: Option<String>,
273    pub enabled: bool,
274    pub trust_level: String,
275    pub last_seen_at: Option<i64>,
276    pub memory_count: i64,
277    pub created_at: i64,
278    pub updated_at: i64,
279}
280
281// ===== Knowledge graph =====
282
283#[derive(Debug, Serialize, Deserialize)]
284pub struct CreateEntityResponse {
285    pub id: String,
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub warnings: Vec<String>,
288}
289
290#[doc(hidden)]
291#[derive(Debug, Serialize, Deserialize)]
292pub struct CreateRelationResponse {
293    pub id: String,
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub warnings: Vec<String>,
296}
297
298#[derive(Debug, Serialize, Deserialize)]
299pub struct AddObservationResponse {
300    pub id: String,
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub warnings: Vec<String>,
303}
304
305#[doc(hidden)]
306#[derive(Debug, Serialize, Deserialize)]
307pub struct CreatePageResponse {
308    pub id: String,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub attached_to: Option<String>,
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub warnings: Vec<String>,
313}
314
315#[derive(Debug, Serialize, Deserialize)]
316pub struct ListEntitiesResponse {
317    pub entities: Vec<Entity>,
318}
319
320#[derive(Debug, Serialize, Deserialize)]
321pub struct SearchEntitiesResponse {
322    pub results: Vec<EntitySearchResult>,
323}
324
325#[derive(Debug, Serialize, Deserialize)]
326pub struct SearchPagesResponse {
327    pub pages: Vec<Page>,
328}
329
330/// Wikilink graph centered on a single page. Outbound = labels parsed
331/// out of this page's body; `target_page_id` is `None` for orphans.
332/// Inbound = active pages whose body cites this title.
333#[derive(Debug, Serialize, Deserialize)]
334pub struct PageLinksResponse {
335    pub outbound: Vec<PageLinkOutbound>,
336    pub inbound: Vec<PageLinkInbound>,
337}
338
339#[derive(Debug, Serialize, Deserialize)]
340pub struct PageLinkOutbound {
341    pub label: String,
342    /// `None` when the resolver couldn't find a matching active page —
343    /// surfaces in the orphan-by-count feed via /api/pages/orphan-links.
344    pub target_page_id: Option<String>,
345}
346
347#[derive(Debug, Serialize, Deserialize)]
348pub struct PageLinkInbound {
349    pub source_page_id: String,
350    pub label: String,
351}
352
353// ===== Import =====
354
355#[derive(Debug, Serialize, Deserialize)]
356pub struct ImportMemoriesResponse {
357    pub imported: usize,
358    pub skipped: usize,
359    pub breakdown: HashMap<String, usize>,
360    pub entities_created: usize,
361    pub observations_added: usize,
362    pub relations_created: usize,
363    pub batch_id: String,
364}
365
366// ===== Steep =====
367
368/// How loud Wenlan should be about a phase's output.
369#[doc(hidden)]
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371pub enum Nudge {
372    Silent,
373    Ambient,
374    Notable,
375    Wow,
376}
377
378/// Result of a single phase within a steep cycle.
379#[doc(hidden)]
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PhaseResult {
382    pub name: String,
383    pub duration_ms: u64,
384    pub items_processed: usize,
385    pub error: Option<String>,
386    pub nudge: Nudge,
387    pub headline: Option<String>,
388}
389
390#[doc(hidden)]
391#[derive(Debug, Serialize, Deserialize)]
392pub struct SteepResponse {
393    pub memories_decayed: u64,
394    pub recaps_generated: u32,
395    pub distilled: u32,
396    pub pending_remaining: u32,
397    pub phases: Vec<PhaseResult>,
398}
399
400// ===== Config =====
401
402#[derive(Debug, Serialize, Deserialize)]
403pub struct ConfigResponse {
404    pub skip_apps: Vec<String>,
405    pub skip_title_patterns: Vec<String>,
406    pub private_browsing_detection: bool,
407    pub setup_completed: bool,
408    pub clipboard_enabled: bool,
409    pub screen_capture_enabled: bool,
410    pub remote_access_enabled: bool,
411    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub routine_model: Option<String>,
414    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub synthesis_model: Option<String>,
417    /// Base URL for an OpenAI-compatible external LLM endpoint.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub external_llm_endpoint: Option<String>,
420    /// Model identifier to use with the external LLM endpoint.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub external_llm_model: Option<String>,
423    /// Whether an external-LLM API key is stored. The key value itself is
424    /// never serialized anywhere.
425    #[serde(default)]
426    pub external_llm_api_key_configured: bool,
427    /// Everyday-job source pin (raw config value): `"anthropic"` | `"external"`
428    /// | `"on_device"`, or absent/null when unpinned. Lets the app show the
429    /// pin the user chose distinctly from the resolved source.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub everyday_source: Option<String>,
432    /// Synthesis-job source pin (raw config value): `"anthropic"` |
433    /// `"external"`, or absent/null when unpinned.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub synthesis_source: Option<String>,
436    /// Whether the proactive Page-Map suggestion phase is enabled. Default true.
437    #[serde(default)]
438    pub page_map_auto_suggest: bool,
439}
440
441// ===== On-device model =====
442
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444pub struct OnDeviceModelEntry {
445    pub id: String,
446    pub display_name: String,
447    pub param_count: String,
448    pub ram_required_gb: f64,
449    pub file_size_gb: f64,
450    pub cached: bool,
451}
452
453/// Response envelope for `GET /api/on-device-model`.
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct OnDeviceModelResponse {
456    /// ID of the model currently loaded in the daemon, if any.
457    pub loaded: Option<String>,
458    /// ID the user has selected in config; may differ from loaded when a
459    /// download is pending or a restart is needed.
460    pub selected: Option<String>,
461    /// All available models with per-model cache/download state.
462    pub models: Vec<OnDeviceModelEntry>,
463}
464
465// ===== Indexed files / chunks =====
466
467#[derive(Debug, Serialize, Deserialize)]
468pub struct IndexedFilesResponse {
469    pub files: Vec<IndexedFileInfo>,
470}
471
472#[derive(Debug, Serialize, Deserialize)]
473pub struct DeleteCountResponse {
474    pub deleted: usize,
475}
476
477// ===== Entity / Observation =====
478
479#[derive(Debug, Serialize, Deserialize)]
480pub struct SuccessResponse {
481    pub ok: bool,
482}
483
484fn is_false(value: &bool) -> bool {
485    !*value
486}
487
488#[derive(Debug, Serialize, Deserialize)]
489pub struct PageWriteResponse {
490    pub ok: bool,
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub revision_card_id: Option<String>,
493    #[serde(default, skip_serializing_if = "is_false")]
494    pub gated: bool,
495}
496
497/// Page draft create, update, and publish response envelope.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct PageDraftResponse {
500    pub page: Page,
501}
502
503// ===== Memory detail =====
504
505#[derive(Debug, Serialize, Deserialize)]
506pub struct MemoryDetailResponse {
507    pub memory: Option<MemoryItem>,
508}
509
510/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct MemoryDetail {
513    pub id: String,
514    pub content: String,
515    pub title: String,
516    pub source_id: String,
517    pub chunk_index: i32,
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub chunk_type: Option<String>,
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub language: Option<String>,
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub semantic_unit: Option<String>,
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub byte_start: Option<i64>,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub byte_end: Option<i64>,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub summary: Option<String>,
530}
531
532/// A pending revision waiting for human approval (Protected tier supersede).
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct PendingRevision {
535    pub source_id: String,
536    pub content: String,
537    pub source_agent: Option<String>,
538}
539
540#[derive(Debug, Serialize, Deserialize)]
541pub struct VersionChainResponse {
542    pub versions: Vec<crate::memory::MemoryVersionItem>,
543}
544
545// ===== Tags =====
546
547#[derive(Debug, Serialize, Deserialize)]
548pub struct TagsResponse {
549    pub tags: Vec<String>,
550    #[serde(default)]
551    pub document_tags: HashMap<String, Vec<String>>,
552}
553
554// ===== Activity =====
555
556#[derive(Debug, Serialize, Deserialize)]
557pub struct ActivityResponse {
558    pub activities: Vec<crate::memory::AgentActivityRow>,
559}
560
561// ===== Decisions =====
562
563#[derive(Debug, Serialize, Deserialize)]
564pub struct DecisionsResponse {
565    pub decisions: Vec<MemoryItem>,
566}
567
568#[derive(Debug, Serialize, Deserialize)]
569pub struct DecisionDomainsResponse {
570    /// Kept as `domains` for one-release back-compat with callers of
571    /// `/api/decisions/domains`; rename to `spaces` in PR-A+1.
572    pub domains: Vec<String>,
573}
574
575// ===== Pinned =====
576
577#[derive(Debug, Serialize, Deserialize)]
578pub struct PinnedMemoriesResponse {
579    pub memories: Vec<MemoryItem>,
580}
581
582// ===== Ingest =====
583
584#[derive(Debug, Serialize, Deserialize)]
585pub struct IngestResponse {
586    pub chunks_created: usize,
587    pub document_id: String,
588}
589
590// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
591// `DeleteResponse { deleted: bool }` defined above — same wire format.
592
593// ===== Concept Export =====
594
595/// Statistics from a bulk page export operation (POST /api/pages/export).
596#[derive(Debug, Default, Serialize, Deserialize)]
597pub struct ExportStats {
598    pub exported: usize,
599    pub skipped: usize,
600    pub failed: usize,
601}
602
603#[derive(Debug, Deserialize, Serialize)]
604pub struct ExportPageResponse {
605    pub path: String,
606}
607
608// ===== Knowledge Directory =====
609
610#[derive(Debug, Deserialize, Serialize)]
611pub struct KnowledgePathResponse {
612    pub path: String,
613}
614
615#[derive(Debug, Deserialize, Serialize)]
616pub struct KnowledgeCountResponse {
617    pub count: u64,
618}
619
620// ===== Revision history =====
621
622/// One entry in a memory's supersede chain, returned by `/api/memory/{id}/revisions`.
623///
624/// `depth = 0` is the current (most-recent) memory; higher depths are older
625/// predecessors. `delta_summary` is `None` for the deepest entry (no predecessor
626/// to diff against) and computed heuristically for all shallower entries.
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct MemoryRevisionEntry {
629    pub source_id: String,
630    pub depth: i64,
631    pub title: String,
632    pub content_preview: String,
633    pub last_modified: i64,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub source_agent: Option<String>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub supersede_mode: Option<String>,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub delta_summary: Option<String>,
640}
641
642/// Response envelope for `/api/memory/{id}/revisions`.
643#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct ListMemoryRevisionsResponse {
645    pub current_source_id: String,
646    pub chain_depth: i64,
647    pub entries: Vec<MemoryRevisionEntry>,
648}
649
650/// One entry in a page's version changelog, returned by `/api/pages/{id}/revisions`.
651#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct PageChangelogEntry {
653    pub version: i64,
654    pub at: i64,
655    pub edited_by: String,
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub delta_summary: Option<String>,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub incoming_source_ids: Option<Vec<String>>,
660    /// Human-readable summary of citation verification for this revision
661    /// (e.g. "3 verified, 1 unverified, 2 stripped"). None for revisions that
662    /// didn't touch citations.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub citations_summary: Option<String>,
665}
666
667/// Response envelope for `/api/pages/{id}/revisions`.
668#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct ListPageRevisionsResponse {
670    pub page_id: String,
671    pub current_version: i64,
672    pub user_edited: bool,
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub stale_reason: Option<String>,
675    pub entries: Vec<PageChangelogEntry>,
676}
677
678// ===== Sources =====
679
680#[doc(hidden)]
681#[derive(Debug, Clone, Serialize, Deserialize)]
682pub struct SyncStatsResponse {
683    pub files_found: usize,
684    pub ingested: usize,
685    pub skipped: usize,
686    pub errors: usize,
687}
688
689// ===== Refinement proposals =====
690
691/// The action type for a background-refinery proposal.
692///
693/// Used as the `action` tag in [`RefinementPayload`].
694#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
695#[serde(rename_all = "snake_case")]
696pub enum ProposalAction {
697    EntityMerge,
698    RelationConflict,
699    DetectContradiction,
700    SuggestEntity,
701    DedupMerge,
702    PageMerge,
703    CrossSpaceDiscovery,
704    PageKeepOrArchive,
705    LintRepairReview,
706    VocabPromote,
707    /// ponytail: deserialize-only catch-all. Lets a stale client decode a
708    /// newer daemon's action tag instead of failing the whole list. NEVER
709    /// constructed or serialized by the daemon (it only ever holds real
710    /// variants), so serialize-of-Unknown never happens in practice.
711    #[serde(other)]
712    Unknown,
713}
714
715#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
716#[serde(rename_all = "snake_case")]
717pub enum RefinementCardAction {
718    Accept,
719    Dismiss,
720    PickSpace,
721}
722
723/// Tagged-union payload emitted by the background refinery.
724///
725/// Each variant carries exactly the fields needed for that action type.
726/// Decoded at the route boundary so downstream consumers (MCP wrappers,
727/// agent skills) can pattern-match instead of inspecting raw JSON strings.
728#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
729#[serde(tag = "action", rename_all = "snake_case")]
730pub enum RefinementPayload {
731    EntityMerge {
732        existing_id: String,
733        new_id: String,
734        similarity: f64,
735    },
736    RelationConflict {
737        existing_id: String,
738        new_id: String,
739        from: String,
740        to: String,
741        old_type: String,
742        new_type: String,
743    },
744    DetectContradiction,
745    SuggestEntity {
746        #[serde(default, skip_serializing_if = "Option::is_none")]
747        name_hint: Option<String>,
748    },
749    DedupMerge,
750    PageMerge {
751        left_page_id: String,
752        right_page_id: String,
753        #[serde(default, skip_serializing_if = "Option::is_none")]
754        similarity: Option<f64>,
755        source_overlap: usize,
756        source_overlap_ratio: f64,
757    },
758    CrossSpaceDiscovery {
759        memory_count: usize,
760        spaces: Vec<String>,
761        allowed_actions: Vec<RefinementCardAction>,
762    },
763    PageKeepOrArchive {
764        page_id: String,
765        source_count: usize,
766        allowed_actions: Vec<RefinementCardAction>,
767    },
768    LintRepairReview {
769        check_id: String,
770        occurrence_digest: RepairDigest,
771        owner_binding_digest: RepairDigest,
772        issue: String,
773        choices: Vec<String>,
774        suggested_research_queries: Vec<String>,
775    },
776    VocabPromote {
777        kind: String,
778        old_value: String,
779        #[serde(default, skip_serializing_if = "Option::is_none")]
780        category: Option<String>,
781    },
782}
783
784#[derive(Debug, Serialize, Deserialize, Clone)]
785pub struct RefinementProposalSummary {
786    pub id: String,
787    pub action: ProposalAction,
788    pub source_ids: Vec<String>,
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub payload: Option<RefinementPayload>,
791    pub confidence: f64,
792    pub created_at: String,
793}
794
795#[derive(Debug, Serialize, Deserialize, Clone, Default)]
796pub struct ListRefinementsResponse {
797    pub proposals: Vec<RefinementProposalSummary>,
798}
799
800#[derive(Debug, Serialize, Deserialize, Clone)]
801pub struct RejectRefinementResponse {
802    pub id: String,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct AcceptRefinementResponse {
807    pub id: String,
808    pub action_applied: String,
809}
810
811#[cfg(test)]
812mod refinement_wire_tests {
813    use super::*;
814
815    #[test]
816    fn proposal_action_serde_round_trip() {
817        let cases = [
818            ("\"entity_merge\"", ProposalAction::EntityMerge),
819            ("\"relation_conflict\"", ProposalAction::RelationConflict),
820            (
821                "\"detect_contradiction\"",
822                ProposalAction::DetectContradiction,
823            ),
824            ("\"suggest_entity\"", ProposalAction::SuggestEntity),
825            ("\"dedup_merge\"", ProposalAction::DedupMerge),
826            (
827                "\"cross_space_discovery\"",
828                ProposalAction::CrossSpaceDiscovery,
829            ),
830            (
831                "\"page_keep_or_archive\"",
832                ProposalAction::PageKeepOrArchive,
833            ),
834            ("\"vocab_promote\"", ProposalAction::VocabPromote),
835        ];
836        for (json, expected) in cases {
837            let parsed: ProposalAction = serde_json::from_str(json).unwrap();
838            assert_eq!(parsed, expected, "deserialize {json}");
839            let back = serde_json::to_string(&expected).unwrap();
840            assert_eq!(back, json, "serialize {expected:?}");
841        }
842    }
843
844    #[test]
845    fn vocab_promote_payload_round_trips() {
846        let p = RefinementPayload::VocabPromote {
847            kind: "relation".into(),
848            old_value: "design_inspiration".into(),
849            category: None,
850        };
851        let json = serde_json::to_string(&p).unwrap();
852        let back: RefinementPayload = serde_json::from_str(&json).unwrap();
853        assert_eq!(p, back);
854        assert!(json.contains("\"action\":\"vocab_promote\""));
855    }
856
857    #[test]
858    fn proposal_action_unknown_future_variant_deserializes() {
859        // A stale client must decode a NEWER daemon's action tag to Unknown,
860        // never error the whole ListRefinementsResponse.
861        let parsed: ProposalAction = serde_json::from_str("\"future_unshipped_action\"").unwrap();
862        assert_eq!(parsed, ProposalAction::Unknown);
863        let parsed2: ProposalAction = serde_json::from_str("\"totally_new_action\"").unwrap();
864        assert_eq!(parsed2, ProposalAction::Unknown);
865    }
866
867    #[test]
868    fn refinement_payload_entity_merge_round_trip() {
869        let json =
870            r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
871        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
872        match parsed {
873            RefinementPayload::EntityMerge {
874                ref existing_id,
875                ref new_id,
876                similarity,
877            } => {
878                assert_eq!(existing_id, "e1");
879                assert_eq!(new_id, "e2");
880                assert!((similarity - 0.87).abs() < 1e-9);
881            }
882            _ => panic!("expected EntityMerge variant"),
883        }
884        let back = serde_json::to_value(&parsed).unwrap();
885        assert_eq!(back["action"], "entity_merge");
886        assert_eq!(back["existing_id"], "e1");
887    }
888
889    #[test]
890    fn refinement_payload_dedup_merge_no_fields() {
891        let json = r#"{"action":"dedup_merge"}"#;
892        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
893        assert!(matches!(parsed, RefinementPayload::DedupMerge));
894    }
895
896    #[test]
897    fn refinement_payload_cross_space_discovery_round_trip() {
898        let json = r#"{"action":"cross_space_discovery","memory_count":3,"spaces":["personal","work"],"allowed_actions":["dismiss","pick_space"]}"#;
899        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
900        match parsed {
901            RefinementPayload::CrossSpaceDiscovery {
902                memory_count,
903                ref spaces,
904                ref allowed_actions,
905            } => {
906                assert_eq!(memory_count, 3);
907                assert_eq!(spaces, &vec!["personal".to_string(), "work".to_string()]);
908                assert_eq!(
909                    allowed_actions,
910                    &vec![
911                        RefinementCardAction::Dismiss,
912                        RefinementCardAction::PickSpace
913                    ]
914                );
915            }
916            _ => panic!("expected CrossSpaceDiscovery"),
917        }
918        let back = serde_json::to_value(&parsed).unwrap();
919        assert_eq!(back["action"], "cross_space_discovery");
920        assert_eq!(back["memory_count"], 3);
921    }
922
923    #[test]
924    fn refinement_payload_page_keep_or_archive_round_trip() {
925        let json = r#"{"action":"page_keep_or_archive","page_id":"page_stub","source_count":1,"allowed_actions":["dismiss","accept"]}"#;
926        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
927        match parsed {
928            RefinementPayload::PageKeepOrArchive {
929                ref page_id,
930                source_count,
931                ref allowed_actions,
932            } => {
933                assert_eq!(page_id, "page_stub");
934                assert_eq!(source_count, 1);
935                assert_eq!(
936                    allowed_actions,
937                    &vec![RefinementCardAction::Dismiss, RefinementCardAction::Accept]
938                );
939            }
940            _ => panic!("expected PageKeepOrArchive"),
941        }
942        let back = serde_json::to_value(&parsed).unwrap();
943        assert_eq!(back["action"], "page_keep_or_archive");
944        assert_eq!(back["source_count"], 1);
945    }
946
947    #[test]
948    fn refinement_payload_relation_conflict_round_trip() {
949        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"}"#;
950        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
951        match parsed {
952            RefinementPayload::RelationConflict {
953                ref existing_id,
954                ref new_id,
955                ref from,
956                ref to,
957                ref old_type,
958                ref new_type,
959            } => {
960                assert_eq!(existing_id, "r1");
961                assert_eq!(new_id, "r2");
962                assert_eq!(from, "e_a");
963                assert_eq!(to, "e_b");
964                assert_eq!(old_type, "works_at");
965                assert_eq!(new_type, "founded");
966            }
967            _ => panic!("expected RelationConflict"),
968        }
969        let back = serde_json::to_value(&parsed).unwrap();
970        assert_eq!(back["from"], "e_a");
971        assert_eq!(back["to"], "e_b");
972    }
973
974    #[test]
975    fn refinement_payload_detect_contradiction_unit_variant() {
976        let json = r#"{"action":"detect_contradiction"}"#;
977        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
978        assert!(matches!(parsed, RefinementPayload::DetectContradiction));
979    }
980
981    #[test]
982    fn refinement_payload_suggest_entity_with_name_hint() {
983        let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
984        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
985        match parsed {
986            RefinementPayload::SuggestEntity { ref name_hint } => {
987                assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
988            }
989            _ => panic!("expected SuggestEntity"),
990        }
991    }
992
993    #[test]
994    fn refinement_payload_suggest_entity_without_name_hint() {
995        let json = r#"{"action":"suggest_entity"}"#;
996        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
997        assert!(matches!(
998            parsed,
999            RefinementPayload::SuggestEntity { name_hint: None }
1000        ));
1001    }
1002
1003    #[test]
1004    fn list_refinements_response_round_trip() {
1005        let resp = ListRefinementsResponse {
1006            proposals: vec![RefinementProposalSummary {
1007                id: "ref_1".into(),
1008                action: ProposalAction::EntityMerge,
1009                source_ids: vec!["a".into(), "b".into()],
1010                payload: Some(RefinementPayload::EntityMerge {
1011                    existing_id: "a".into(),
1012                    new_id: "b".into(),
1013                    similarity: 0.86,
1014                }),
1015                confidence: 0.86,
1016                created_at: "2026-05-12T00:00:00Z".into(),
1017            }],
1018        };
1019        let json = serde_json::to_string(&resp).unwrap();
1020        let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
1021        assert_eq!(parsed.proposals.len(), 1);
1022        assert_eq!(parsed.proposals[0].id, "ref_1");
1023        assert!(matches!(
1024            parsed.proposals[0].action,
1025            ProposalAction::EntityMerge
1026        ));
1027    }
1028
1029    #[test]
1030    fn reject_refinement_response_round_trip() {
1031        let resp = RejectRefinementResponse { id: "ref_x".into() };
1032        let json = serde_json::to_string(&resp).unwrap();
1033        let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
1034        assert_eq!(parsed.id, "ref_x");
1035    }
1036}
1037
1038#[cfg(test)]
1039mod on_device_model_response_tests {
1040    use super::*;
1041
1042    #[test]
1043    fn on_device_model_response_preserves_selected_loaded_and_models() {
1044        let response = OnDeviceModelResponse {
1045            loaded: Some("qwen3-4b".to_string()),
1046            selected: Some("qwen3-4b".to_string()),
1047            models: vec![OnDeviceModelEntry {
1048                id: "qwen3-4b".to_string(),
1049                display_name: "Qwen3 4B".to_string(),
1050                param_count: "4B".to_string(),
1051                ram_required_gb: 6.0,
1052                file_size_gb: 2.7,
1053                cached: true,
1054            }],
1055        };
1056
1057        let value = serde_json::to_value(&response).unwrap();
1058
1059        assert_eq!(value["loaded"], "qwen3-4b");
1060        assert_eq!(value["selected"], "qwen3-4b");
1061        assert_eq!(value["models"][0]["id"], "qwen3-4b");
1062        assert_eq!(value["models"][0]["cached"], true);
1063
1064        let parsed: OnDeviceModelResponse = serde_json::from_value(value).unwrap();
1065        assert_eq!(parsed.loaded.as_deref(), Some("qwen3-4b"));
1066        assert_eq!(parsed.selected.as_deref(), Some("qwen3-4b"));
1067        assert_eq!(parsed.models.len(), 1);
1068        assert!(parsed.models[0].cached);
1069    }
1070
1071    #[test]
1072    fn on_device_model_response_allows_null_loaded_and_selected() {
1073        let parsed: OnDeviceModelResponse =
1074            serde_json::from_str(r#"{"loaded":null,"selected":null,"models":[]}"#).unwrap();
1075
1076        assert!(parsed.loaded.is_none());
1077        assert!(parsed.selected.is_none());
1078        assert!(parsed.models.is_empty());
1079    }
1080}
1081
1082/// One orphaned page link label aggregated across sources.
1083///
1084/// `count` is how many distinct source pages reference this label
1085/// without a matching target page existing.
1086#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1087pub struct OrphanLink {
1088    pub label: String,
1089    pub count: i64,
1090}
1091
1092/// Response for `GET /api/pages/orphan-links`.
1093#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1094pub struct OrphanLinksResponse {
1095    pub min_count: usize,
1096    pub orphan_labels: Vec<OrphanLink>,
1097}
1098
1099/// One pending revision awaiting human accept/dismiss.
1100///
1101/// `target_source_id` is the memory being revised; pass it to
1102/// `accept_pending_revision` or `dismiss_pending_revision`.
1103/// `revision_source_id` is the staged revision row itself, exposed
1104/// for diagnostics and round-tripping (not for the action call).
1105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1106pub struct PendingRevisionItem {
1107    pub target_source_id: String,
1108    pub revision_source_id: String,
1109    pub revision_content: String,
1110    pub source_agent: Option<String>,
1111    pub last_modified: i64,
1112    /// Doc file source_id that grounds a doc-grounded revision (L3); None for
1113    /// other revision producers. Read from structured_fields.grounded_in.
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    pub grounded_in: Option<String>,
1116}
1117
1118/// Response returned by `POST /api/memory/revision/{id}/accept`.
1119/// Carries the now-consumed revision row id so agents can correlate with
1120/// their `list_pending_revisions` cache.
1121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1122pub struct RevisionAcceptResponse {
1123    pub target_source_id: String,
1124    pub revision_source_id: String,
1125    pub wrote: bool,
1126}
1127
1128/// Response returned by `POST /api/memory/revision/{id}/dismiss`.
1129/// `wrote: true` always (404 on missing).
1130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1131pub struct RevisionDismissResponse {
1132    pub target_source_id: String,
1133    pub wrote: bool,
1134}
1135
1136/// Response returned by `POST /api/memory/contradiction/{source_id}/dismiss`.
1137/// `wrote: true` is best-effort: the daemon's underlying DB method silently
1138/// no-ops when no rows match. Wrapper cannot distinguish dismiss-of-existing
1139/// from dismiss-of-nothing without an extra SELECT (out of scope).
1140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1141pub struct ContradictionDismissResponse {
1142    pub source_id: String,
1143    pub wrote: bool,
1144}
1145
1146#[cfg(test)]
1147mod mutation_response_tests {
1148    use super::*;
1149
1150    #[test]
1151    fn revision_accept_response_serializes_byte_identical() {
1152        let r = RevisionAcceptResponse {
1153            target_source_id: "mem_target".into(),
1154            revision_source_id: "mem_rev".into(),
1155            wrote: true,
1156        };
1157        assert_eq!(
1158            serde_json::to_string(&r).unwrap(),
1159            r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
1160        );
1161    }
1162
1163    #[test]
1164    fn revision_dismiss_response_serializes_byte_identical() {
1165        let r = RevisionDismissResponse {
1166            target_source_id: "mem_target".into(),
1167            wrote: true,
1168        };
1169        assert_eq!(
1170            serde_json::to_string(&r).unwrap(),
1171            r#"{"target_source_id":"mem_target","wrote":true}"#
1172        );
1173    }
1174
1175    #[test]
1176    fn contradiction_dismiss_response_serializes_byte_identical() {
1177        let r = ContradictionDismissResponse {
1178            source_id: "mem_abc".into(),
1179            wrote: true,
1180        };
1181        assert_eq!(
1182            serde_json::to_string(&r).unwrap(),
1183            r#"{"source_id":"mem_abc","wrote":true}"#
1184        );
1185    }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    #[test]
1193    fn store_memory_response_deserializes_without_extraction_method() {
1194        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
1195        let json = r#"{
1196            "source_id": "mem_abc",
1197            "chunks_created": 3,
1198            "memory_type": "fact"
1199        }"#;
1200        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1201        assert_eq!(parsed.source_id, "mem_abc");
1202        assert_eq!(parsed.chunks_created, 3);
1203        assert_eq!(parsed.memory_type, "fact");
1204        assert_eq!(parsed.extraction_method, "unknown");
1205        assert!(parsed.warnings.is_empty());
1206    }
1207
1208    #[test]
1209    fn store_memory_response_deserializes_with_all_fields() {
1210        let json = r#"{
1211            "source_id": "mem_abc",
1212            "chunks_created": 3,
1213            "memory_type": "fact",
1214            "warnings": ["decision memory missing claim"],
1215            "extraction_method": "llm"
1216        }"#;
1217        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1218        assert_eq!(parsed.warnings.len(), 1);
1219        assert_eq!(parsed.extraction_method, "llm");
1220    }
1221
1222    #[test]
1223    fn store_memory_response_exposes_enrichment_and_hint() {
1224        // Post-async-refactor shape: the daemon returns immediately after
1225        // upsert and reports deferred enrichment via `enrichment` + `hint`.
1226        let json = r#"{
1227            "source_id": "mem_xyz",
1228            "chunks_created": 1,
1229            "memory_type": "fact",
1230            "warnings": [],
1231            "extraction_method": "unknown",
1232            "enrichment": "pending",
1233            "hint": "Stored. Wenlan is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
1234        }"#;
1235        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1236        assert_eq!(parsed.enrichment, "pending");
1237        assert!(parsed.hint.contains("compiling"));
1238    }
1239
1240    #[test]
1241    fn store_memory_response_defaults_enrichment_for_older_responses() {
1242        // Backward-compat: existing clients (wenlan-mcp, Tauri app) that
1243        // deserialize pre-async-refactor responses must keep working.
1244        let json = r#"{
1245            "source_id": "mem_old",
1246            "chunks_created": 1,
1247            "memory_type": "fact"
1248        }"#;
1249        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1250        assert_eq!(parsed.enrichment, ""); // default
1251        assert_eq!(parsed.hint, ""); // default
1252    }
1253
1254    #[test]
1255    fn tags_response_defaults_document_tags_for_older_responses() {
1256        let json = r#"{"tags":["rust","tauri"]}"#;
1257        let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1258
1259        assert_eq!(parsed.tags, vec!["rust", "tauri"]);
1260        assert!(parsed.document_tags.is_empty());
1261    }
1262
1263    #[test]
1264    fn tags_response_deserializes_document_tag_map() {
1265        let json = r#"{
1266            "tags":["rust","tauri"],
1267            "document_tags":{"memory::mem1":["rust"],"page::page1":["tauri"]}
1268        }"#;
1269        let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1270
1271        assert_eq!(
1272            parsed.document_tags.get("memory::mem1"),
1273            Some(&vec!["rust".to_string()])
1274        );
1275        assert_eq!(
1276            parsed.document_tags.get("page::page1"),
1277            Some(&vec!["tauri".to_string()])
1278        );
1279    }
1280
1281    #[test]
1282    fn store_memory_response_roundtrips_not_needed_state() {
1283        // Daemon reports `not_needed` when no LLM is available. Hint is empty
1284        // (skip_serializing_if) so the JSON shrinks accordingly.
1285        let response = StoreMemoryResponse {
1286            source_id: "mem_no_llm".into(),
1287            chunks_created: 1,
1288            memory_type: "fact".into(),
1289            entity_id: None,
1290            quality: None,
1291            warnings: vec![],
1292            extraction_method: "none".into(),
1293            enrichment: "not_needed".into(),
1294            hint: String::new(),
1295        };
1296        let json = serde_json::to_string(&response).unwrap();
1297        assert!(json.contains("\"enrichment\":\"not_needed\""));
1298        assert!(
1299            !json.contains("\"hint\""),
1300            "empty hint must be skipped on the wire, got: {json}"
1301        );
1302        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1303        assert_eq!(parsed.enrichment, "not_needed");
1304        assert_eq!(parsed.hint, "");
1305    }
1306
1307    #[test]
1308    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1309        // ProfileContext.goals is deprecated; constructing it directly here
1310        // for wire roundtrip coverage until 0.4 drops the field entirely.
1311        #[allow(deprecated)]
1312        let profile = ProfileContext {
1313            narrative: "n".into(),
1314            identity: vec![],
1315            preferences: vec![],
1316            goals: vec![],
1317        };
1318        let response = ChatContextResponse {
1319            context: "context".into(),
1320            profile,
1321            knowledge: KnowledgeContext {
1322                pages: vec![],
1323                decisions: vec![],
1324                relevant_memories: vec![],
1325                graph_context: vec![],
1326            },
1327            took_ms: 1.0,
1328            token_estimates: TierTokenEstimates {
1329                tier1_identity: 1,
1330                tier2_project: 2,
1331                tier3_relevant: 3,
1332                total: 6,
1333            },
1334        };
1335
1336        let json = serde_json::to_string(&response).unwrap();
1337        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1338        assert!(parsed.knowledge.pages.is_empty());
1339        assert!(parsed.knowledge.decisions.is_empty());
1340        assert!(parsed.knowledge.relevant_memories.is_empty());
1341        assert!(parsed.knowledge.graph_context.is_empty());
1342    }
1343
1344    #[test]
1345    fn orphan_links_response_golden_string() {
1346        let resp = OrphanLinksResponse {
1347            min_count: 2,
1348            orphan_labels: vec![OrphanLink {
1349                label: "Rust".to_string(),
1350                count: 3,
1351            }],
1352        };
1353        let s = serde_json::to_string(&resp).unwrap();
1354        assert_eq!(
1355            s,
1356            r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1357        );
1358    }
1359
1360    #[test]
1361    fn orphan_links_response_empty_round_trip() {
1362        let resp = OrphanLinksResponse {
1363            min_count: 1,
1364            orphan_labels: vec![],
1365        };
1366        let decoded: OrphanLinksResponse =
1367            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1368        assert_eq!(decoded, resp);
1369    }
1370
1371    #[test]
1372    fn pending_revision_item_round_trip() {
1373        let item = PendingRevisionItem {
1374            target_source_id: "mem_target".into(),
1375            revision_source_id: "mem_rev".into(),
1376            revision_content: "new body".into(),
1377            source_agent: Some("claude-code".into()),
1378            last_modified: 1_715_000_000,
1379            grounded_in: None,
1380        };
1381        let json = serde_json::to_value(&item).unwrap();
1382        assert_eq!(json["target_source_id"], "mem_target");
1383        assert_eq!(json["revision_source_id"], "mem_rev");
1384        assert_eq!(json["revision_content"], "new body");
1385        let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1386        assert_eq!(decoded, item);
1387    }
1388}
1389
1390#[cfg(test)]
1391mod queue_status_tests {
1392    use super::*;
1393
1394    #[test]
1395    fn status_response_defaults_queue_to_idle_when_absent() {
1396        // Old daemons omit `queue` entirely — it must default to Idle so the
1397        // wire change stays additive (a new client reads an old response).
1398        let json =
1399            r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1400        let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1401        assert_eq!(parsed.queue, QueueStatus::Idle);
1402    }
1403
1404    #[test]
1405    fn queue_status_paused_round_trips_with_reason_and_retry() {
1406        let s = QueueStatus::Paused {
1407            reason: "analysis LLM failed".into(),
1408            pending: 2,
1409            next_retry_at: Some(1_712_678_400),
1410        };
1411        let json = serde_json::to_string(&s).unwrap();
1412        assert!(json.contains("\"state\":\"paused\""), "got: {json}");
1413        assert!(
1414            json.contains("\"reason\":\"analysis LLM failed\""),
1415            "got: {json}"
1416        );
1417        assert!(json.contains("\"next_retry_at\":1712678400"), "got: {json}");
1418        assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1419    }
1420
1421    #[test]
1422    fn queue_status_active_round_trips() {
1423        let s = QueueStatus::Active { pending: 3 };
1424        let json = serde_json::to_string(&s).unwrap();
1425        assert!(json.contains("\"state\":\"active\""), "got: {json}");
1426        assert!(json.contains("\"pending\":3"), "got: {json}");
1427        assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1428    }
1429
1430    #[test]
1431    fn queue_status_idle_serializes_state_only() {
1432        let json = serde_json::to_string(&QueueStatus::Idle).unwrap();
1433        assert_eq!(json, r#"{"state":"idle"}"#);
1434    }
1435}
1436
1437#[cfg(test)]
1438mod reranker_status_tests {
1439    use super::*;
1440
1441    #[test]
1442    fn status_response_defaults_reranker_to_disabled() {
1443        // Old daemons omit reranker AND the newer reranker_light/reranker_mode fields
1444        // entirely — all three must default cleanly (additive wire change, no break).
1445        let json =
1446            r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1447        let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1448        assert_eq!(parsed.reranker, RerankerStatus::Disabled);
1449        assert_eq!(parsed.reranker_light, RerankerStatus::Disabled);
1450        assert_eq!(parsed.reranker_mode, "");
1451    }
1452
1453    #[test]
1454    fn status_response_roundtrips_per_path_reranker() {
1455        let s = StatusResponse {
1456            is_running: true,
1457            files_indexed: 0,
1458            files_total: 0,
1459            sources_connected: vec![],
1460            queue: QueueStatus::Idle,
1461            compile_queue: QueueStatus::Idle,
1462            reranker: RerankerStatus::Active {
1463                model_id: "BGERerankerBase".into(),
1464            },
1465            reranker_light: RerankerStatus::Active {
1466                model_id: "JINARerankerV1TurboEn".into(),
1467            },
1468            reranker_mode: "full".into(),
1469        };
1470        let json = serde_json::to_string(&s).unwrap();
1471        let parsed: StatusResponse = serde_json::from_str(&json).unwrap();
1472        assert_eq!(parsed.reranker, s.reranker);
1473        assert_eq!(parsed.reranker_light, s.reranker_light);
1474        assert_eq!(parsed.reranker_mode, "full");
1475    }
1476
1477    #[test]
1478    fn reranker_status_active_roundtrips() {
1479        let s = RerankerStatus::Active {
1480            model_id: "BGERerankerBase".into(),
1481        };
1482        let json = serde_json::to_string(&s).unwrap();
1483        assert_eq!(serde_json::from_str::<RerankerStatus>(&json).unwrap(), s);
1484        assert!(json.contains("\"state\":\"active\""));
1485    }
1486}
1487
1488#[cfg(test)]
1489mod search_memory_response_tests {
1490    use super::SearchMemoryResponse;
1491
1492    /// Old daemon responses (no `supplemental_pages` key) must deserialize
1493    /// successfully with `supplemental_pages == None`.  This locks in the
1494    /// back-compat guarantee: clients talking to an older daemon never see a
1495    /// deserialization error.
1496    #[test]
1497    fn back_compat_missing_supplemental_pages_is_none() {
1498        let json = r#"{"results":[],"took_ms":1.0}"#;
1499        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1500        assert!(
1501            resp.supplemental_pages.is_none(),
1502            "should be None when key absent"
1503        );
1504        assert_eq!(resp.took_ms, 1.0);
1505    }
1506
1507    /// `supplemental_pages` absent means the field is omitted on the wire
1508    /// (skip_serializing_if = "Option::is_none").
1509    #[test]
1510    fn none_supplemental_pages_not_serialized() {
1511        let resp = SearchMemoryResponse {
1512            results: vec![],
1513            took_ms: 2.0,
1514            supplemental_pages: None,
1515        };
1516        let json = serde_json::to_string(&resp).expect("serialize");
1517        assert!(
1518            !json.contains("supplemental_pages"),
1519            "None field must be omitted from wire: {}",
1520            json
1521        );
1522    }
1523
1524    /// When pages are present they round-trip correctly.
1525    #[test]
1526    fn some_supplemental_pages_round_trips() {
1527        let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1528        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1529        assert!(
1530            resp.supplemental_pages.is_some(),
1531            "supplemental_pages should be Some"
1532        );
1533        assert!(
1534            resp.supplemental_pages.unwrap().is_empty(),
1535            "empty array should deserialize to empty vec"
1536        );
1537    }
1538}
1539
1540#[cfg(test)]
1541mod search_response_tests {
1542    use super::SearchResponse;
1543
1544    /// Old daemon responses (no `supplemental_pages` key) must deserialize
1545    /// successfully with `supplemental_pages == None`. Back-compat guarantee:
1546    /// a client built against the new wire type can still read an older
1547    /// `/api/search` response that predates the additive page field.
1548    #[test]
1549    fn back_compat_missing_supplemental_pages_is_none() {
1550        let json = r#"{"results":[],"took_ms":1.0}"#;
1551        let resp: SearchResponse = serde_json::from_str(json).expect("should deserialize");
1552        assert!(
1553            resp.supplemental_pages.is_none(),
1554            "should be None when key absent"
1555        );
1556        assert_eq!(resp.took_ms, 1.0);
1557    }
1558
1559    /// `supplemental_pages == None` is omitted on the wire
1560    /// (skip_serializing_if = "Option::is_none").
1561    #[test]
1562    fn none_supplemental_pages_not_serialized() {
1563        let resp = SearchResponse {
1564            results: vec![],
1565            took_ms: 2.0,
1566            supplemental_pages: None,
1567        };
1568        let json = serde_json::to_string(&resp).expect("serialize");
1569        assert!(
1570            !json.contains("supplemental_pages"),
1571            "None field must be omitted from wire: {}",
1572            json
1573        );
1574    }
1575
1576    /// When pages are present they round-trip correctly.
1577    #[test]
1578    fn some_supplemental_pages_round_trips() {
1579        let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1580        let resp: SearchResponse = serde_json::from_str(json).expect("deserialize");
1581        assert!(
1582            resp.supplemental_pages.is_some(),
1583            "supplemental_pages should be Some"
1584        );
1585        assert!(
1586            resp.supplemental_pages.unwrap().is_empty(),
1587            "empty array should deserialize to empty vec"
1588        );
1589    }
1590}