Skip to main content

nexus_core/app/
research.rs

1//! Deep research: a background multi-agent pipeline triggered by `/research`.
2//! Every stage but the Searcher fan-out is a single `Provider::complete`
3//! call; parsing/prompt-building here is pure and unit tested. The async
4//! orchestration (Task 9) calls real network endpoints and is exercised
5//! manually, like every other network-calling background job in this
6//! codebase (`maybe_generate_title`, image description, embedding).
7
8// Casts here are on bounded values: token counts, byte sizes, and
9// selection indices — never on unbounded input. JSON-derived indices in
10// provider/tools go through try_from instead.
11#![allow(
12    clippy::cast_possible_truncation,
13    clippy::cast_possible_wrap,
14    clippy::cast_precision_loss,
15    clippy::cast_sign_loss
16)]
17use crate::provider::ChatMessage;
18use std::fmt::Write as _;
19
20/// A background research pipeline update: a phase label (+ progress detail),
21/// the survey's clarifying questions awaiting a chat reply, the Planner's
22/// sub-questions awaiting approval, or the final report/error.
23#[derive(Clone)]
24pub enum ResearchUpdate {
25    /// Successive updates within one stage share a `label` so the UI/db
26    /// replace one row in place instead of appending per tick.
27    Stage {
28        label: String,
29        detail: String,
30    },
31    /// The scoping agent's clarifying questions; the pipeline is parked
32    /// awaiting a chat reply (`reply_to_survey_gate`). `round` is 1-based
33    /// (max `MAX_SURVEY_ROUNDS`).
34    SurveyReady {
35        questions: Vec<String>,
36        round: u8,
37    },
38    /// The Planner finished; the pipeline is parked awaiting a chat reply:
39    /// "approve" runs the questions, edits get folded in by the approval
40    /// agent and re-presented (`rework = true`) once, capped.
41    PlanReady {
42        questions: Vec<PlanQuestion>,
43        rework: bool,
44    },
45    Done(std::result::Result<String, String>),
46}
47
48/// Hard cap on Planner-generated sub-questions per outer round.
49const MAX_SUBQUESTIONS: usize = 6;
50/// Hard bound on queued `/steer` instructions (and thus on retained steer
51/// text and the unbounded channel) — beyond this, new steers are refused
52/// with a status message until the next round boundary drains the queue.
53const MAX_QUEUED_STEERS: usize = 64;
54/// Cap on the scoping agent's clarifying questions per round.
55const MAX_SURVEY_QUESTIONS: usize = 4;
56/// Max survey rounds (initial + follow-ups) before the survey force-completes.
57pub const MAX_SURVEY_ROUNDS: u8 = 3;
58/// Tool-call budget for a single Searcher agent — a few search→fetch hops,
59/// not a whole interactive conversation's worth.
60pub const RESEARCH_SEARCHER_MAX_ITERS: usize = 6;
61
62/// One Planner sub-question with its supporting brief: why it matters, the
63/// angles to cover, and promising source types/leads. The whole block is
64/// handed to its Searcher agent as the prompt — detail is functional.
65#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
66pub struct PlanQuestion {
67    #[serde(default)]
68    pub question: String,
69    #[serde(default)]
70    pub why: String,
71    #[serde(default)]
72    pub angles: Vec<String>,
73    #[serde(default)]
74    pub sources: Vec<String>,
75}
76
77impl PlanQuestion {
78    /// A question with no brief — the fallback shape when the Planner didn't
79    /// follow the JSON-object instructions.
80    pub const fn bare(question: String) -> Self {
81        Self {
82            question,
83            why: String::new(),
84            angles: Vec::new(),
85            sources: Vec::new(),
86        }
87    }
88
89    /// The Searcher's prompt: the topic plus this question's full block
90    /// (why/angles/sources), so one focused agent answers one focused brief.
91    pub fn prompt(&self, topic: &str) -> String {
92        let mut p = format!(
93            "Research topic: {topic}\n\nSub-question: {}\n",
94            self.question
95        );
96        if !self.why.is_empty() {
97            let _ = writeln!(p, "\nWhy this angle matters: {}", self.why);
98        }
99        if !self.angles.is_empty() {
100            let _ = write!(p, "\nAngles to cover: {}\n", self.angles.join("; "));
101        }
102        if !self.sources.is_empty() {
103            let _ = write!(p, "\nSource leads: {}\n", self.sources.join("; "));
104        }
105        p
106    }
107}
108
109/// A plan rendered for the transcript / plan file: numbered questions, each
110/// with its Why/Angles/Sources brief indented under it.
111pub fn plan_text(questions: &[PlanQuestion]) -> String {
112    questions
113        .iter()
114        .enumerate()
115        .map(|(i, q)| {
116            let mut s = format!("{}. {}", i + 1, q.question);
117            if !q.why.is_empty() {
118                let _ = write!(s, "\n   Why: {}", q.why);
119            }
120            if !q.angles.is_empty() {
121                let _ = write!(s, "\n   Angles: {}", q.angles.join("; "));
122            }
123            if !q.sources.is_empty() {
124                let _ = write!(s, "\n   Sources: {}", q.sources.join("; "));
125            }
126            s
127        })
128        .collect::<Vec<_>>()
129        .join("\n")
130}
131
132/// The scoping agent's reply: the single-word COMPLETE marker, a numbered
133/// list of clarifying questions, or output that violates the contract
134/// (`Malformed` — empty, explanatory prose, error text) which fails the
135/// survey visibly instead of silently dropping it.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum SurveyReply {
138    Complete,
139    Questions(Vec<String>),
140    Malformed,
141}
142
143/// The approval agent's verdict on a user reply to the plan.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Approval {
146    Approved,
147    Revised(Vec<PlanQuestion>),
148    /// The agent produced output that is neither an approval nor a usable
149    /// revision (bare prose, empty reply). Fails the phase visibly —
150    /// malformed output must never be mistaken for approval.
151    Malformed,
152}
153
154const PLANNER_PROMPT: &str = "You are the planning stage of an automated research pipeline. Given a research topic, decompose it into 3 to 6 focused sub-questions that together cover the topic thoroughly (different angles: definitions, current state, evidence/data, controversies, practical implications — whichever apply). For each sub-question include: 'question' (the sub-question itself), 'why' (one short sentence on the angle it covers), 'angles' (2-5 specific facets to investigate), and 'sources' (1-4 source types or leads likely to answer it). Respond with ONLY a JSON array of objects, no prose, no markdown fences. Example: [{\"question\": \"...\", \"why\": \"...\", \"angles\": [\"...\", \"...\"], \"sources\": [\"...\", \"...\"]}]. Note: searcher agents handling scholarly sub-questions can call search(mode=academic) in addition to search(mode=web), so peer-reviewed angles are fair game.";
155
156const SURVEY_AGENT_PROMPT: &str = "You are the scoping stage of a research pipeline. You'll be given a research topic and, on later rounds, the user's answers so far. Ask 1 to 4 focused clarifying questions that would meaningfully change the research plan — scope, depth, angles, constraints — and skip anything you can infer. When you have enough to plan, reply with exactly the single word COMPLETE. Otherwise reply with your numbered questions only, one per line, no preamble, no markdown.";
157
158const PLAN_APPROVAL_PROMPT: &str = "You are the approval stage of a research pipeline. The user was shown a plan of sub-questions (each with why/angles/sources). If the user's reply approves it — phrases like 'approve', 'looks good', 'go', 'ok', 'yes', or a bare affirmation — reply with exactly the single word APPROVED. Otherwise fold their feedback into the plan: apply the requested changes (drop questions, add angles, reword, add new questions up to 6 total) and reply with ONLY the revised JSON array of plan objects, no prose, no markdown fences. Example: [{\"question\": \"...\", \"why\": \"...\", \"angles\": [\"...\", \"...\"], \"sources\": [\"...\", \"...\"]}]";
159
160pub const SEARCHER_PROMPT: &str = "You are a research searcher agent. You will be given one focused sub-question. Use search(mode=web) and fetch_url to investigate it thoroughly: search, then fetch and read the most promising pages, and search again with new terms you learn from them if needed. When you have enough to answer well, write a concise findings summary (a few paragraphs, prose, no headers) that directly answers the sub-question, citing sources inline as [n]. End your answer with a line starting exactly with 'Sources:' followed by the numbered list of URLs you used, one per line, matching your [n] citations. Prefer sources from domains you have not already cited — diverse sources make a stronger report.";
161
162/// Fixed protocol text placed before every research-stage instruction. Keeping
163/// this prefix byte-identical gives independent stages and agent branches a
164/// common provider-cache anchor; stage-specific instructions follow it.
165const RESEARCH_CACHE_PREFIX: &str = "You are operating inside Nexus's deterministic research workflow. Treat this request as one step in a structured pipeline: obey the stage instruction that follows, use only evidence supplied by the current messages or tools, preserve exact URLs and citation meaning, and never invent a source or claim. Earlier messages may be workflow context; do not repeat them unless the current stage asks for it. Keep output within the requested format because the next pipeline stage parses it mechanically. This protocol is fixed across research stages so the reusable input prefix remains stable.";
166
167fn research_stage_prompt(stage: &str) -> String {
168    format!("{RESEARCH_CACHE_PREFIX}\n\n{stage}")
169}
170
171const SYNTHESIZER_PROMPT: &str = "You are the synthesis stage of a research pipeline. You'll be given the original topic and findings from several searcher agents, each already citing their own sources. Combine them into a single coherent draft report on the topic: organize by theme (not by sub-question), resolve obvious overlaps, keep every citation but you may renumber them consistently as you merge. Do not invent facts not present in the findings. Output the draft report in markdown, no preamble.";
172
173const CRITIC_PROMPT: &str = "You are the critic stage of a research pipeline. Given the original topic and a draft report, decide if it's ready. Respond in exactly one of these forms:\n- the single word SATISFIED, if the draft thoroughly covers the topic with no notable gaps or contradictions.\n- GAPS: followed by a newline-separated bullet list (each line starting with '- ') of specific missing sub-topics or unanswered angles, each phrased as a searchable question.\n- CONTRADICTION: followed by one line describing a specific factual contradiction between sources in the draft that isn't resolved.\nUse CONTRADICTION only for an actual conflict between sources, not a missing angle — missing angles are always GAPS. Respond with nothing else.";
174
175const RESOLVER_PROMPT: &str = "You are resolving a contradiction found in a research draft. You are given the topic, the draft, the full set of source findings gathered so far, and a description of the contradiction. Determine which claim the evidence better supports (or that both apply in different contexts) and write one paragraph resolving it, citing the [n] sources involved. Output only that paragraph.";
176
177const VERIFIER_PROMPT: &str = "You are the verifier stage. Given the topic, the gathered source findings (with their citations), and a draft report, check every factual claim in the draft against the source findings. Rewrite the draft unchanged except: (1) remove or mark with '⚠ unverifiable:' any claim not actually supported by the gathered findings; (2) immediately after a claim's citations, judge its confidence from citation count and cross-source agreement and, only for low or medium confidence, append the tag ‹low› or ‹med› right after the citation (high confidence is the default and stays untagged — do not tag it). Output the corrected draft in markdown, nothing else. You have a fetch_url tool restricted to already-cached pages: use it to check any direct quote in the draft against the cached source text, and mark a quote that doesn't actually match with '‹unverified quote›' immediately after it.";
178
179const WRITER_PROMPT: &str = "You are the final writer stage. Given the topic and a verified draft report (with inline [n] citations and prose from earlier stages, possibly including a contradiction-resolution paragraph to fold in), produce the final report: clean markdown, a short introductory paragraph, organized sections with headers, inline [n] citations preserved/renumbered consistently, and a trailing '## Sources' section listing every cited URL as 'n. url'. Output only the final report markdown, nothing else — it will be saved and shown to the user as-is.";
180
181/// The Critic stage's structured decision.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum Critique {
184    Satisfied,
185    Gaps(Vec<String>),
186    Contradiction(String),
187}
188
189/// Parse the Planner's raw reply into sub-questions: a JSON string array, or
190/// (if the model didn't follow instructions) a best-effort line-by-line
191/// fallback stripping bullet/number prefixes. Always capped at
192/// `MAX_SUBQUESTIONS`.
193pub fn parse_subquestions(text: &str) -> Vec<String> {
194    let trimmed = text
195        .trim()
196        .trim_start_matches("```json")
197        .trim_start_matches("```")
198        .trim_end_matches("```")
199        .trim();
200    if let Ok(v) = serde_json::from_str::<Vec<String>>(trimmed) {
201        return v
202            .into_iter()
203            .map(|s| s.trim().to_string())
204            .filter(|s| !s.is_empty())
205            .take(MAX_SUBQUESTIONS)
206            .collect();
207    }
208    trimmed
209        .lines()
210        .map(strip_list_prefix)
211        .filter(|l| !l.is_empty())
212        .take(MAX_SUBQUESTIONS)
213        .collect()
214}
215
216/// Strip a leading `-`, `*`, or `N.`/`N)` list-item marker, if present.
217fn strip_list_prefix(line: &str) -> String {
218    let s = line.trim().trim_start_matches(['-', '*']).trim();
219    let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(0);
220    if digits_end > 0
221        && let Some(rest) = s[digits_end..].strip_prefix(['.', ')'])
222    {
223        return rest.trim().to_string();
224    }
225    s.to_string()
226}
227
228/// Parse the scoping agent's reply: `COMPLETE` (case-insensitive, optionally
229/// with trailing punctuation or prose) ends the survey. Otherwise only lines
230/// that look like questions — numbered/bulleted, or ending in `?` — are read
231/// as clarifying questions. Anything else (empty output, explanatory or
232/// error prose) is `Malformed`: the agent's output contract says COMPLETE or
233/// questions, so a violation must fail the survey visibly — never be
234/// mistaken for completion, and never park the pipeline awaiting an answer
235/// for a non-question.
236pub fn parse_survey_reply(text: &str) -> SurveyReply {
237    let t = text.trim();
238    // First word COMPLETE ends the survey, tolerating trailing punctuation
239    // and prose: "COMPLETE", "COMPLETE.", "COMPLETE: proceed", "COMPLETE —".
240    let head = t
241        .split_whitespace()
242        .next()
243        .unwrap_or("")
244        .trim_end_matches(|c: char| !c.is_ascii_alphanumeric());
245    if head.eq_ignore_ascii_case("COMPLETE") {
246        return SurveyReply::Complete;
247    }
248    let mut qs: Vec<String> = Vec::new();
249    for line in t.lines() {
250        let s = line.trim();
251        if s.is_empty() {
252            continue;
253        }
254        // Only list-marked or question-shaped lines count as questions;
255        // bare prose (explanations, error messages) is a contract violation.
256        let marked =
257            s.starts_with(['-', '*']) || s.chars().next().is_some_and(|c| c.is_ascii_digit());
258        let q = strip_list_prefix(s);
259        if q.is_empty() {
260            continue;
261        }
262        if !marked && !q.ends_with('?') {
263            continue;
264        }
265        qs.push(q);
266        if qs.len() >= MAX_SURVEY_QUESTIONS {
267            break;
268        }
269    }
270    if qs.is_empty() {
271        SurveyReply::Malformed
272    } else {
273        SurveyReply::Questions(qs)
274    }
275}
276
277/// Byte offset of the first `[` or `{` in `s`, if any. Structured JSON the
278/// model wrapped in prose ("Here is the plan:\n[{\"question\":…}]") is
279/// still JSON and must be parsed as such — never re-read as bare lines.
280fn json_start(s: &str) -> Option<usize> {
281    s.find(['[', '{'])
282}
283
284/// Parse the Planner's reply into plan blocks: a JSON array of objects with
285/// `question`/`why`/`angles`/`sources` (missing fields default to empty).
286/// Structured JSON is unambiguous — malformed JSON (parse failure, wrong
287/// field types) or JSON with no usable questions yields an empty result and
288/// fails planning; the raw JSON lines are never reinterpreted as bare
289/// questions (`[{}]` must not become a plan whose question is literally
290/// `[{}]`, and prose-prefixed JSON like "Here is the plan:\n[…]" is still
291/// parsed as JSON, not as two raw lines). Only output with no JSON shape at
292/// all falls back to one bare question per line (the legacy line format),
293/// which also still accepts a legacy JSON array of strings. Always capped at
294/// `MAX_SUBQUESTIONS`.
295pub fn parse_plan_blocks(text: &str) -> Vec<PlanQuestion> {
296    let trimmed = text
297        .trim()
298        .trim_start_matches("```json")
299        .trim_start_matches("```")
300        .trim_end_matches("```")
301        .trim();
302    if let Some(start) = json_start(trimmed) {
303        let candidate = &trimmed[start..];
304        if let Ok(v) = serde_json::from_str::<Vec<PlanQuestion>>(candidate) {
305            let qs: Vec<PlanQuestion> = v
306                .into_iter()
307                .map(|mut q| {
308                    q.question = q.question.trim().to_string();
309                    q
310                })
311                .filter(|q| !q.question.is_empty())
312                .take(MAX_SUBQUESTIONS)
313                .collect();
314            if !qs.is_empty() {
315                return qs;
316            }
317        }
318        // Legacy structured format: a JSON array of plain strings.
319        if let Ok(v) = serde_json::from_str::<Vec<String>>(candidate) {
320            let qs: Vec<PlanQuestion> = v
321                .into_iter()
322                .map(|s| s.trim().to_string())
323                .filter(|s| !s.is_empty())
324                .map(PlanQuestion::bare)
325                .take(MAX_SUBQUESTIONS)
326                .collect();
327            if !qs.is_empty() {
328                return qs;
329            }
330        }
331        // Malformed or unusable structured output: fail, no line fallback.
332        return Vec::new();
333    }
334    parse_subquestions(text)
335        .into_iter()
336        .map(PlanQuestion::bare)
337        .collect()
338}
339
340/// Parse the approval agent's reply: `APPROVED` (case-insensitive, optionally
341/// with trailing prose) accepts the plan; a JSON plan array is a revision;
342/// line-formatted revisions are only accepted when they look like a list
343/// (bullets or `N.`/`N)` prefixes). Anything else is `Malformed` — a garbled
344/// verdict must fail the phase visibly, never silently count as approval.
345pub fn parse_approval(text: &str) -> Approval {
346    let upper = text.trim().to_ascii_uppercase();
347    if upper == "APPROVED"
348        || upper.starts_with("APPROVED:")
349        || upper.starts_with("APPROVED —")
350        || upper.starts_with("APPROVED\n")
351    {
352        return Approval::Approved;
353    }
354    // Structured JSON (possibly wrapped in prose) is unambiguous: parse it
355    // strictly through `parse_plan_blocks`, and treat malformed or unusable
356    // output as `Malformed` — never fall back to reading the raw JSON lines
357    // as bare plan questions.
358    let trimmed = text
359        .trim()
360        .trim_start_matches("```json")
361        .trim_start_matches("```")
362        .trim_end_matches("```")
363        .trim();
364    if json_start(trimmed).is_some() {
365        let qs = parse_plan_blocks(text);
366        return if qs.is_empty() {
367            Approval::Malformed
368        } else {
369            Approval::Revised(qs)
370        };
371    }
372    // Line fallback: only when the output is recognizably a list.
373    let has_markers = trimmed.lines().any(|l| {
374        let s = l.trim();
375        s.starts_with(['-', '*']) || s.chars().next().is_some_and(|c| c.is_ascii_digit())
376    });
377    if !has_markers {
378        return Approval::Malformed;
379    }
380    let qs = parse_plan_blocks(text);
381    if qs.is_empty() {
382        Approval::Malformed
383    } else {
384        Approval::Revised(qs)
385    }
386}
387
388/// Parse the Critic's raw reply into a `Critique`. Anything that doesn't
389/// match one of the three expected shapes is treated as `Satisfied` — an
390/// unparseable critique shouldn't loop the pipeline forever on garbage.
391pub fn parse_critique(text: &str) -> Critique {
392    let t = text.trim();
393    if t.eq_ignore_ascii_case("SATISFIED") {
394        return Critique::Satisfied;
395    }
396    if let Some(rest) = t.strip_prefix("CONTRADICTION:") {
397        let desc = rest.trim();
398        if !desc.is_empty() {
399            return Critique::Contradiction(desc.to_string());
400        }
401    }
402    if let Some(rest) = t.strip_prefix("GAPS:") {
403        let gaps: Vec<String> = rest
404            .lines()
405            .map(str::trim)
406            .filter_map(|l| l.strip_prefix('-'))
407            .map(|l| l.trim().to_string())
408            .filter(|l| !l.is_empty())
409            .take(MAX_SUBQUESTIONS)
410            .collect();
411        if !gaps.is_empty() {
412            return Critique::Gaps(gaps);
413        }
414    }
415    Critique::Satisfied
416}
417
418/// The Planner's request: the topic, the user's survey answers ("what they
419/// said they want"), and any locally-known context (chunks from the space's
420/// own files plus a preliminary web survey, semantically matched to the
421/// topic) framed as "already known — plan sub-questions for the gaps".
422fn planner_messages_with_context(
423    topic: &str,
424    answers: &[(String, String)],
425    known: &[String],
426) -> Vec<ChatMessage> {
427    let mut user = String::new();
428    if !answers.is_empty() {
429        user.push_str("The user answered clarifying questions before planning:\n");
430        for (i, (qs, reply)) in answers.iter().enumerate() {
431            let _ = write!(user, "Round {} — asked: {}\nAnswered: {reply}\n", i + 1, qs);
432        }
433        user.push('\n');
434    }
435    if known.is_empty() {
436        user.push_str(topic);
437    } else {
438        let _ = write!(
439            user,
440            "Topic: {topic}\n\nAlready known (from local files and/or a preliminary web survey) — \
441             plan sub-questions for the gaps, not what's already covered:\n{}",
442            known.join("\n\n")
443        );
444    }
445    vec![
446        ChatMessage::text("system", research_stage_prompt(PLANNER_PROMPT)),
447        ChatMessage::text("user", user),
448    ]
449}
450
451/// The scoping agent's request: the topic, and on later rounds the questions
452/// asked + answers given so far. One prompt serves both the initial questions
453/// (empty rounds) and each follow-up round — the agent replies COMPLETE when
454/// it has enough.
455fn survey_messages(topic: &str, rounds: &[(String, String)]) -> Vec<ChatMessage> {
456    let mut user = format!("Research topic: {topic}\n");
457    if !rounds.is_empty() {
458        user.push_str("\nSo far:\n");
459        for (i, (qs, reply)) in rounds.iter().enumerate() {
460            let _ = write!(
461                user,
462                "Round {} — I asked:\n{qs}\nThe user answered: {reply}\n",
463                i + 1
464            );
465        }
466    }
467    vec![
468        ChatMessage::text("system", research_stage_prompt(SURVEY_AGENT_PROMPT)),
469        ChatMessage::text("user", user),
470    ]
471}
472
473/// Fold the user's reply to the presented plan back into the pipeline: the
474/// approval agent either recognizes an approval (APPROVED) or returns a
475/// revised plan.
476fn plan_approval_messages(
477    topic: &str,
478    questions: &[PlanQuestion],
479    user_reply: &str,
480) -> Vec<ChatMessage> {
481    vec![
482        ChatMessage::text("system", research_stage_prompt(PLAN_APPROVAL_PROMPT)),
483        ChatMessage::text(
484            "user",
485            format!(
486                "Topic: {topic}\n\nPlan:\n{}\n\nUser reply: {user_reply}",
487                plan_text(questions)
488            ),
489        ),
490    ]
491}
492
493fn synthesizer_messages(topic: &str, findings: &[String], pinned: &[String]) -> Vec<ChatMessage> {
494    let body = findings
495        .iter()
496        .enumerate()
497        .map(|(i, f)| format!("--- Searcher {} findings ---\n{f}", i + 1))
498        .collect::<Vec<_>>()
499        .join("\n\n");
500    let mut user = format!("Topic: {topic}\n\n");
501    if !pinned.is_empty() {
502        let _ = write!(
503            user,
504            "Prioritize these pinned sources in the synthesis if their content is present in the findings below:\n{}\n\n",
505            pinned.join("\n")
506        );
507    }
508    user.push_str(&body);
509    vec![
510        ChatMessage::text("system", research_stage_prompt(SYNTHESIZER_PROMPT)),
511        ChatMessage::text("user", user),
512    ]
513}
514
515fn critic_messages(topic: &str, draft: &str) -> Vec<ChatMessage> {
516    vec![
517        ChatMessage::text("system", research_stage_prompt(CRITIC_PROMPT)),
518        ChatMessage::text("user", format!("Topic: {topic}\n\nDraft:\n{draft}")),
519    ]
520}
521
522fn resolver_messages(
523    topic: &str,
524    draft: &str,
525    findings: &[String],
526    contradiction: &str,
527) -> Vec<ChatMessage> {
528    let body = findings.join("\n\n");
529    vec![
530        ChatMessage::text("system", research_stage_prompt(RESOLVER_PROMPT)),
531        ChatMessage::text(
532            "user",
533            format!(
534                "Topic: {topic}\n\nContradiction: {contradiction}\n\nDraft:\n{draft}\n\nSource findings:\n{body}"
535            ),
536        ),
537    ]
538}
539
540fn verifier_messages(topic: &str, draft: &str, findings: &[String]) -> Vec<ChatMessage> {
541    let body = findings.join("\n\n");
542    vec![
543        ChatMessage::text("system", research_stage_prompt(VERIFIER_PROMPT)),
544        ChatMessage::text(
545            "user",
546            format!("Topic: {topic}\n\nSource findings:\n{body}\n\nDraft:\n{draft}"),
547        ),
548    ]
549}
550
551fn writer_messages(topic: &str, verified_draft: &str, pinned: &[String]) -> Vec<ChatMessage> {
552    let mut user = format!("Topic: {topic}\n\n");
553    if !pinned.is_empty() {
554        let _ = write!(
555            user,
556            "Prioritize these pinned sources in the final report if their content is present in the verified draft below:\n{}\n\n",
557            pinned.join("\n")
558        );
559    }
560    let _ = write!(user, "Verified draft:\n{verified_draft}");
561    vec![
562        ChatMessage::text("system", research_stage_prompt(WRITER_PROMPT)),
563        ChatMessage::text("user", user),
564    ]
565}
566
567use std::sync::Arc;
568
569use tokio::sync::mpsc;
570
571use crate::db::Db;
572use crate::provider::openrouter::OpenRouter;
573use crate::provider::{ChatParams, StreamEvent, Usage};
574use crate::tools::{ToolBox, ToolExecutor};
575
576use super::ResearchMsg;
577use super::{SurveyGate, SurveyPhase};
578
579/// Content-free usage sink for background research requests. Each stage uses
580/// the same provider accounting as interactive chat without putting prompt
581/// text into the database.
582#[derive(Clone)]
583struct ResearchUsage {
584    db_path: std::path::PathBuf,
585    session_id: String,
586    prompt_cache_key: String,
587    space_id: String,
588    backend: &'static str,
589}
590
591impl ResearchUsage {
592    fn chat_params(&self) -> ChatParams {
593        ChatParams {
594            prompt_cache_key: Some(self.prompt_cache_key.clone()),
595            ..ChatParams::default()
596        }
597    }
598
599    fn record(&self, model: &str, usage: Usage) {
600        // Cost-only trailing events are merged by the interactive task;
601        // background streams have no request boundary for that event, so do
602        // not create a zero-token duplicate row here.
603        if usage.prompt_tokens == 0 && usage.completion_tokens == 0 {
604            return;
605        }
606        let Ok(db) = Db::open(&self.db_path) else {
607            return;
608        };
609        let cost_is_provider = usage.cost.is_some();
610        let cost = usage.cost.or_else(|| {
611            db.request_cost(
612                model,
613                usage.prompt_tokens,
614                usage.completion_tokens,
615                usage.cache_read_tokens,
616                usage.cache_creation_tokens,
617            )
618        });
619        let _ = db.log_usage(
620            self.backend,
621            model,
622            usage.prompt_tokens,
623            usage.completion_tokens,
624            usage.cache_read_tokens,
625            usage.cache_creation_tokens,
626            cost,
627            cost_is_provider,
628            Some(&self.session_id),
629            Some(&self.space_id),
630        );
631    }
632}
633
634/// Send the `(session_id, space_id, space_name)` triple's stage update.
635fn send_stage(
636    tx: &mpsc::UnboundedSender<ResearchMsg>,
637    ids: &(String, String, String),
638    label: impl Into<String>,
639    detail: impl Into<String>,
640) {
641    let _ = tx.send((
642        ids.0.clone(),
643        ids.1.clone(),
644        ids.2.clone(),
645        ResearchUpdate::Stage {
646            label: label.into(),
647            detail: detail.into(),
648        },
649    ));
650}
651
652/// Every steer instruction queued since the last drain, without blocking —
653/// `try_recv` until the channel is empty. Called at each round boundary so
654/// a user's mid-flight `/steer` gets picked up as an extra searcher round.
655pub fn drain_steers(rx: &mut mpsc::UnboundedReceiver<String>) -> Vec<String> {
656    let mut out = Vec::new();
657    while let Ok(s) = rx.try_recv() {
658        out.push(s);
659    }
660    out
661}
662
663async fn complete_text(
664    provider: &OpenRouter,
665    model: &str,
666    messages: Vec<ChatMessage>,
667    usage: &ResearchUsage,
668) -> Result<String, String> {
669    let params = usage.chat_params();
670    provider
671        .complete_with_params(model, messages, &params)
672        .await
673        .map(|completion| {
674            if let Some(reported) = completion.usage {
675                usage.record(model, reported);
676            }
677            completion.text.trim().to_string()
678        })
679        .map_err(|e| e.to_string())
680}
681
682/// Run a non-streaming pipeline agent and terminalize its activity row on
683/// failure. The caller owns the stage-specific success detail.
684async fn complete_agent(
685    provider: &OpenRouter,
686    model: &str,
687    messages: Vec<ChatMessage>,
688    usage: &ResearchUsage,
689    tx: &mpsc::UnboundedSender<ResearchMsg>,
690    ids: &(String, String, String),
691    label: &str,
692) -> Result<String, String> {
693    match complete_text(provider, model, messages, usage).await {
694        Ok(text) => Ok(text),
695        Err(e) => {
696            send_stage(tx, ids, label, format!("error — {e}"));
697            Err(e)
698        }
699    }
700}
701
702async fn plan(
703    provider: &OpenRouter,
704    model: &str,
705    topic: &str,
706    answers: &[(String, String)],
707    known: &[String],
708    usage: &ResearchUsage,
709) -> Result<Vec<PlanQuestion>, String> {
710    let text = complete_text(
711        provider,
712        model,
713        planner_messages_with_context(topic, answers, known),
714        usage,
715    )
716    .await?;
717    let qs = parse_plan_blocks(&text);
718    if qs.is_empty() {
719        return Err(format!(
720            "planner returned no usable sub-questions (raw reply: {text:.200})"
721        ));
722    }
723    Ok(qs)
724}
725
726/// One Searcher agent: given one focused sub-question prompt, runs the normal
727/// tool-loop (restricted to `search/fetch_url`) and returns its final prose
728/// findings (including its own "Sources:" citation list). Never returns an
729/// `Err` — a dead search/fetch/model call becomes a placeholder finding
730/// string so one bad sub-question can't sink the whole pipeline.
731///
732/// `prompt` is the full block handed to the model (topic + why/angles/sources
733/// brief — detail is functional); `display` is the short label used in the
734/// live activity rows, so a searcher's status never leaks the whole brief.
735///
736/// Every `Status`/`ToolCall` event along the way is forwarded as a live
737/// stage update under this searcher's own label (`searcher N/total`), so the
738/// UI shows what it's actually doing (searching, fetching a URL, etc.) in
739/// real time instead of going silent until it finishes.
740#[allow(clippy::too_many_arguments)]
741/// The execution plumbing a searcher agent shares with the rest of the
742/// research pipeline: the tool box, the job's stage-update channel, and the
743/// job's session/space identity.
744pub struct SearcherCtx<'a> {
745    pub toolbox: Arc<dyn ToolExecutor>,
746    usage: Arc<ResearchUsage>,
747    pub tx: &'a mpsc::UnboundedSender<ResearchMsg>,
748    pub ids: &'a (String, String, String),
749}
750
751/// Which slot in which batch a searcher agent occupies — its stage-row
752/// identity (`searcher {batch} {idx}/{total}`) in the live activity view.
753pub struct SearcherSlot {
754    pub batch: String,
755    pub idx: usize,
756    pub total: usize,
757}
758
759async fn run_searcher(
760    provider: &OpenRouter,
761    model: &str,
762    prompt: &str,
763    display: &str,
764    ctx: SearcherCtx<'_>,
765    slot: SearcherSlot,
766) -> String {
767    // Include the batch in the identity so follow-up and steered agents never
768    // overwrite earlier activity rows that happen to have the same index.
769    let label = format!("searcher {} {}/{}", slot.batch, slot.idx + 1, slot.total);
770    send_stage(
771        ctx.tx,
772        ctx.ids,
773        &label,
774        format!("working — investigating \"{display}\""),
775    );
776    let messages = vec![
777        ChatMessage::text("system", research_stage_prompt(SEARCHER_PROMPT)),
778        ChatMessage::text("user", prompt),
779    ];
780    let tools = ctx.toolbox.defs();
781    let (mut rx, abort) = provider.stream_chat(
782        model.to_string(),
783        messages,
784        ctx.usage.chat_params(),
785        tools,
786        ctx.toolbox,
787        RESEARCH_SEARCHER_MAX_ITERS,
788    );
789    let _abort = super::AbortOnDrop(abort);
790    let mut buf = String::new();
791    while let Some(ev) = rx.recv().await {
792        match ev {
793            StreamEvent::Token(t) => buf.push_str(&t),
794            StreamEvent::Status(s) => {
795                send_stage(ctx.tx, ctx.ids, &label, format!("working — {s}"));
796            }
797            StreamEvent::ToolCall {
798                name,
799                arguments,
800                result,
801                ..
802            } => {
803                let summary = crate::app::tool_call_summary(&name, &arguments, &result);
804                send_stage(ctx.tx, ctx.ids, &label, format!("working — {summary}"));
805            }
806            StreamEvent::Error(e) => {
807                send_stage(ctx.tx, ctx.ids, &label, format!("error — {e}"));
808                return format!("[search agent error on \"{display}\": {e}]");
809            }
810            StreamEvent::Usage(reported) => ctx.usage.record(model, reported),
811            StreamEvent::Done => break,
812            StreamEvent::Reasoning(_) => {}
813        }
814    }
815    let text = buf.trim();
816    if text.is_empty() {
817        send_stage(ctx.tx, ctx.ids, &label, "error — no findings returned");
818        format!("[no findings for \"{display}\"]")
819    } else {
820        send_stage(
821            ctx.tx,
822            ctx.ids,
823            &label,
824            format!("done — answered \"{display}\""),
825        );
826        text.to_string()
827    }
828}
829
830/// Run the Verifier stage with a cache-only toolbox so it can check direct
831/// quotes against exactly the pages searchers already gathered (never a
832/// fresh fetch). Never returns an `Err` — returns whatever text accumulated
833/// before the stream ended, or an empty string if it errored before
834/// producing any. The caller falls back to the unverified draft when this
835/// comes back empty, so verification failing must never blank out an
836/// otherwise-good report.
837async fn verify_with_quote_check(
838    provider: &OpenRouter,
839    model: &str,
840    messages: Vec<ChatMessage>,
841    cache_only_toolbox: Arc<dyn ToolExecutor>,
842    usage: &ResearchUsage,
843    tx: &mpsc::UnboundedSender<ResearchMsg>,
844    ids: &(String, String, String),
845) -> String {
846    let tools = cache_only_toolbox.defs();
847    let (mut rx, abort) = provider.stream_chat(
848        model.to_string(),
849        messages,
850        usage.chat_params(),
851        tools,
852        cache_only_toolbox,
853        RESEARCH_SEARCHER_MAX_ITERS,
854    );
855    let _abort = super::AbortOnDrop(abort);
856    let mut buf = String::new();
857    let mut failed = false;
858    while let Some(ev) = rx.recv().await {
859        match ev {
860            StreamEvent::Token(t) => buf.push_str(&t),
861            StreamEvent::Status(s) => send_stage(tx, ids, "verifier", format!("working — {s}")),
862            StreamEvent::ToolCall {
863                name,
864                arguments,
865                result,
866                ..
867            } => {
868                let summary = crate::app::tool_call_summary(&name, &arguments, &result);
869                send_stage(tx, ids, "verifier", format!("working — {summary}"));
870            }
871            StreamEvent::Error(e) => {
872                failed = true;
873                send_stage(tx, ids, "verifier", format!("error — {e}"));
874                break;
875            }
876            StreamEvent::Usage(reported) => usage.record(model, reported),
877            StreamEvent::Done => break,
878            StreamEvent::Reasoning(_) => {}
879        }
880    }
881    if !failed {
882        if buf.trim().is_empty() {
883            send_stage(
884                tx,
885                ids,
886                "verifier",
887                "error — no verification output returned",
888            );
889        } else {
890            send_stage(tx, ids, "verifier", "done — source checks complete");
891        }
892    }
893    buf
894}
895
896/// The shared plumbing for one searcher fan-out batch.
897struct SearcherBatch<'a> {
898    provider: &'a OpenRouter,
899    model: &'a str,
900    toolbox: &'a Arc<dyn ToolExecutor>,
901    usage: &'a Arc<ResearchUsage>,
902    tx: &'a mpsc::UnboundedSender<ResearchMsg>,
903    ids: &'a (String, String, String),
904    batch: &'a str,
905}
906
907/// Fan out one Searcher per question in parallel, sending a running
908/// `{done}/{total}` stage update as each finishes (in addition to each
909/// searcher's own live per-tool-call feed). Order of the returned findings
910/// doesn't matter (synthesis treats them as an unordered set). Each item is
911/// `(prompt, display)`: the full prompt goes to the agent, the short display
912/// label goes into the activity rows.
913async fn run_searchers(batch_ctx: SearcherBatch<'_>, items: &[(String, String)]) -> Vec<String> {
914    let SearcherBatch {
915        provider,
916        model,
917        toolbox,
918        usage,
919        tx,
920        ids,
921        batch,
922    } = batch_ctx;
923    let total = items.len();
924    send_stage(
925        tx,
926        ids,
927        format!("search {batch}"),
928        format!("working — 0/{total} agents complete"),
929    );
930    let mut set = tokio::task::JoinSet::new();
931    for (idx, (prompt, display)) in items.iter().cloned().enumerate() {
932        let provider = provider.clone();
933        let model = model.to_string();
934        let toolbox = toolbox.clone();
935        let usage = usage.clone();
936        let tx = tx.clone();
937        let ids = ids.clone();
938        let batch = batch.to_string();
939        set.spawn(async move {
940            let ctx = SearcherCtx {
941                toolbox,
942                usage,
943                tx: &tx,
944                ids: &ids,
945            };
946            let slot = SearcherSlot { batch, idx, total };
947            run_searcher(&provider, &model, &prompt, &display, ctx, slot).await
948        });
949    }
950    let mut done = 0usize;
951    let mut findings = Vec::with_capacity(total);
952    while let Some(res) = set.join_next().await {
953        done += 1;
954        send_stage(
955            tx,
956            ids,
957            format!("search {batch}"),
958            format!("working — {done}/{total} agents complete"),
959        );
960        findings.push(res.unwrap_or_else(|e| format!("[search agent panicked: {e}]")));
961    }
962    send_stage(
963        tx,
964        ids,
965        format!("search {batch}"),
966        format!("done — {done}/{total} agents complete"),
967    );
968    findings
969}
970
971/// Run the full pipeline and send exactly one final `Done` on `tx` (the
972/// caller's channel then closes naturally when this function returns and
973/// `tx` is dropped).
974/// Everything `run_research` needs to start a gated or ungated research job.
975pub struct ResearchOptions {
976    pub research_provider: OpenRouter,
977    pub research_model: String,
978    pub embedding_provider: OpenRouter,
979    pub embedding_model: String,
980    pub db_path: std::path::PathBuf,
981    pub topic: String,
982    pub reply_rx: Option<mpsc::UnboundedReceiver<String>>,
983    pub steer_rx: mpsc::UnboundedReceiver<String>,
984    pub toolbox: Arc<dyn ToolExecutor>,
985    pub tx: mpsc::UnboundedSender<ResearchMsg>,
986    pub session_id: String,
987    pub prompt_cache_key: String,
988    pub space_id: String,
989    pub space_name: String,
990}
991
992pub async fn run_research(mut opts: ResearchOptions) {
993    let result = run_research_inner(&mut opts).await;
994    let _ = opts.tx.send((
995        opts.session_id,
996        opts.space_id,
997        opts.space_name,
998        ResearchUpdate::Done(result),
999    ));
1000}
1001
1002/// Top-k chunks from the space's files already relevant to `topic`, for the
1003/// Planner's "already known" context — silently empty when embeddings are
1004/// unconfigured, embedding fails, or the space has no files (never blocks
1005/// `/research` on any of those).
1006async fn local_known_chunks(
1007    provider: &OpenRouter,
1008    embedding_model: &str,
1009    db_path: &std::path::Path,
1010    space_id: &str,
1011    topic: &str,
1012) -> Vec<String> {
1013    if embedding_model.trim().is_empty() {
1014        return Vec::new();
1015    }
1016    let Ok(mut vecs) = provider
1017        .embed(embedding_model, vec![topic.to_string()])
1018        .await
1019    else {
1020        return Vec::new();
1021    };
1022    if vecs.is_empty() {
1023        return Vec::new();
1024    }
1025    let query = vecs.remove(0);
1026    let Ok(conn) = crate::db::open_attached(db_path) else {
1027        return Vec::new();
1028    };
1029    crate::db::semantic_chunks(&conn, space_id, &query, 5)
1030        .map(|hits| {
1031            hits.into_iter()
1032                .map(|(name, loc, text, _)| format!("{name} ({loc}): {text}"))
1033                .collect()
1034        })
1035        .unwrap_or_default()
1036}
1037
1038/// One survey round's questions, sent to the UI and awaited: park on
1039/// `reply_rx` until the user answers (or the job is stopped and the channel
1040/// drops). Returns the user's trimmed reply, or `None` when the channel
1041/// closed.
1042async fn await_survey_reply(
1043    tx: &mpsc::UnboundedSender<ResearchMsg>,
1044    ids: &(String, String, String),
1045    reply_rx: &mut mpsc::UnboundedReceiver<String>,
1046    questions: &[String],
1047    round: u8,
1048) -> Option<String> {
1049    let _ = tx.send((
1050        ids.0.clone(),
1051        ids.1.clone(),
1052        ids.2.clone(),
1053        ResearchUpdate::SurveyReady {
1054            questions: questions.to_vec(),
1055            round,
1056        },
1057    ));
1058    reply_rx.recv().await.map(|r| r.trim().to_string())
1059}
1060
1061/// The conversational survey: the scoping agent asks what the user wants
1062/// (1–3 rounds), the user answers in chat, and the agent declares the survey
1063/// complete once it has enough — no phrase-matching in app code. An empty
1064/// reply (Enter on an empty input) skips ahead. Request failures (auth,
1065/// rate limits, network), malformed agent output, and a closed reply channel
1066/// all propagate as `Err` — the survey is a promised phase of the
1067/// conversational flow, not an optional garnish, so it must not silently
1068/// skip and still report success. Returns the (questions, answer) rounds
1069/// for the planner's context.
1070async fn run_user_survey(
1071    provider: &OpenRouter,
1072    model: &str,
1073    topic: &str,
1074    reply_rx: &mut mpsc::UnboundedReceiver<String>,
1075    tx: &mpsc::UnboundedSender<ResearchMsg>,
1076    ids: &(String, String, String),
1077    usage: &ResearchUsage,
1078) -> Result<Vec<(String, String)>, String> {
1079    let mut rounds: Vec<(String, String)> = Vec::new();
1080    let mut history = survey_messages(topic, &[]);
1081    let initial = complete_text(provider, model, history.clone(), usage)
1082        .await
1083        .map_err(|e| format!("survey agent failed: {e}"))?;
1084    history.push(ChatMessage::text("assistant", initial.clone()));
1085    let mut questions = parse_survey_reply(&initial);
1086    let mut raw = initial;
1087    let mut round: u8 = 1;
1088    loop {
1089        match questions {
1090            SurveyReply::Complete => return Ok(rounds),
1091            SurveyReply::Malformed => {
1092                return Err(format!(
1093                    "survey agent returned unusable output (raw reply: {raw:.200})"
1094                ));
1095            }
1096            SurveyReply::Questions(qs) if qs.is_empty() || round > MAX_SURVEY_ROUNDS => {
1097                return Ok(rounds);
1098            }
1099            SurveyReply::Questions(qs) => {
1100                let Some(reply) = await_survey_reply(tx, ids, reply_rx, &qs, round).await else {
1101                    // The reply channel closed — the job is being torn down
1102                    // (or a stop raced the parked gate). Don't keep planning
1103                    // as if the scoping happened.
1104                    return Err("survey cancelled — the reply channel closed".to_string());
1105                };
1106                if reply.is_empty() {
1107                    return Ok(rounds); // Empty Enter = skip the rest.
1108                }
1109                let asked = qs.join("\n");
1110                rounds.push((asked.clone(), reply.clone()));
1111                round += 1;
1112                if round > MAX_SURVEY_ROUNDS {
1113                    return Ok(rounds);
1114                }
1115                // Preserve the exact prior request and append only the
1116                // user's answer. Rebuilding one growing user string changes
1117                // the serialized history and forfeits the provider's cached
1118                // prefix on every survey round.
1119                history.push(ChatMessage::text(
1120                    "user",
1121                    format!("Questions asked:\n{asked}\nUser answer: {reply}"),
1122                ));
1123                raw = complete_text(provider, model, history.clone(), usage)
1124                    .await
1125                    .map_err(|e| format!("survey follow-up failed: {e}"))?;
1126                history.push(ChatMessage::text("assistant", raw.clone()));
1127                questions = parse_survey_reply(&raw);
1128            }
1129        }
1130    }
1131}
1132
1133/// The shared plumbing for the plan-approval agent.
1134struct PlanApprovalContext<'a> {
1135    provider: &'a OpenRouter,
1136    model: &'a str,
1137    topic: &'a str,
1138    usage: &'a ResearchUsage,
1139    tx: &'a mpsc::UnboundedSender<ResearchMsg>,
1140    ids: &'a (String, String, String),
1141}
1142
1143/// The plan-approval phase: present the plan, park for a chat reply, and fold
1144/// edits back in via the approval agent. An empty reply (Enter) or an
1145/// agent-recognized "approve" runs the questions as-is; edits are re-presented
1146/// once (`rework` cap) for a final approval. A second edit, a failed approval
1147/// call, malformed agent output, or a closed reply channel (job teardown
1148/// racing the parked gate) fails visibly (`Err`) — searchers never run on a
1149/// plan the user hasn't approved, and approval never fails open.
1150async fn await_plan_approval(
1151    ctx: PlanApprovalContext<'_>,
1152    questions: &mut Vec<PlanQuestion>,
1153    reply_rx: &mut mpsc::UnboundedReceiver<String>,
1154) -> Result<(), String> {
1155    let PlanApprovalContext {
1156        provider,
1157        model,
1158        topic,
1159        usage,
1160        tx,
1161        ids,
1162    } = ctx;
1163    let mut rework = false;
1164    loop {
1165        let _ = tx.send((
1166            ids.0.clone(),
1167            ids.1.clone(),
1168            ids.2.clone(),
1169            ResearchUpdate::PlanReady {
1170                questions: questions.clone(),
1171                rework,
1172            },
1173        ));
1174        let Some(reply) = reply_rx.recv().await else {
1175            // The reply channel closed without an approval (job teardown, or
1176            // a stop racing the parked gate). Fail visibly rather than
1177            // running searchers on a plan the user hasn't approved.
1178            return Err(
1179                "plan approval cancelled — the reply channel closed before the plan was approved"
1180                    .to_string(),
1181            );
1182        };
1183        if reply.trim().is_empty() {
1184            return Ok(()); // Enter on an empty input = approve.
1185        }
1186        let text = complete_text(
1187            provider,
1188            model,
1189            plan_approval_messages(topic, questions, &reply),
1190            usage,
1191        )
1192        .await
1193        .map_err(|e| format!("plan approval agent failed: {e}"))?;
1194        match parse_approval(&text) {
1195            Approval::Approved => return Ok(()),
1196            Approval::Revised(revised) if !revised.is_empty() => {
1197                if rework {
1198                    // Second edit: never silently folded in and run. The
1199                    // rework cap is one re-presentation — fail visibly
1200                    // rather than execute an unapproved revision.
1201                    return Err(
1202                        "plan was revised twice — rework cap reached; re-run /research \
1203                         with the final plan"
1204                            .to_string(),
1205                    );
1206                }
1207                *questions = revised;
1208                rework = true;
1209            }
1210            Approval::Revised(_) | Approval::Malformed => {
1211                return Err(format!(
1212                    "plan approval agent returned an unusable verdict (raw reply: {text:.200})"
1213                ));
1214            }
1215        }
1216    }
1217}
1218
1219// Long by design (pipeline orchestration).
1220#[allow(clippy::too_many_lines)]
1221async fn run_research_inner(opts: &mut ResearchOptions) -> Result<String, String> {
1222    let ids = &(
1223        opts.session_id.clone(),
1224        opts.space_id.clone(),
1225        opts.space_name.clone(),
1226    );
1227    let prompt_cache_key = opts.prompt_cache_key.clone();
1228    let ResearchOptions {
1229        research_provider,
1230        research_model,
1231        embedding_provider,
1232        embedding_model,
1233        db_path,
1234        topic,
1235        reply_rx,
1236        steer_rx,
1237        toolbox,
1238        tx,
1239        ..
1240    } = &mut *opts;
1241    let db_path = db_path.as_path();
1242    let usage = Arc::new(ResearchUsage {
1243        db_path: db_path.to_path_buf(),
1244        session_id: ids.0.clone(),
1245        prompt_cache_key,
1246        space_id: ids.1.clone(),
1247        backend: research_provider.backend_tag().name(),
1248    });
1249    // Gather the planning context (local chunks + a web landscape survey)
1250    // concurrently with the conversational survey — the user answers while
1251    // the ground truth arrives, so the plan targets real gaps. The two
1252    // gatherers also run concurrently with each other.
1253    let gather_task = {
1254        let provider = embedding_provider.clone();
1255        let model = embedding_model.clone();
1256        let db_path = db_path.to_path_buf();
1257        let space_id = ids.1.clone();
1258        let topic = topic.clone();
1259        let research_provider = research_provider.clone();
1260        let research_model = research_model.clone();
1261        let toolbox = toolbox.clone();
1262        let usage = usage.clone();
1263        let tx = tx.clone();
1264        let ids = ids.clone();
1265        tokio::spawn(async move {
1266            let known =
1267                async { local_known_chunks(&provider, &model, &db_path, &space_id, &topic).await };
1268            let survey = async {
1269                send_stage(
1270                    &tx,
1271                    &ids,
1272                    "web survey",
1273                    "working — mapping the topic, debates, evidence, and source landscape",
1274                );
1275                let survey_question = format!(
1276                    "Conduct a broad preliminary survey of this research topic before planning: {topic}. \
1277                     Identify the major concepts, current debates, useful source types, and important \
1278                     evidence gaps."
1279                );
1280                let ctx = SearcherCtx {
1281                    toolbox,
1282                    usage,
1283                    tx: &tx,
1284                    ids: &ids,
1285                };
1286                let slot = SearcherSlot {
1287                    batch: "web survey".to_string(),
1288                    idx: 0,
1289                    total: 1,
1290                };
1291                let survey = run_searcher(
1292                    &research_provider,
1293                    &research_model,
1294                    &survey_question,
1295                    &survey_question,
1296                    ctx,
1297                    slot,
1298                )
1299                .await;
1300                persist_session_sources(&db_path, &ids.0, std::slice::from_ref(&survey));
1301                survey
1302            };
1303            let (known, survey) = tokio::join!(known, survey);
1304            (known, survey)
1305        })
1306    };
1307    // Abort-on-drop guard: dropping the JoinHandle alone would detach the
1308    // gather task instead of cancelling it, so web calls and DB writes could
1309    // keep running after the user stops research. Aborting the outer task
1310    // drops this handle, which cancels the gather task.
1311    let gather_guard = super::AbortOnDrop(gather_task.abort_handle());
1312
1313    // Phase 1: the conversational survey (skipped entirely for `/research!`).
1314    // Failures propagate visibly — the survey is a promised phase, and a
1315    // silent skip would report success without the user's scoping input.
1316    let answers: Vec<(String, String)> = if let Some(rx) = reply_rx.as_mut() {
1317        run_user_survey(
1318            research_provider,
1319            research_model,
1320            topic,
1321            rx,
1322            tx,
1323            ids,
1324            &usage,
1325        )
1326        .await
1327        .map_err(|e| {
1328            send_stage(tx, ids, "survey", format!("error — {e}"));
1329            e
1330        })?
1331    } else {
1332        Vec::new()
1333    };
1334
1335    // Join the concurrent gathering and fold it into planning context. A
1336    // panic (or cancellation) inside the gather task terminates the job with
1337    // context — defaulting to empty context would mask a programming failure
1338    // and let the pipeline report success on invented ground truth.
1339    let (known, web_survey) = match gather_task.await {
1340        Ok(v) => v,
1341        Err(e) if e.is_panic() => return Err(format!("context gathering panicked: {e}")),
1342        Err(e) => return Err(format!("context gathering was cancelled: {e}")),
1343    };
1344    drop(gather_guard);
1345    let mut planning_context = known;
1346    if !web_survey.is_empty() && !web_survey.starts_with('[') {
1347        planning_context.push(format!("Preliminary web survey:\n{web_survey}"));
1348        send_stage(
1349            tx,
1350            ids,
1351            "web survey",
1352            "done — landscape mapped for planning",
1353        );
1354    } else {
1355        send_stage(
1356            tx,
1357            ids,
1358            "web survey",
1359            "error — survey failed; planning from local context only",
1360        );
1361    }
1362
1363    send_stage(
1364        tx,
1365        ids,
1366        "planner",
1367        "working — decomposing the surveyed landscape into focused questions",
1368    );
1369    let mut questions = match plan(
1370        research_provider,
1371        research_model,
1372        topic,
1373        &answers,
1374        &planning_context,
1375        &usage,
1376    )
1377    .await
1378    {
1379        Ok(questions) => questions,
1380        Err(e) => {
1381            send_stage(tx, ids, "planner", format!("error — {e}"));
1382            return Err(e);
1383        }
1384    };
1385    send_stage(
1386        tx,
1387        ids,
1388        "planner",
1389        format!("done — proposed {} questions", questions.len()),
1390    );
1391
1392    // Phase 2: plan approval — reply in chat to approve or change it. The
1393    // gate parks with no timeout; Ctrl+↑ then Ctrl+X (the live view's stop)
1394    // is the escape hatch. Skipped entirely for `/research!`. Approval is
1395    // fail-closed: any failure here returns `Err` and research stops rather
1396    // than running searchers on an unapproved plan.
1397    if let Some(rx) = reply_rx.as_mut() {
1398        await_plan_approval(
1399            PlanApprovalContext {
1400                provider: research_provider,
1401                model: research_model,
1402                topic,
1403                usage: &usage,
1404                tx,
1405                ids,
1406            },
1407            &mut questions,
1408            rx,
1409        )
1410        .await?;
1411    }
1412
1413    let pinned = rusqlite::Connection::open(db_path)
1414        .ok()
1415        .and_then(|conn| crate::db::pinned_urls(&conn, &ids.0).ok())
1416        .unwrap_or_default();
1417
1418    let mut findings: Vec<String> = if !web_survey.is_empty() && !web_survey.starts_with('[') {
1419        // Successful web survey: seed it into the findings so synthesis can
1420        // cite the landscape overview alongside each answer's own citations.
1421        vec![format!("--- Survey overview ---\n{web_survey}")]
1422    } else {
1423        Vec::new()
1424    };
1425    // Each searcher gets the full question block as its prompt (detail is
1426    // functional) but only the bare question as its live display label, so
1427    // the activity rows stay short.
1428    let searcher_items: Vec<(String, String)> = questions
1429        .iter()
1430        .map(|q| (q.prompt(topic), q.question.clone()))
1431        .collect();
1432    findings.extend(
1433        run_searchers(
1434            SearcherBatch {
1435                provider: research_provider,
1436                model: research_model,
1437                toolbox,
1438                usage: &usage,
1439                tx,
1440                ids,
1441                batch: "round 1",
1442            },
1443            &searcher_items,
1444        )
1445        .await,
1446    );
1447    persist_session_sources(db_path, &ids.0, &findings);
1448
1449    // One stage row per drained steer, keyed by a job-global sequence number
1450    // (`steer #N` — N = the steer's 1-based queue position, which equals its
1451    // drain order). The stage upsert matches rows by label, so the key must
1452    // never be user text: duplicate, prefix-of-each-other, or LIKE-wildcard
1453    // (`%`/`_`) steer text would collapse or hijack rows and leave picked-up
1454    // steers looking queued (the live popup derives picked-up from the same
1455    // numbered keys).
1456    let mut steer_seq: usize = 0;
1457    let steers = drain_steers(steer_rx);
1458    if !steers.is_empty() {
1459        let steer_items: Vec<(String, String)> =
1460            steers.iter().map(|s| (s.clone(), s.clone())).collect();
1461        for s in &steers {
1462            steer_seq += 1;
1463            send_stage(tx, ids, format!("steer #{steer_seq}"), s.clone());
1464        }
1465        let steered = run_searchers(
1466            SearcherBatch {
1467                provider: research_provider,
1468                model: research_model,
1469                toolbox,
1470                usage: &usage,
1471                tx,
1472                ids,
1473                batch: "round 1 steer",
1474            },
1475            &steer_items,
1476        )
1477        .await;
1478        persist_session_sources(db_path, &ids.0, &steered);
1479        findings.extend(steered);
1480    }
1481
1482    send_stage(
1483        tx,
1484        ids,
1485        "synthesizer",
1486        format!("working — combining {} agent findings", findings.len()),
1487    );
1488    let mut synthesizer_history =
1489        synthesizer_messages(topic, &crate::tools::dedup_source_lines(&findings), &pinned);
1490    let mut draft = complete_agent(
1491        research_provider,
1492        research_model,
1493        synthesizer_history.clone(),
1494        &usage,
1495        tx,
1496        ids,
1497        "synthesizer",
1498    )
1499    .await?;
1500    send_stage(tx, ids, "synthesizer", "done — draft assembled");
1501
1502    send_stage(
1503        tx,
1504        ids,
1505        "critic",
1506        "working — checking coverage and contradictions",
1507    );
1508    let mut critique = parse_critique(
1509        &complete_agent(
1510            research_provider,
1511            research_model,
1512            critic_messages(topic, &draft),
1513            &usage,
1514            tx,
1515            ids,
1516            "critic",
1517        )
1518        .await?,
1519    );
1520    let critic_detail = match &critique {
1521        Critique::Satisfied => "done — draft is sufficiently complete".to_string(),
1522        // Quick win: surface the actual gap questions, not just a count — the
1523        // follow-up searchers are about to investigate exactly these.
1524        Critique::Gaps(gaps) => {
1525            let list = gaps
1526                .iter()
1527                .enumerate()
1528                .map(|(i, g)| format!("{}. {g}", i + 1))
1529                .collect::<Vec<_>>()
1530                .join("\n");
1531            format!("done — found {} coverage gaps:\n{list}", gaps.len())
1532        }
1533        Critique::Contradiction(_) => "done — found a source contradiction".to_string(),
1534    };
1535    send_stage(tx, ids, "critic", critic_detail);
1536
1537    if let Critique::Gaps(gaps) = &critique {
1538        let more = run_searchers(
1539            SearcherBatch {
1540                provider: research_provider,
1541                model: research_model,
1542                toolbox,
1543                usage: &usage,
1544                tx,
1545                ids,
1546                batch: "round 2",
1547            },
1548            &gaps
1549                .iter()
1550                .map(|g| (g.clone(), g.clone()))
1551                .collect::<Vec<_>>(),
1552        )
1553        .await;
1554        persist_session_sources(db_path, &ids.0, &more);
1555        let mut follow_up_findings = more;
1556
1557        let steers = drain_steers(steer_rx);
1558        if !steers.is_empty() {
1559            let steer_items: Vec<(String, String)> =
1560                steers.iter().map(|s| (s.clone(), s.clone())).collect();
1561            for s in &steers {
1562                steer_seq += 1;
1563                send_stage(tx, ids, format!("steer #{steer_seq}"), s.clone());
1564            }
1565            let steered = run_searchers(
1566                SearcherBatch {
1567                    provider: research_provider,
1568                    model: research_model,
1569                    toolbox,
1570                    usage: &usage,
1571                    tx,
1572                    ids,
1573                    batch: "round 2 steer",
1574                },
1575                &steer_items,
1576            )
1577            .await;
1578            persist_session_sources(db_path, &ids.0, &steered);
1579            follow_up_findings.extend(steered);
1580        }
1581        findings.extend(follow_up_findings.clone());
1582
1583        send_stage(
1584            tx,
1585            ids,
1586            "synthesizer r2",
1587            "working — merging follow-up findings",
1588        );
1589        // Continue the original synthesis conversation instead of
1590        // rebuilding one giant user message. The old request remains an
1591        // exact prefix and only the new findings are appended.
1592        synthesizer_history.push(ChatMessage::text("assistant", draft.clone()));
1593        synthesizer_history.push(ChatMessage::text(
1594            "user",
1595            format!(
1596                "Additional searcher findings to incorporate:\n{}",
1597                crate::tools::dedup_source_lines(&follow_up_findings).join("\n\n")
1598            ),
1599        ));
1600        draft = complete_agent(
1601            research_provider,
1602            research_model,
1603            synthesizer_history.clone(),
1604            &usage,
1605            tx,
1606            ids,
1607            "synthesizer r2",
1608        )
1609        .await?;
1610        send_stage(tx, ids, "synthesizer r2", "done — revised draft assembled");
1611        send_stage(
1612            tx,
1613            ids,
1614            "critic r2",
1615            "working — reviewing the revised draft",
1616        );
1617        critique = parse_critique(
1618            &complete_agent(
1619                research_provider,
1620                research_model,
1621                critic_messages(topic, &draft),
1622                &usage,
1623                tx,
1624                ids,
1625                "critic r2",
1626            )
1627            .await?,
1628        );
1629        let detail = match &critique {
1630            Critique::Satisfied => "done — revised draft is complete".to_string(),
1631            // Same shape as round 1: the remaining gap questions, not a count.
1632            Critique::Gaps(gaps) => {
1633                let list = gaps
1634                    .iter()
1635                    .enumerate()
1636                    .map(|(i, g)| format!("{}. {g}", i + 1))
1637                    .collect::<Vec<_>>()
1638                    .join("\n");
1639                format!("done — {} gaps remain:\n{list}", gaps.len())
1640            }
1641            Critique::Contradiction(_) => "done — contradiction remains".to_string(),
1642        };
1643        send_stage(tx, ids, "critic r2", detail);
1644    }
1645
1646    if let Critique::Contradiction(desc) = &critique {
1647        send_stage(
1648            tx,
1649            ids,
1650            "resolver",
1651            "working — reconciling conflicting source claims",
1652        );
1653        let resolution = complete_agent(
1654            research_provider,
1655            research_model,
1656            resolver_messages(topic, &draft, &findings, desc),
1657            &usage,
1658            tx,
1659            ids,
1660            "resolver",
1661        )
1662        .await?;
1663        draft.push_str("\n\n");
1664        draft.push_str(&resolution);
1665        send_stage(tx, ids, "resolver", "done — contradiction reconciled");
1666    }
1667
1668    send_stage(
1669        tx,
1670        ids,
1671        "verifier",
1672        "working — checking claims, citations, and direct quotes",
1673    );
1674    let verify_toolbox = Arc::new(
1675        ToolBox::research(
1676            None,
1677            None,
1678            "auto".to_string(),
1679            Vec::new(),
1680            Some(db_path.to_path_buf()),
1681        )
1682        .cache_only(),
1683    );
1684    let verified_raw = verify_with_quote_check(
1685        research_provider,
1686        research_model,
1687        verifier_messages(topic, &draft, &findings),
1688        verify_toolbox,
1689        &usage,
1690        tx,
1691        ids,
1692    )
1693    .await;
1694    let verified = if verified_raw.trim().is_empty() {
1695        draft.clone()
1696    } else {
1697        verified_raw
1698    };
1699
1700    send_stage(
1701        tx,
1702        ids,
1703        "writer",
1704        "working — polishing structure and citations",
1705    );
1706    match complete_text(
1707        research_provider,
1708        research_model,
1709        writer_messages(topic, &verified, &pinned),
1710        &usage,
1711    )
1712    .await
1713    {
1714        Ok(report) => {
1715            send_stage(tx, ids, "writer", "done — final report ready");
1716            Ok(report)
1717        }
1718        Err(e) => {
1719            send_stage(tx, ids, "writer", format!("error — {e}"));
1720            Err(e)
1721        }
1722    }
1723}
1724
1725/// Link every URL cited in `findings` into the session's source bundle
1726/// (they're already in `web_cache` from `fetch_url`'s write-through). Best
1727/// effort — a failed write never disturbs the pipeline.
1728fn persist_session_sources(db_path: &std::path::Path, session_id: &str, findings: &[String]) {
1729    let url_norms = crate::tools::cited_url_norms(findings);
1730    if url_norms.is_empty() {
1731        return;
1732    }
1733    if let Ok(conn) = rusqlite::Connection::open(db_path) {
1734        let _ = crate::db::add_session_sources(&conn, session_id, &url_norms);
1735    }
1736}
1737
1738impl super::App {
1739    /// `/research <topic>`: run the multi-agent research pipeline in a new
1740    /// background session. One job at a time. `/research! <topic>` skips the
1741    /// plan-approval gate.
1742    pub fn start_research(&mut self, topic: &str) {
1743        self.start_research_with_gate(topic, true);
1744    }
1745
1746    /// `/steer <text>`: queue an extra instruction for the running research
1747    /// job, picked up at the next round boundary. No-op with a status message
1748    /// if no research job is running.
1749    pub fn steer_research(&mut self, text: &str) {
1750        if text.is_empty() {
1751            self.push_status("usage: /steer <what to also look into>".to_string());
1752            return;
1753        }
1754        match &self.research_steer_tx {
1755            // Hard bound: refuse once the queue is full — this also bounds
1756            // the unbounded channel and the retained log.
1757            Some(_) if self.research_steer_log.len() >= MAX_QUEUED_STEERS => {
1758                self.push_status(format!(
1759                    "steer queue full ({MAX_QUEUED_STEERS} pending) — wait for the next round"
1760                ));
1761            }
1762            Some(tx) if tx.send(text.to_string()).is_ok() => {
1763                // Keep a log so the live popup can show what's queued vs.
1764                // already picked up by the pipeline. Entries carry their
1765                // 1-based queue position (positions are never renumbered, so
1766                // acknowledged entries can be dropped without shifting the
1767                // popup's view), and entries the pipeline has drained are
1768                // removed here — a long job can't retain unbounded steer
1769                // text without backpressure.
1770                let pos = self
1771                    .research_steer_acked
1772                    .iter()
1773                    .chain(self.research_steer_log.iter().map(|(p, _)| p))
1774                    .max()
1775                    .map_or(0, |&p| p)
1776                    + 1;
1777                self.research_steer_log.push((pos, text.to_string()));
1778                self.research_steer_log
1779                    .retain(|(p, _)| !self.research_steer_acked.contains(p));
1780                self.push_status(format!("queued steer: {text}"));
1781            }
1782            _ => self.push_status("no research job is running".to_string()),
1783        }
1784    }
1785
1786    /// `/research` with no topic: distill one from the last ~20 chat turns
1787    /// (one cheap completion, same background-channel shape as
1788    /// `maybe_generate_title`) then hand it to `start_research_with_gate`
1789    /// exactly as if it had been typed — the existing plan-approval gate
1790    /// still lets you bail or edit before searchers run.
1791    pub fn start_research_from_chat(&mut self) {
1792        if self.research_topic_rx.is_some() {
1793            self.push_status("already scoping a topic from this chat…".to_string());
1794            return;
1795        }
1796        if self.research_rx.is_some() {
1797            self.push_status("a research job is already running".to_string());
1798            return;
1799        }
1800        let Some(model) = self.current_model.clone() else {
1801            self.push_status("no model configured — set one in /login or /model".to_string());
1802            return;
1803        };
1804        let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
1805            self.push_status(format!(
1806                "model backend unavailable: {model} — pick another with /model"
1807            ));
1808            return;
1809        };
1810        let convo: String = self
1811            .messages
1812            .iter()
1813            .filter(|m| m.role == "user" || m.role == "assistant")
1814            .rev()
1815            .take(20)
1816            .collect::<Vec<_>>()
1817            .into_iter()
1818            .rev()
1819            .map(|m| {
1820                format!(
1821                    "{}: {}",
1822                    m.role,
1823                    m.content.chars().take(500).collect::<String>()
1824                )
1825            })
1826            .collect::<Vec<_>>()
1827            .join("\n");
1828        if convo.trim().is_empty() {
1829            self.push_status(
1830                "nothing to scope yet — chat first, or use /research <topic>".to_string(),
1831            );
1832            return;
1833        }
1834        let (tx, rx) = mpsc::unbounded_channel();
1835        self.research_topic_rx = Some(rx);
1836        self.push_status("scoping a research topic from this chat…".to_string());
1837        tokio::spawn(async move {
1838            let prompt = format!(
1839                "Based on this conversation, reply with ONLY a single-line research topic \
1840                 or question suitable for a multi-source research task. No preamble, no \
1841                 quotes, no markdown.\n\n{convo}"
1842            );
1843            let msgs = vec![ChatMessage::text("user", prompt)];
1844            let result = provider
1845                .complete(&raw_model, msgs)
1846                .await
1847                .map(|s| s.trim().to_string())
1848                .map_err(|e| e.to_string());
1849            let _ = tx.send(result);
1850        });
1851    }
1852
1853    /// The topic-distillation job finished: start research with it, or
1854    /// report the failure. `None` = channel closed without a result.
1855    pub fn on_research_topic_derived(&mut self, r: Option<Result<String, String>>) {
1856        self.research_topic_rx = None;
1857        let Some(result) = r else { return };
1858        match result {
1859            Ok(topic) if !topic.is_empty() => self.start_research_with_gate(&topic, true),
1860            Ok(_) => {
1861                self.push_status("couldn't derive a topic — try /research <topic>".to_string());
1862            }
1863            Err(e) => self.push_status(format!("topic scoping failed: {e}")),
1864        }
1865    }
1866
1867    // Long by design (gate setup + mirroring).
1868    #[allow(clippy::too_many_lines)]
1869    pub fn start_research_with_gate(&mut self, topic: &str, gated: bool) {
1870        let topic = topic.trim().to_string();
1871        if topic.is_empty() {
1872            self.push_status("usage: /research <topic>".to_string());
1873            return;
1874        }
1875        if self.research_rx.is_some() {
1876            self.push_status("a research job is already running".to_string());
1877            return;
1878        }
1879        // Research inherits the session's model (or the global default) —
1880        // there is no separate researcher-model setting anymore.
1881        let Some(model) = self.current_model.clone() else {
1882            self.push_status("no model configured — set one in /login or /model".to_string());
1883            return;
1884        };
1885        let Some((provider, raw_research_model)) = self.resolve_model_backend(&model) else {
1886            self.push_status(format!(
1887                "model backend unavailable: {model} — pick another with /model"
1888            ));
1889            return;
1890        };
1891        let title = super::chat::title_from(&topic);
1892        // Hygiene: no gate or reply channel from a previous job may linger.
1893        self.set_survey_gate(None);
1894        self.survey_reply_tx = None;
1895
1896        // Check if there's a conversation to migrate to the research session.
1897        let parent_id = self.session.as_ref().and_then(|s| {
1898            let has_content = self
1899                .messages
1900                .iter()
1901                .any(|m| m.role == "user" || m.role == "assistant");
1902            if has_content {
1903                Some(s.id.clone())
1904            } else {
1905                None
1906            }
1907        });
1908        let parent_title = parent_id
1909            .as_ref()
1910            .and_then(|pid| self.db.get_session(pid).ok()?.map(|s| s.title));
1911
1912        let session =
1913            match self
1914                .db
1915                .create_session(&title, &model, &self.active_space.id, "research")
1916            {
1917                Ok(s) => s,
1918                Err(e) => {
1919                    self.push_status(format!("could not start research session: {e}"));
1920                    return;
1921                }
1922            };
1923
1924        if let Some(ref pid) = parent_id {
1925            let _ = self.db.set_research_parent(&session.id, pid);
1926
1927            // Build compacted context from the original conversation
1928            let compact_summary = self
1929                .session
1930                .as_ref()
1931                .and_then(|s| s.compact_summary.clone());
1932            let compact_through = self
1933                .session
1934                .as_ref()
1935                .map_or(0, |s| s.compact_through as usize);
1936            let mut ctx = String::new();
1937            if let Some(ref summary) = compact_summary {
1938                ctx.push_str("Previous conversation summary:\n");
1939                ctx.push_str(summary);
1940            }
1941            let tail: Vec<&crate::db::Message> = self.messages[compact_through..]
1942                .iter()
1943                .filter(|m| m.role == "user" || m.role == "assistant")
1944                .collect();
1945            if !tail.is_empty() {
1946                if !ctx.is_empty() {
1947                    ctx.push_str("\n\n");
1948                }
1949                ctx.push_str("Recent messages:\n");
1950                for m in tail {
1951                    let t = m.content.chars().take(300).collect::<String>();
1952                    let _ = writeln!(ctx, "{}: {t}", m.role);
1953                }
1954            }
1955            let msg = if ctx.is_empty() {
1956                format!("/research {topic}")
1957            } else {
1958                format!("/research {topic}\n\n{ctx}")
1959            };
1960            let _ = self.db.add_user_message(&session.id, &msg);
1961
1962            // Link message in the original session
1963            let _ = self.db.insert_message(
1964                pid,
1965                "session_link",
1966                &format!("{}\n🔗 Research session started for: {topic}", session.id),
1967                None,
1968                None,
1969                None,
1970                None,
1971                None,
1972                None,
1973            );
1974
1975            // Link message in the research session back to the original
1976            let back_title = parent_title.as_deref().unwrap_or("previous chat");
1977            let _ = self.db.insert_message(
1978                &session.id,
1979                "session_link",
1980                &format!("{pid}\n↩ Originally from: {back_title}"),
1981                None,
1982                None,
1983                None,
1984                None,
1985                None,
1986                None,
1987            );
1988
1989            self.messages = self.db.load_messages(&session.id).unwrap_or_default();
1990        } else {
1991            let _ = self
1992                .db
1993                .add_user_message(&session.id, &format!("/research {topic}"));
1994            self.messages = self.db.load_messages(&session.id).unwrap_or_default();
1995        }
1996
1997        let searxng_url =
1998            (!self.searxng_url.trim().is_empty()).then(|| self.searxng_url.trim().to_string());
1999        let langsearch_key = (!self.langsearch_key.trim().is_empty())
2000            .then(|| self.langsearch_key.trim().to_string());
2001        let toolbox = Arc::new(ToolBox::research(
2002            searxng_url,
2003            langsearch_key,
2004            self.search_provider.clone(),
2005            self.blocked_domains(),
2006            Some(self.space.db_path()),
2007        ));
2008
2009        let (tx, rx) = mpsc::unbounded_channel();
2010        self.research_rx = Some(rx);
2011        self.research_running = Some((session.id.clone(), topic.clone()));
2012        self.push_status(format!("researching: {topic} · Ctrl+↑ agents"));
2013
2014        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
2015        self.research_steer_tx = Some(steer_tx);
2016        self.research_steer_log.clear();
2017        self.research_steer_acked.clear();
2018        self.research_stage_rows.clear();
2019        // Capture the mode at job start: plan-message and artifact
2020        // persistence follow this, never a mid-job incognito toggle.
2021        self.research_incognito = self.incognito;
2022
2023        // The conversational gates (survey + plan approval) ride one reply
2024        // channel: the pipeline parks on `reply_rx`, the App arms a gate on
2025        // each SurveyReady/PlanReady. Ungated (`/research!`, watches) skips
2026        // both phases entirely.
2027        let reply_rx = if gated {
2028            let (reply_tx, reply_rx) = mpsc::unbounded_channel();
2029            self.survey_reply_tx = Some(reply_tx);
2030            Some(reply_rx)
2031        } else {
2032            None
2033        };
2034
2035        let space_id = self.active_space.id.clone();
2036        let space_name = self.active_space.name.clone();
2037        self.session = Some(session.clone());
2038        self.refresh_memory_snapshot();
2039        let prompt_cache_key = format!("research:{}", self.prompt_cache_key_for(&session.id));
2040        self.context_total = None;
2041        // Selection + scroll point into the previous session's lines; the
2042        // view resets them on `ViewportReset`.
2043        self.push_viewport_reset();
2044        self.refresh_toolbox();
2045
2046        let embedding_model = self.embedding_model.trim().to_string();
2047        let (embedding_provider, raw_embedding_model) = self
2048            .resolve_model_backend(&embedding_model)
2049            .unwrap_or_else(|| (provider.clone(), embedding_model.clone()));
2050
2051        let task = tokio::spawn(run_research(crate::app::research::ResearchOptions {
2052            research_provider: provider,
2053            research_model: raw_research_model,
2054            embedding_provider,
2055            embedding_model: raw_embedding_model,
2056            db_path: self.space.db_path(),
2057            topic,
2058            reply_rx,
2059            steer_rx,
2060            toolbox,
2061            tx,
2062            session_id: session.id,
2063            prompt_cache_key,
2064            space_id,
2065            space_name,
2066        }));
2067        self.research_abort = Some(task.abort_handle());
2068    }
2069
2070    /// Abort the active research pipeline, including survey/searcher/tool
2071    /// streams spawned under its orchestration task.
2072    pub fn stop_research(&mut self) {
2073        self.set_survey_gate(None);
2074        if let Some(abort) = self.research_abort.take() {
2075            abort.abort();
2076        }
2077        if self.research_rx.take().is_some() {
2078            if let Some((session_id, _)) = self.research_running.take() {
2079                let _ = self.db.upsert_research_stage_message(
2080                    &session_id,
2081                    "research",
2082                    "stopped by user",
2083                );
2084            }
2085            self.set_survey_gate(None);
2086            self.survey_reply_tx = None;
2087            self.research_steer_tx = None;
2088            // Retained steer state belongs to the job: drop it so a long
2089            // session can't keep unbounded text after research stops.
2090            self.research_steer_log.clear();
2091            self.research_steer_acked.clear();
2092            self.push_status("research stopped".to_string());
2093        } else {
2094            self.push_status("no research job is running".to_string());
2095        }
2096    }
2097
2098    /// Whether the survey gate (clarifying questions or plan approval) is
2099    /// armed for the currently viewed session — the only case where Enter is
2100    /// intercepted and routed to the pipeline instead of a normal chat send.
2101    /// A gate in another session must never swallow typing (the old
2102    /// cross-session hijack).
2103    pub fn survey_gate_targets_current_session(&self) -> bool {
2104        self.survey_gate
2105            .as_ref()
2106            .is_some_and(|g| self.session.as_ref().is_some_and(|s| s.id == g.session_id))
2107    }
2108
2109    /// Restore an actionable gate row after loading its session. Normal jobs
2110    /// already load the persisted row, while incognito jobs recover it from
2111    /// `SurveyGate` without writing private content to the database.
2112    pub fn restore_survey_gate_prompt(&mut self) {
2113        let pending = self.survey_gate.as_ref().and_then(|gate| {
2114            self.session
2115                .as_ref()
2116                .filter(|session| session.id == gate.session_id)
2117                .map(|_| (gate.prompt_role.clone(), gate.prompt_content.clone()))
2118        });
2119        let Some((role, content)) = pending else {
2120            return;
2121        };
2122        if self
2123            .messages
2124            .iter()
2125            .any(|message| message.role == role && message.content == content)
2126        {
2127            return;
2128        }
2129        self.messages.push(crate::db::Message {
2130            role,
2131            content,
2132            model: None,
2133            reasoning: None,
2134            tokens: None,
2135            secs: None,
2136            cost: None,
2137            phrase: None,
2138            persona: None,
2139            created_at: None,
2140        });
2141    }
2142
2143    /// Route a chat reply into the parked survey gate (survey answer or plan
2144    /// approval/edit). Records the reply as a `gate_reply` in the session —
2145    /// it renders in the transcript like a user message but is never replayed
2146    /// to the model, since the survey/plan rows it answers are excluded too:
2147    /// a bare "the second option" or "drop Q2" must not leak into model
2148    /// history without its context.
2149    /// Arm or clear the parked gate, emitting a `Gate` event either way.
2150    /// The event carries the session id so consumers can compare it against
2151    /// the viewed session — a gate in another session must never swallow
2152    /// typing.
2153    pub fn set_survey_gate(&mut self, gate: Option<SurveyGate>) {
2154        let state = gate.as_ref().map(|g| super::GateState {
2155            session_id: g.session_id.clone(),
2156            phase: g.phase.clone(),
2157        });
2158        self.survey_gate = gate;
2159        self.pending_events.push_back(super::AppEvent::Gate(state));
2160    }
2161
2162    pub fn reply_to_survey_gate(&mut self, text: &str) {
2163        let Some(gate) = self.survey_gate.take() else {
2164            return;
2165        };
2166        self.pending_events.push_back(super::AppEvent::Gate(None));
2167        // Persist before acknowledging: the pipeline and the transcript must
2168        // never incorporate a reply the database didn't record (a locked or
2169        // full db would otherwise silently lose the answer on reload). On a
2170        // persistence failure the gate stays armed and the composer is
2171        // restored so the user can retry.
2172        let saved_id = if !text.trim().is_empty() && !self.research_incognito {
2173            match self.db.add_gate_reply_message(&gate.session_id, text) {
2174                Ok(id) => Some(id),
2175                Err(e) => {
2176                    self.set_survey_gate(Some(gate));
2177                    self.push_composer_set(text);
2178                    self.push_status(format!("couldn't save your reply — {e}"));
2179                    return;
2180                }
2181            }
2182        } else {
2183            None
2184        };
2185        if gate.reply_tx.send(text.to_string()).is_err() {
2186            // Delivery failed: roll back the persisted reply so a retry
2187            // can't duplicate it in the transcript, then put the text back
2188            // in the composer rather than eating the user's typing.
2189            let rollback_error = saved_id.and_then(|id| self.db.delete_message(&id).err());
2190            self.push_composer_set(text);
2191            self.push_status(match rollback_error {
2192                Some(e) => format!(
2193                    "the job stopped waiting and the saved reply could not be rolled back: {e} — text restored to the composer"
2194                ),
2195                None => "the job is no longer waiting for a reply — text restored to the composer"
2196                    .to_string(),
2197            });
2198            return;
2199        }
2200        if !text.trim().is_empty()
2201            && self
2202                .session
2203                .as_ref()
2204                .is_some_and(|s| s.id == gate.session_id)
2205        {
2206            self.messages.push(crate::db::Message {
2207                role: "gate_reply".to_string(),
2208                content: text.to_string(),
2209                model: None,
2210                reasoning: None,
2211                tokens: None,
2212                secs: None,
2213                cost: None,
2214                phrase: None,
2215                persona: None,
2216                created_at: None,
2217            });
2218        }
2219        match gate.phase {
2220            SurveyPhase::Clarify { round } => {
2221                self.push_status(format!(
2222                    "answer noted (round {round}) — checking for follow-ups… · Ctrl+↑ agents"
2223                ));
2224            }
2225            SurveyPhase::Approve { rework } => self.push_status(if rework {
2226                "revision folded in — continuing… · Ctrl+↑ agents".to_string()
2227            } else {
2228                "plan reply sent — continuing… · Ctrl+↑ agents".to_string()
2229            }),
2230        }
2231    }
2232
2233    /// Persist a stage row and keep both in-memory views in sync: the
2234    /// viewed transcript (`self.messages`, only when the job's session is
2235    /// viewed) and the job's stage-row mirror (`research_stage_rows`, always
2236    /// — the live popup renders from it without a db read per frame). One
2237    /// row per label, updated in place. Also used for error rows (plan
2238    /// record / report file) so a save failure is visible immediately,
2239    /// not only after a reload.
2240    fn mirror_stage(&mut self, session_id: &str, label: &str, detail: &str) {
2241        let _ = self
2242            .db
2243            .upsert_research_stage_message(session_id, label, detail);
2244        let text = crate::db::stage_content(label, detail);
2245        let prefix = format!("{label}:");
2246        // Job-level mirror: the live popup's single source of truth.
2247        if let Some(row) = self
2248            .research_stage_rows
2249            .iter_mut()
2250            .rev()
2251            .find(|c| c.as_str() == label || c.starts_with(prefix.as_str()))
2252        {
2253            row.clone_from(&text);
2254        } else {
2255            self.research_stage_rows.push(text.clone());
2256        }
2257        if self.session.as_ref().is_some_and(|s| s.id == session_id) {
2258            if let Some(row) = self.messages.iter_mut().rev().find(|m| {
2259                m.role == "research_stage" && (m.content == label || m.content.starts_with(&prefix))
2260            }) {
2261                row.content = text;
2262                // Stage rows update in place, so message count does not
2263                // change and the wrapped transcript cache would otherwise
2264                // keep rendering stale progress.
2265                self.push_history_invalidated();
2266            } else {
2267                self.messages.push(crate::db::Message {
2268                    role: "research_stage".to_string(),
2269                    content: text,
2270                    model: None,
2271                    reasoning: None,
2272                    tokens: None,
2273                    secs: None,
2274                    cost: None,
2275                    phrase: None,
2276                    persona: None,
2277                    created_at: None,
2278                });
2279            }
2280        }
2281    }
2282
2283    /// A research pipeline update: a stage label, or the final report/error.
2284    /// `None` = the job's channel closed (fires once, right after `Done`).
2285    // Long by design (event dispatch).
2286    #[allow(clippy::too_many_lines)]
2287    pub fn on_research_done(&mut self, r: Option<ResearchMsg>) {
2288        let Some((session_id, space_id, space_name, update)) = r else {
2289            self.research_rx = None;
2290            self.research_abort = None;
2291            self.research_running = None;
2292            self.set_survey_gate(None);
2293            self.survey_reply_tx = None;
2294            self.research_steer_tx = None;
2295            // Retained steer state belongs to the job: drop it when the job
2296            // ends so the next job starts from an empty queue view.
2297            self.research_steer_log.clear();
2298            self.research_steer_acked.clear();
2299            self.research_stage_rows.clear();
2300            self.research_incognito = false;
2301            self.research_live_input.clear();
2302            // The view closes its live popup when the job's channel closes.
2303            return;
2304        };
2305        let viewing = self.session.as_ref().is_some_and(|s| s.id == session_id);
2306        match update {
2307            ResearchUpdate::Stage { label, detail } => {
2308                // A `steer #N` stage row means the pipeline drained that
2309                // steer: record the acknowledgment and prune the retained
2310                // log immediately — acknowledged text must not linger in
2311                // memory while the job is parked.
2312                if let Some(n) = label.strip_prefix("steer #").and_then(|n| n.parse().ok()) {
2313                    self.research_steer_acked.insert(n);
2314                    self.research_steer_log
2315                        .retain(|(p, _)| !self.research_steer_acked.contains(p));
2316                }
2317                self.mirror_stage(&session_id, &label, &detail);
2318                if viewing {
2319                    self.push_status(format!(
2320                        "research: {} · Ctrl+↑ agents",
2321                        crate::db::stage_content(&label, &detail)
2322                    ));
2323                }
2324            }
2325            ResearchUpdate::SurveyReady { questions, round } => {
2326                let topic = self
2327                    .research_running
2328                    .as_ref()
2329                    .map(|(_, t)| t.clone())
2330                    .unwrap_or_default();
2331                let header = if round <= 1 {
2332                    format!("For \"{topic}\":")
2333                } else {
2334                    format!("Follow-up (round {round} of {MAX_SURVEY_ROUNDS}) for \"{topic}\":")
2335                };
2336                let qs = questions
2337                    .iter()
2338                    .enumerate()
2339                    .map(|(i, q)| format!(" {}. {q}", i + 1))
2340                    .collect::<Vec<_>>()
2341                    .join("\n");
2342                let content = format!(
2343                    "{header}\n{qs}\n\nAnswer in chat — I may ask follow-ups (up to {MAX_SURVEY_ROUNDS} rounds), \
2344                     then say \"I approve\". (Enter on an empty input skips ahead.)"
2345                );
2346
2347                // A normal job must make the prompt durable before it can
2348                // intercept Enter. Incognito deliberately keeps it only in
2349                // SurveyGate, where session loading can restore it in memory.
2350                if !self.research_incognito
2351                    && let Err(e) = self.db.add_survey_message(&session_id, &content)
2352                {
2353                    self.stop_research();
2354                    self.push_status(format!(
2355                        "couldn't persist the survey — research stopped: {e}"
2356                    ));
2357                    return;
2358                }
2359                let Some(tx) = self.survey_reply_tx.clone() else {
2360                    self.stop_research();
2361                    self.push_status(
2362                        "survey reply channel unavailable — research stopped".to_string(),
2363                    );
2364                    return;
2365                };
2366                self.set_survey_gate(Some(SurveyGate {
2367                    session_id: session_id.clone(),
2368                    reply_tx: tx,
2369                    phase: SurveyPhase::Clarify { round },
2370                    prompt_role: "survey".to_string(),
2371                    prompt_content: content.clone(),
2372                }));
2373
2374                if viewing {
2375                    self.messages.push(crate::db::Message {
2376                        role: "survey".to_string(),
2377                        content,
2378                        model: None,
2379                        reasoning: None,
2380                        tokens: None,
2381                        secs: None,
2382                        cost: None,
2383                        phrase: None,
2384                        persona: None,
2385                        created_at: None,
2386                    });
2387                    self.push_status(format!(
2388                        "survey round {round} — answer in chat · Ctrl+↑ agents"
2389                    ));
2390                } else {
2391                    // The gate is parked off-screen: mark the job's session
2392                    // unread and say where input is needed. In incognito the
2393                    // prompt will be restored from SurveyGate when opened.
2394                    self.unread.insert(session_id.clone());
2395                    self.push_status(format!(
2396                        "research is waiting on you — survey round {round} for \"{topic}\": \
2397                         open that session and answer in chat"
2398                    ));
2399                }
2400            }
2401            ResearchUpdate::PlanReady { questions, rework } => {
2402                let topic = self
2403                    .research_running
2404                    .as_ref()
2405                    .map(|(_, t)| t.clone())
2406                    .unwrap_or_default();
2407                let plan = plan_text(&questions);
2408                let heading = if rework {
2409                    "Research plan (revised with your feedback) — reply \"approve\" to continue:"
2410                } else {
2411                    "Research plan — reply to approve, or tell me what to change (\"drop Q2\", \"also look into X\"):"
2412                };
2413                let content = format!("{heading}\n{plan}");
2414
2415                // As with survey prompts, normal jobs persist before arming;
2416                // incognito jobs retain the actionable row only in the gate.
2417                if !self.research_incognito
2418                    && let Err(e) = self.db.add_research_plan_message(&session_id, &content)
2419                {
2420                    self.stop_research();
2421                    self.push_status(format!("couldn't persist the plan — research stopped: {e}"));
2422                    return;
2423                }
2424                let Some(tx) = self.survey_reply_tx.clone() else {
2425                    self.stop_research();
2426                    self.push_status(
2427                        "plan approval channel unavailable — research stopped".to_string(),
2428                    );
2429                    return;
2430                };
2431                self.set_survey_gate(Some(SurveyGate {
2432                    session_id: session_id.clone(),
2433                    reply_tx: tx,
2434                    phase: SurveyPhase::Approve { rework },
2435                    prompt_role: "research_plan".to_string(),
2436                    prompt_content: content.clone(),
2437                }));
2438
2439                // A byproduct record in the space's files, like the report:
2440                // the conversation is the edit surface, the file is history.
2441                // Skipped entirely in incognito — the plan folds in the user's
2442                // survey replies, so "nothing persists" must not leave it on
2443                // disk even if the job stops before any report. Failures
2444                // surface as a transcript stage row instead of vanishing.
2445                if let Err(e) = self.save_space_artifact(
2446                    &space_id,
2447                    &space_name,
2448                    &topic,
2449                    "plan",
2450                    &format!("# Research plan: {topic}\n\n{plan}\n"),
2451                ) {
2452                    self.mirror_stage(
2453                        &session_id,
2454                        "plan record",
2455                        &format!("error — could not save plan record: {e}"),
2456                    );
2457                }
2458                if viewing {
2459                    self.messages.push(crate::db::Message {
2460                        role: "research_plan".to_string(),
2461                        content,
2462                        model: None,
2463                        reasoning: None,
2464                        tokens: None,
2465                        secs: None,
2466                        cost: None,
2467                        phrase: None,
2468                        persona: None,
2469                        created_at: None,
2470                    });
2471                    self.push_status(if rework {
2472                        "revised plan ready — reply to approve".to_string()
2473                    } else {
2474                        "research plan ready — reply to approve or change · Ctrl+↑ agents"
2475                            .to_string()
2476                    });
2477                } else {
2478                    // The gate is parked off-screen: mark the job's session
2479                    // unread and say where input is needed. An incognito plan
2480                    // is restored from SurveyGate rather than the database.
2481                    self.unread.insert(session_id.clone());
2482                    self.push_status(format!(
2483                        "research is waiting on you — plan approval for \"{topic}\": \
2484                         open that session and reply \"approve\""
2485                    ));
2486                }
2487            }
2488            ResearchUpdate::Done(Ok(report)) => {
2489                // A watch session with a prior run gets a "what changed"
2490                // section prepended, listing sources not cited last time.
2491                let report = if let Ok(Some(prev_citations)) =
2492                    self.previous_citations_for_watch_session(&session_id, &space_id)
2493                {
2494                    let new_sources =
2495                        crate::app::watches::new_sources_since(&report, &prev_citations);
2496                    format!(
2497                        "{}\n\n{}",
2498                        crate::app::watches::diff_section("", &report, &new_sources),
2499                        report
2500                    )
2501                } else {
2502                    report
2503                };
2504                let _ = self.db.add_assistant_message(
2505                    &session_id,
2506                    &report,
2507                    None,
2508                    None,
2509                    None,
2510                    None,
2511                    None,
2512                    None,
2513                );
2514                let topic = self
2515                    .research_running
2516                    .as_ref()
2517                    .map(|(_, t)| t.clone())
2518                    .unwrap_or_default();
2519                // Failures surface as a transcript stage row (mirrored into
2520                // the in-memory transcript and the live popup) instead of
2521                // vanishing (incognito skips the write by design).
2522                if let Err(e) = self.save_research_report(&space_id, &space_name, &topic, &report) {
2523                    self.mirror_stage(
2524                        &session_id,
2525                        "report file",
2526                        &format!("error — could not save report file: {e}"),
2527                    );
2528                }
2529                if viewing {
2530                    self.messages.push(crate::db::Message {
2531                        role: "assistant".to_string(),
2532                        content: report,
2533                        model: None,
2534                        reasoning: None,
2535                        tokens: None,
2536                        secs: None,
2537                        cost: None,
2538                        phrase: Some("Researched".to_string()),
2539                        persona: None,
2540                        created_at: None,
2541                    });
2542                    self.push_status("research complete".to_string());
2543                } else {
2544                    self.unread.insert(session_id);
2545                    if let Some((_, topic)) = &self.research_running {
2546                        self.push_status(format!("✓ research ready: {topic}"));
2547                    }
2548                }
2549            }
2550            ResearchUpdate::Done(Err(e)) => {
2551                let msg = format!("research failed: {e}");
2552                let _ = self.db.add_assistant_message(
2553                    &session_id,
2554                    &msg,
2555                    None,
2556                    None,
2557                    None,
2558                    None,
2559                    None,
2560                    None,
2561                );
2562                if viewing {
2563                    self.messages.push(crate::db::Message {
2564                        role: "assistant".to_string(),
2565                        content: msg.clone(),
2566                        model: None,
2567                        reasoning: None,
2568                        tokens: None,
2569                        secs: None,
2570                        cost: None,
2571                        phrase: None,
2572                        persona: None,
2573                        created_at: None,
2574                    });
2575                }
2576                self.push_status(msg);
2577            }
2578        }
2579    }
2580
2581    /// Write a space artifact (finished report or presented plan) into the
2582    /// job's own space — not necessarily the currently active one, since the
2583    /// user may have switched spaces while the job ran — named
2584    /// `{prefix}-<slug>-<timestamp>.md`. Only refreshes the files cache /
2585    /// triggers a rescan if that space is still active; otherwise the file
2586    /// sits on disk and gets picked up next time that space's /files is
2587    /// opened, same as any externally-dropped file. Returns the written path
2588    /// so callers can surface failures at the update boundary instead of
2589    /// dropping them. Never writes in incognito mode: the plan folds in the
2590    /// user's survey replies, so "nothing persists" must not leave it on
2591    /// disk even if the job stops before any report.
2592    fn save_space_artifact(
2593        &mut self,
2594        space_id: &str,
2595        space_name: &str,
2596        topic: &str,
2597        prefix: &str,
2598        body: &str,
2599    ) -> std::io::Result<Option<std::path::PathBuf>> {
2600        // "Nothing persists" mode: no plan/report records on disk at all —
2601        // plan files incorporate survey replies, so even a job stopped
2602        // before its report must not leak user details. The mode is the one
2603        // captured when the job started, not a mid-job toggle.
2604        if self.research_incognito {
2605            return Ok(None);
2606        }
2607        let dir = self.space.files_dir(space_name);
2608        std::fs::create_dir_all(&dir)?;
2609        let slug = super::sessions::slugify(topic);
2610        let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
2611        let name = format!("{prefix}-{slug}-{stamp}.md");
2612        let path = dir.join(&name);
2613        std::fs::write(&path, body)?;
2614        if space_id == self.active_space.id {
2615            self.rescan_files();
2616        }
2617        Ok(Some(path))
2618    }
2619
2620    /// Save the finished report into the job's own space, named
2621    /// `research-<slug>-<timestamp>.md`, via the shared artifact writer,
2622    /// then index the report's cited sources for
2623    /// `research_lookup(scope=citations)`. Failures propagate to the caller
2624    /// (the `Done` handler surfaces them as a transcript stage row).
2625    fn save_research_report(
2626        &mut self,
2627        space_id: &str,
2628        space_name: &str,
2629        topic: &str,
2630        report: &str,
2631    ) -> std::io::Result<Option<std::path::PathBuf>> {
2632        let saved = self.save_space_artifact(space_id, space_name, topic, "research", report)?;
2633        if let Some(path) = &saved {
2634            // Index the report's cited sources for research_lookup(scope=citations).
2635            let citations = crate::citations::parse_citations(report);
2636            if !citations.is_empty() {
2637                // Titles aren't in the Sources-list format; index url only.
2638                let rows: Vec<(String, Option<String>)> =
2639                    citations.into_iter().map(|(_, url)| (url, None)).collect();
2640                let name = path
2641                    .file_name()
2642                    .and_then(|n| n.to_str())
2643                    .unwrap_or_default()
2644                    .to_string();
2645                let _ = self.db.add_citations(space_id, &name, &rows);
2646            }
2647        }
2648        Ok(saved)
2649    }
2650}
2651
2652#[cfg(test)]
2653mod tests {
2654    use super::*;
2655    use crate::app::App;
2656    use crate::db::Db;
2657    use crate::space::Space;
2658
2659    fn test_app() -> App {
2660        let db = Db::open_in_memory().unwrap();
2661        let root =
2662            std::env::temp_dir().join(format!("nexus-research-test-{}", uuid::Uuid::new_v4()));
2663        std::fs::create_dir_all(root.join("spaces")).unwrap();
2664        let space = Space { root };
2665        let mut a = App::new(db, Some("k"), space);
2666        // Research inherits the session/global model — give tests one.
2667        a.current_model = Some("openai/gpt-5-mini".to_string());
2668        a
2669    }
2670
2671    #[tokio::test]
2672    async fn drain_steers_collects_all_queued_without_blocking() {
2673        let (tx, mut rx) = mpsc::unbounded_channel();
2674        tx.send("look into X".to_string()).unwrap();
2675        tx.send("also Y".to_string()).unwrap();
2676        let drained = drain_steers(&mut rx);
2677        assert_eq!(
2678            drained,
2679            vec!["look into X".to_string(), "also Y".to_string()]
2680        );
2681        // Second call with nothing queued returns empty immediately (no hang).
2682        let empty = drain_steers(&mut rx);
2683        assert!(empty.is_empty());
2684    }
2685
2686    #[test]
2687    fn planner_messages_with_context_includes_known_chunks_as_gap_guidance() {
2688        let msgs = planner_messages_with_context(
2689            "rust async runtimes",
2690            &[],
2691            &["Rust's async model uses a Future trait.".to_string()],
2692        );
2693        assert_eq!(msgs[0].role, "system");
2694        assert!(msgs[1].content.contains("rust async runtimes"));
2695        assert!(msgs[1].content.contains("Already known"));
2696        assert!(msgs[1].content.contains("Future trait"));
2697    }
2698
2699    #[test]
2700    fn planner_messages_with_context_falls_back_to_plain_prompt_when_empty() {
2701        let msgs = planner_messages_with_context("topic", &[], &[]);
2702        assert!(!msgs[1].content.contains("Already known"));
2703        assert_eq!(msgs[1].content, "topic");
2704    }
2705
2706    #[test]
2707    fn planner_messages_with_context_folds_user_answers_into_the_prompt() {
2708        let msgs = planner_messages_with_context(
2709            "topic",
2710            &[
2711                ("q1".to_string(), "depth first".to_string()),
2712                ("q2".to_string(), "current state only".to_string()),
2713            ],
2714            &[],
2715        );
2716        let user = &msgs[1].content;
2717        assert!(user.contains("answered clarifying questions"));
2718        assert!(user.contains("depth first"));
2719        assert!(user.contains("current state only"));
2720        assert!(user.contains("topic"));
2721    }
2722
2723    #[test]
2724    fn verifier_prompt_mentions_quote_checking() {
2725        assert!(VERIFIER_PROMPT.to_lowercase().contains("quote"));
2726    }
2727
2728    #[test]
2729    fn parse_plan_blocks_reads_json_objects_with_all_fields() {
2730        let qs = parse_plan_blocks(
2731            r#"[{"question":"what is X","why":"definitions matter","angles":["a1","a2"],"sources":["s1","s2"]}]"#,
2732        );
2733        assert_eq!(qs.len(), 1);
2734        assert_eq!(qs[0].question, "what is X");
2735        assert_eq!(qs[0].why, "definitions matter");
2736        assert_eq!(qs[0].angles, vec!["a1".to_string(), "a2".to_string()]);
2737        assert_eq!(qs[0].sources, vec!["s1".to_string(), "s2".to_string()]);
2738    }
2739
2740    #[test]
2741    fn parse_plan_blocks_defaults_missing_fields_and_strips_fences() {
2742        let qs = parse_plan_blocks("```json\n[{\"question\": \"what is X\"}]\n```");
2743        assert_eq!(qs.len(), 1);
2744        assert_eq!(qs[0].question, "what is X");
2745        assert!(qs[0].why.is_empty());
2746        assert!(qs[0].angles.is_empty());
2747        assert!(qs[0].sources.is_empty());
2748    }
2749
2750    #[test]
2751    fn parse_plan_blocks_falls_back_to_bare_questions_on_non_json() {
2752        let qs = parse_plan_blocks("- what is X\n2. how does Y work");
2753        assert_eq!(qs.len(), 2);
2754        assert_eq!(qs[0].question, "what is X");
2755        assert!(qs[0].why.is_empty());
2756        assert_eq!(qs[1].question, "how does Y work");
2757    }
2758
2759    #[test]
2760    fn parse_plan_blocks_filters_empty_questions_and_caps_at_max() {
2761        let qs = parse_plan_blocks(
2762            r#"[{"question":""},{"question":"q1"},{"question":"q2"},{"question":"q3"}]"#,
2763        );
2764        assert_eq!(qs.len(), 3);
2765        assert_eq!(qs[0].question, "q1");
2766        let lines: Vec<String> = (0..10).map(|i| format!("q{i}")).collect();
2767        assert_eq!(parse_plan_blocks(&lines.join("\n")).len(), MAX_SUBQUESTIONS);
2768    }
2769
2770    #[test]
2771    fn parse_plan_blocks_rejects_malformed_json_without_line_fallback() {
2772        // Structured JSON is unambiguous: malformed or unusable output must
2773        // fail planning — the raw JSON lines are never reinterpreted as bare
2774        // questions (`[{}]` must not become a plan whose question is
2775        // literally `[{}]`).
2776        assert!(parse_plan_blocks("[{}]").is_empty(), "[{{}}] must fail");
2777        assert!(
2778            parse_plan_blocks(r#"[{"question":""}]"#).is_empty(),
2779            "empty questions must fail"
2780        );
2781        assert!(
2782            parse_plan_blocks(r#"[{"question": 5}]"#).is_empty(),
2783            "wrong field types must fail"
2784        );
2785        assert!(
2786            parse_plan_blocks(r#"{"question":"q1"}"#).is_empty(),
2787            "a bare object is not the required array and must fail"
2788        );
2789        // Non-JSON legacy line output still falls back to bare questions.
2790        assert_eq!(parse_plan_blocks("- what is X").len(), 1);
2791        // JSON wrapped in model prose is still JSON — never re-read as lines.
2792        let prose = parse_plan_blocks("Here is the plan:\n[{\"question\":\"q1\"}]");
2793        assert_eq!(prose.len(), 1, "prose-prefixed JSON must parse as JSON");
2794        assert_eq!(prose[0].question, "q1");
2795        // A legacy JSON array of strings still works.
2796        let legacy = parse_plan_blocks(r#"["what is X", "how does Y work"]"#);
2797        assert_eq!(legacy.len(), 2);
2798        assert_eq!(legacy[0].question, "what is X");
2799    }
2800
2801    #[test]
2802    fn parse_survey_reply_recognizes_complete_markers() {
2803        assert_eq!(parse_survey_reply("COMPLETE"), SurveyReply::Complete);
2804        assert_eq!(parse_survey_reply("  complete  "), SurveyReply::Complete);
2805        assert_eq!(
2806            parse_survey_reply("COMPLETE: I have enough"),
2807            SurveyReply::Complete
2808        );
2809        assert_eq!(
2810            parse_survey_reply("COMPLETE — proceed"),
2811            SurveyReply::Complete
2812        );
2813        // Trailing punctuation counts too — the docs promise prose tolerance.
2814        assert_eq!(parse_survey_reply("COMPLETE."), SurveyReply::Complete);
2815        assert_eq!(parse_survey_reply("COMPLETE!"), SurveyReply::Complete);
2816    }
2817
2818    #[test]
2819    fn parse_survey_reply_reads_numbered_questions() {
2820        assert_eq!(
2821            parse_survey_reply("1. Depth or breadth?\n2. History too?"),
2822            SurveyReply::Questions(vec![
2823                "Depth or breadth?".to_string(),
2824                "History too?".to_string()
2825            ])
2826        );
2827        assert_eq!(
2828            parse_survey_reply("- just one angle"),
2829            SurveyReply::Questions(vec!["just one angle".to_string()])
2830        );
2831    }
2832
2833    #[test]
2834    fn parse_survey_reply_marks_output_contract_violations_and_caps_questions() {
2835        // Empty output and arbitrary prose violate the agent's contract
2836        // (COMPLETE or numbered questions) — they must be `Malformed`, not
2837        // silently indistinguishable from the required COMPLETE marker.
2838        assert_eq!(parse_survey_reply(""), SurveyReply::Malformed);
2839        assert_eq!(parse_survey_reply("\n\n"), SurveyReply::Malformed);
2840        assert_eq!(
2841            parse_survey_reply("I couldn't understand your last answer, please retry"),
2842            SurveyReply::Malformed
2843        );
2844        assert_eq!(
2845            parse_survey_reply("The model encountered an error processing the request."),
2846            SurveyReply::Malformed
2847        );
2848        assert_eq!(
2849            parse_survey_reply("No further questions are needed"),
2850            SurveyReply::Malformed
2851        );
2852        // An unmarked line is only a question when it looks like one.
2853        assert_eq!(
2854            parse_survey_reply("Depth or breadth?"),
2855            SurveyReply::Questions(vec!["Depth or breadth?".to_string()])
2856        );
2857        // Numbered lines are questions; prose mixed in is skipped.
2858        let mixed = "1. Depth or breadth?\nPlease be specific.\n2. History too?";
2859        assert_eq!(
2860            parse_survey_reply(mixed),
2861            SurveyReply::Questions(vec![
2862                "Depth or breadth?".to_string(),
2863                "History too?".to_string()
2864            ])
2865        );
2866        let lines: Vec<String> = (0..8).map(|i| format!("{}. q{i}?", i + 1)).collect();
2867        match parse_survey_reply(&lines.join("\n")) {
2868            SurveyReply::Questions(qs) => assert_eq!(qs.len(), MAX_SURVEY_QUESTIONS),
2869            _ => panic!("expected questions"),
2870        }
2871    }
2872
2873    #[test]
2874    fn parse_approval_recognizes_approved_and_revised_plans() {
2875        assert_eq!(parse_approval("APPROVED"), Approval::Approved);
2876        assert_eq!(parse_approval("  approved  "), Approval::Approved);
2877        assert_eq!(parse_approval("APPROVED: run it"), Approval::Approved);
2878        let revised = parse_approval("[{\"question\": \"revised q\"}]");
2879        assert_eq!(
2880            revised,
2881            Approval::Revised(vec![PlanQuestion::bare("revised q".to_string())])
2882        );
2883        // Malformed output is never treated as approval — the phase fails
2884        // visibly instead of running an unapproved plan.
2885        assert_eq!(parse_approval("huh?"), Approval::Malformed);
2886        assert_eq!(parse_approval(""), Approval::Malformed);
2887        assert_eq!(
2888            parse_approval("Here is the revised plan I prepared for you"),
2889            Approval::Malformed
2890        );
2891        // Structured JSON that parses but holds no usable questions, or that
2892        // has wrong field types, is Malformed — never re-read as bare lines.
2893        assert_eq!(parse_approval("[{}]"), Approval::Malformed);
2894        assert_eq!(parse_approval("[{\"question\": 5}]"), Approval::Malformed);
2895        // JSON wrapped in model prose is still JSON.
2896        assert_eq!(
2897            parse_approval("Here is my revised plan:\n[{\"question\":\"q\"}]\n"),
2898            Approval::Revised(vec![PlanQuestion::bare("q".to_string())])
2899        );
2900        // A recognizably list-formatted revision still counts.
2901        assert_eq!(
2902            parse_approval("- drop q2"),
2903            Approval::Revised(vec![PlanQuestion::bare("drop q2".to_string())])
2904        );
2905    }
2906
2907    #[test]
2908    fn plan_question_prompt_includes_topic_and_full_brief() {
2909        let q = PlanQuestion {
2910            question: "how does X work".to_string(),
2911            why: "mechanism matters".to_string(),
2912            angles: vec!["internals".to_string(), "benchmarks".to_string()],
2913            sources: vec!["papers".to_string()],
2914        };
2915        let p = q.prompt("rust async");
2916        assert!(p.contains("rust async"));
2917        assert!(p.contains("how does X work"));
2918        assert!(p.contains("mechanism matters"));
2919        assert!(p.contains("internals; benchmarks"));
2920        assert!(p.contains("papers"));
2921        // A bare question stays prompt-safe too.
2922        assert!(PlanQuestion::bare("q".into()).prompt("t").contains('q'));
2923    }
2924
2925    #[test]
2926    fn plan_text_renders_numbered_questions_with_indented_briefs() {
2927        let qs = vec![
2928            PlanQuestion::bare("q1".to_string()),
2929            PlanQuestion {
2930                question: "q2".to_string(),
2931                why: "why2".to_string(),
2932                angles: vec!["a".to_string()],
2933                sources: vec!["s".to_string()],
2934            },
2935        ];
2936        let t = plan_text(&qs);
2937        assert!(t.contains("1. q1"));
2938        assert!(t.contains("2. q2"));
2939        assert!(t.contains("\n   Why: why2"));
2940        assert!(t.contains("\n   Angles: a"));
2941        assert!(t.contains("\n   Sources: s"));
2942    }
2943
2944    #[test]
2945    fn research_stage_prompts_share_a_stable_cache_prefix() {
2946        let planner = research_stage_prompt(PLANNER_PROMPT);
2947        let writer = research_stage_prompt(WRITER_PROMPT);
2948        assert!(planner.starts_with(RESEARCH_CACHE_PREFIX));
2949        assert!(writer.starts_with(RESEARCH_CACHE_PREFIX));
2950        assert_ne!(planner, writer);
2951    }
2952
2953    #[test]
2954    fn survey_messages_include_topic_and_rounds() {
2955        let msgs = survey_messages("t", &[]);
2956        assert_eq!(msgs[0].role, "system");
2957        assert!(msgs[1].content.contains('t'));
2958        let msgs = survey_messages("t", &[("q".to_string(), "a".to_string())]);
2959        assert!(msgs[1].content.contains('q'));
2960        assert!(msgs[1].content.contains('a'));
2961    }
2962
2963    #[test]
2964    fn plan_approval_messages_include_plan_and_user_reply() {
2965        let msgs =
2966            plan_approval_messages("topic", &[PlanQuestion::bare("q1".to_string())], "drop q2");
2967        assert!(msgs[1].content.contains("topic"));
2968        assert!(msgs[1].content.contains("1. q1"));
2969        assert!(msgs[1].content.contains("drop q2"));
2970    }
2971
2972    #[tokio::test]
2973    async fn on_research_done_final_report_populates_citation_index() {
2974        let mut a = test_app();
2975        a.start_research("rust async runtimes");
2976        let session_id = a.session.as_ref().unwrap().id.clone();
2977        let space_id = a.active_space.id.clone();
2978        let space_name = a.active_space.name.clone();
2979
2980        a.on_research_done(Some((
2981            session_id,
2982            space_id.clone(),
2983            space_name,
2984            ResearchUpdate::Done(Ok(
2985                "# Report\n\nBody [1].\n\n## Sources\n1. https://example.com/a\n".to_string(),
2986            )),
2987        )));
2988
2989        let hits =
2990            a.db.search_citations(&space_id, Some("example.com"))
2991                .unwrap();
2992        assert_eq!(hits.len(), 1);
2993        assert_eq!(hits[0].1, "https://example.com/a");
2994        // And a miss filter returns nothing.
2995        assert!(
2996            a.db.search_citations(&space_id, Some("nope.example"))
2997                .unwrap()
2998                .is_empty()
2999        );
3000    }
3001
3002    #[tokio::test]
3003    async fn plan_ready_arms_the_gate_and_reply_routes_into_the_pipeline() {
3004        let mut a = test_app();
3005        a.start_research("rust async runtimes");
3006        let session_id = a.session.as_ref().unwrap().id.clone();
3007        let space_id = a.active_space.id.clone();
3008        let space_name = a.active_space.name.clone();
3009
3010        // The pipeline's reply sender must be reachable for the gate to arm.
3011        let (tx, mut rx) = mpsc::unbounded_channel();
3012        a.survey_reply_tx = Some(tx);
3013        let q1 = PlanQuestion::bare("q1".to_string());
3014        let q2 = PlanQuestion::bare("q2".to_string());
3015
3016        a.on_research_done(Some((
3017            session_id.clone(),
3018            space_id,
3019            space_name,
3020            ResearchUpdate::PlanReady {
3021                questions: vec![q1.clone(), q2.clone()],
3022                rework: false,
3023            },
3024        )));
3025        assert!(a.survey_gate.is_some());
3026        assert!(a.survey_gate_targets_current_session());
3027        assert!(a.messages.iter().any(|m| m.role == "research_plan"));
3028        let stored = a.db.load_messages(&session_id).unwrap();
3029        assert!(stored.iter().any(|m| m.role == "research_plan"));
3030        // The presented plan includes the block briefs.
3031        let plan_msg = a
3032            .messages
3033            .iter()
3034            .find(|m| m.role == "research_plan")
3035            .unwrap();
3036        assert!(plan_msg.content.contains("1. q1"));
3037
3038        // A chat reply (with an edit) routes into the pipeline and is
3039        // recorded as a gate reply in the session transcript — rendered like
3040        // a user message but never replayed to the model.
3041        a.reply_to_survey_gate("drop q2");
3042        assert!(a.survey_gate.is_none());
3043        assert!(!a.survey_gate_targets_current_session());
3044        assert_eq!(rx.recv().await.unwrap(), "drop q2");
3045        assert!(
3046            a.messages
3047                .iter()
3048                .any(|m| m.role == "gate_reply" && m.content == "drop q2")
3049        );
3050        let stored = a.db.load_messages(&session_id).unwrap();
3051        assert!(
3052            stored
3053                .iter()
3054                .any(|m| m.role == "gate_reply" && m.content == "drop q2")
3055        );
3056        // The gate reply must not leak into model history without context.
3057        let history = a.build_history();
3058        assert!(
3059            !history.iter().any(|m| m.content == "drop q2"),
3060            "gate replies must be excluded from model history"
3061        );
3062    }
3063
3064    #[tokio::test]
3065    async fn plan_ready_saves_a_plan_file_record_in_the_space() {
3066        let mut a = test_app();
3067        a.start_research("rust async runtimes");
3068        let session_id = a.session.as_ref().unwrap().id.clone();
3069        let space_id = a.active_space.id.clone();
3070        let space_name = a.active_space.name.clone();
3071        let (tx, _rx) = mpsc::unbounded_channel();
3072        a.survey_reply_tx = Some(tx);
3073
3074        a.on_research_done(Some((
3075            session_id,
3076            space_id,
3077            space_name.clone(),
3078            ResearchUpdate::PlanReady {
3079                questions: vec![PlanQuestion::bare("q1".to_string())],
3080                rework: false,
3081            },
3082        )));
3083
3084        let dir = a.space.files_dir(&space_name);
3085        let saved: Vec<String> = std::fs::read_dir(&dir)
3086            .unwrap()
3087            .filter_map(std::result::Result::ok)
3088            .map(|e| e.file_name().to_string_lossy().into_owned())
3089            .filter(|n| {
3090                n.starts_with("plan-")
3091                    && std::path::Path::new(n)
3092                        .extension()
3093                        .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
3094            })
3095            .collect();
3096        assert_eq!(saved.len(), 1, "expected one plan file in {dir:?}");
3097        let body = std::fs::read_to_string(dir.join(&saved[0])).unwrap();
3098        assert!(body.contains("Research plan: rust async runtimes"));
3099        assert!(body.contains("1. q1"));
3100    }
3101
3102    #[tokio::test]
3103    async fn survey_ready_arms_the_gate_and_renders_a_survey_section() {
3104        let mut a = test_app();
3105        a.start_research("fine-tuning LLMs");
3106        let session_id = a.session.as_ref().unwrap().id.clone();
3107        let space_id = a.active_space.id.clone();
3108        let space_name = a.active_space.name.clone();
3109        let (tx, mut rx) = mpsc::unbounded_channel();
3110        a.survey_reply_tx = Some(tx);
3111
3112        a.on_research_done(Some((
3113            session_id.clone(),
3114            space_id,
3115            space_name,
3116            ResearchUpdate::SurveyReady {
3117                questions: vec!["Depth or breadth?".to_string()],
3118                round: 1,
3119            },
3120        )));
3121        assert!(a.survey_gate_targets_current_session());
3122        let survey = a.messages.iter().find(|m| m.role == "survey").unwrap();
3123        assert!(survey.content.contains("For \"fine-tuning LLMs\":"));
3124        assert!(survey.content.contains("1. Depth or breadth?"));
3125        assert!(survey.content.contains("I approve"));
3126        let stored = a.db.load_messages(&session_id).unwrap();
3127        assert!(stored.iter().any(|m| m.role == "survey"));
3128
3129        a.reply_to_survey_gate("depth first");
3130        assert!(a.survey_gate.is_none());
3131        assert_eq!(rx.recv().await.unwrap(), "depth first");
3132        let (_, status) = a.drain_ui_events();
3133        assert!(status.contains("follow-ups"));
3134    }
3135
3136    #[tokio::test]
3137    async fn gate_only_targets_the_viewed_gated_session() {
3138        let mut a = test_app();
3139        a.start_research("topic one");
3140        let gated_session = a.session.as_ref().unwrap().id.clone();
3141        let (tx, _rx) = mpsc::unbounded_channel();
3142        a.survey_reply_tx = Some(tx);
3143        a.on_research_done(Some((
3144            gated_session.clone(),
3145            a.active_space.id.clone(),
3146            a.active_space.name.clone(),
3147            ResearchUpdate::SurveyReady {
3148                questions: vec!["q?".to_string()],
3149                round: 1,
3150            },
3151        )));
3152        assert!(a.survey_gate_targets_current_session());
3153
3154        // Switch to a different session: the gate must not intercept typing.
3155        let other =
3156            a.db.create_session("other", "m", &a.active_space.id, "chat")
3157                .unwrap();
3158        a.session = Some(other);
3159        a.messages.clear();
3160        assert!(!a.survey_gate_targets_current_session());
3161        assert!(a.survey_gate.is_some(), "gate stays armed for its session");
3162    }
3163
3164    #[tokio::test]
3165    async fn closed_reply_channel_fails_plan_approval_closed() {
3166        // A gate whose reply channel closed (job teardown racing the parked
3167        // approval) must fail closed — never run searchers on an unapproved
3168        // plan. The closed channel surfaces immediately, before any provider
3169        // call, so a bare test client is fine.
3170        let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::<String>();
3171        drop(reply_tx);
3172        let (tx, _rx) = mpsc::unbounded_channel::<ResearchMsg>();
3173        let ids = ("s".to_string(), "sp".to_string(), "sn".to_string());
3174        let mut questions = vec![PlanQuestion::bare("q1".to_string())];
3175        let provider = OpenRouter::openrouter_flavor("test-key".to_string());
3176        let usage = ResearchUsage {
3177            db_path: std::env::temp_dir().join("nexus-research-test.db"),
3178            session_id: ids.0.clone(),
3179            prompt_cache_key: "s:0".to_string(),
3180            space_id: ids.1.clone(),
3181            backend: provider.backend_tag().name(),
3182        };
3183        let result = await_plan_approval(
3184            PlanApprovalContext {
3185                provider: &provider,
3186                model: "a/b",
3187                topic: "topic",
3188                usage: &usage,
3189                tx: &tx,
3190                ids: &ids,
3191            },
3192            &mut questions,
3193            &mut reply_rx,
3194        )
3195        .await;
3196        let err = result.expect_err("closed channel must fail closed, not approve");
3197        assert!(err.contains("cancelled"), "{err}");
3198    }
3199
3200    #[test]
3201    fn start_research_rejects_blank_topic_and_missing_model() {
3202        let mut a = test_app();
3203        a.start_research("  ");
3204        let (_, status) = a.drain_ui_events();
3205        assert!(status.contains("usage:"));
3206        assert!(a.research_rx.is_none());
3207
3208        a.current_model = None;
3209        a.start_research("rust async runtimes");
3210        let (_, status) = a.drain_ui_events();
3211        assert!(status.contains("no model configured"));
3212        assert!(a.research_rx.is_none());
3213    }
3214
3215    #[tokio::test]
3216    async fn start_research_creates_and_switches_into_a_new_session() {
3217        let mut a = test_app();
3218        a.start_research("rust async runtimes");
3219        assert!(a.research_rx.is_some());
3220        assert!(a.research_running.is_some());
3221        let session = a
3222            .session
3223            .as_ref()
3224            .expect("switched into the research session");
3225        assert!(session.title.contains("rust async runtimes"));
3226        assert!(
3227            a.messages
3228                .iter()
3229                .any(|m| m.content.contains("/research rust async runtimes"))
3230        );
3231    }
3232
3233    #[tokio::test]
3234    async fn start_research_refuses_a_second_concurrent_job() {
3235        let mut a = test_app();
3236        a.start_research("topic one");
3237        assert!(a.research_rx.is_some());
3238        a.start_research("topic two");
3239        let (_, status) = a.drain_ui_events();
3240        assert!(status.contains("already running"));
3241        // Still the first job's session.
3242        assert!(a.session.as_ref().unwrap().title.contains("topic one"));
3243    }
3244
3245    #[tokio::test]
3246    async fn on_research_done_stage_update_persists_and_shows_when_viewing() {
3247        let mut a = test_app();
3248        a.start_research("rust async runtimes");
3249        let session_id = a.session.as_ref().unwrap().id.clone();
3250        let space_id = a.active_space.id.clone();
3251        let space_name = a.active_space.name.clone();
3252
3253        a.on_research_done(Some((
3254            session_id.clone(),
3255            space_id,
3256            space_name,
3257            ResearchUpdate::Stage {
3258                label: "planning".to_string(),
3259                detail: String::new(),
3260            },
3261        )));
3262
3263        assert!(
3264            a.messages
3265                .iter()
3266                .any(|m| m.role == "research_stage" && m.content == "planning")
3267        );
3268        let stored = a.db.load_messages(&session_id).unwrap();
3269        assert!(
3270            stored
3271                .iter()
3272                .any(|m| m.role == "research_stage" && m.content == "planning")
3273        );
3274        let (_, status) = a.drain_ui_events();
3275        assert!(status.contains("planning"));
3276
3277        // A second tick with the same label replaces the row, not appends.
3278        let space_id = a.active_space.id.clone();
3279        let space_name = a.active_space.name.clone();
3280        a.on_research_done(Some((
3281            session_id.clone(),
3282            space_id,
3283            space_name,
3284            ResearchUpdate::Stage {
3285                label: "planning".to_string(),
3286                detail: "revised".to_string(),
3287            },
3288        )));
3289        let stored = a.db.load_messages(&session_id).unwrap();
3290        let rows: Vec<_> = stored
3291            .iter()
3292            .filter(|m| m.role == "research_stage")
3293            .collect();
3294        assert_eq!(rows.len(), 1, "one row per label, updated in place");
3295        assert_eq!(rows[0].content, "planning: revised");
3296        let visible_rows: Vec<_> = a
3297            .messages
3298            .iter()
3299            .filter(|m| m.role == "research_stage")
3300            .collect();
3301        assert_eq!(visible_rows.len(), 1);
3302        assert_eq!(visible_rows[0].content, "planning: revised");
3303        let (_, status) = a.drain_ui_events();
3304        assert!(status.contains("Ctrl+↑ agents"));
3305    }
3306
3307    #[tokio::test]
3308    async fn multiple_drained_steers_each_keep_their_own_stage_row() {
3309        let mut a = test_app();
3310        a.start_research("rust async runtimes");
3311        let session_id = a.session.as_ref().unwrap().id.clone();
3312        let space_id = a.active_space.id.clone();
3313        let space_name = a.active_space.name.clone();
3314
3315        // Two drained steers arrive as `steer #N` rows (N = queue position =
3316        // drain order). A text-keyed label would collapse rows when one
3317        // steer's text is a prefix of another's — the sequence key keeps
3318        // every drained steer its own persisted + visible row.
3319        for (i, steer) in ["look into X", "also Y"].iter().enumerate() {
3320            a.on_research_done(Some((
3321                session_id.clone(),
3322                space_id.clone(),
3323                space_name.clone(),
3324                ResearchUpdate::Stage {
3325                    label: format!("steer #{}", i + 1),
3326                    detail: steer.to_string(),
3327                },
3328            )));
3329        }
3330        // The pipeline's acknowledgements are job-global: both steers must
3331        // no longer count as queued, and their rows persist for display.
3332        assert_eq!(
3333            a.research_steer_acked,
3334            std::collections::HashSet::from([1, 2])
3335        );
3336        let stored = a.db.load_messages(&session_id).unwrap();
3337        let steer_rows: Vec<_> = stored
3338            .iter()
3339            .filter(|m| m.role == "research_stage" && m.content.starts_with("steer #"))
3340            .collect();
3341        assert_eq!(steer_rows.len(), 2, "one persisted row per drained steer");
3342        let visible: Vec<_> = a
3343            .messages
3344            .iter()
3345            .filter(|m| m.role == "research_stage" && m.content.starts_with("steer #"))
3346            .collect();
3347        assert_eq!(visible.len(), 2, "both steers visible in the transcript");
3348    }
3349
3350    #[tokio::test]
3351    async fn steer_rows_do_not_collide_on_duplicate_prefix_or_wildcard_text() {
3352        let mut a = test_app();
3353        a.start_research("rust async runtimes");
3354        let session_id = a.session.as_ref().unwrap().id.clone();
3355        let space_id = a.active_space.id.clone();
3356        let space_name = a.active_space.name.clone();
3357
3358        // The bug this guards against: steers were keyed by their text, so
3359        // "a: b" then "a" collapsed into one row (the upsert matches labels
3360        // by prefix), identical duplicates collapsed too, and `%`/`_` acted
3361        // as SQL LIKE wildcards in the match. Sequence keys make the text
3362        // irrelevant to row identity.
3363        let steers = ["a: b", "a", "same", "same", "100% done"];
3364        for (i, steer) in steers.iter().enumerate() {
3365            a.on_research_done(Some((
3366                session_id.clone(),
3367                space_id.clone(),
3368                space_name.clone(),
3369                ResearchUpdate::Stage {
3370                    label: format!("steer #{}", i + 1),
3371                    detail: steer.to_string(),
3372                },
3373            )));
3374        }
3375        let stored = a.db.load_messages(&session_id).unwrap();
3376        let rows: Vec<_> = stored
3377            .iter()
3378            .filter(|m| m.role == "research_stage" && m.content.starts_with("steer #"))
3379            .collect();
3380        assert_eq!(
3381            rows.len(),
3382            steers.len(),
3383            "one row per drained steer — no collapse on duplicate/prefix/wildcard text"
3384        );
3385        for (i, steer) in steers.iter().enumerate() {
3386            let want = format!("steer #{}: {steer}", i + 1);
3387            assert!(
3388                rows.iter().any(|m| m.content == want),
3389                "missing row for steer #{}: {want}",
3390                i + 1
3391            );
3392        }
3393        // The pipeline's acknowledgements are job-global and position-keyed.
3394        assert_eq!(
3395            a.research_steer_acked,
3396            (1..=steers.len()).collect::<std::collections::HashSet<usize>>(),
3397            "every steer position picked up"
3398        );
3399    }
3400
3401    #[test]
3402    fn steer_log_drops_acknowledged_entries_and_clears_on_stop() {
3403        let mut a = test_app();
3404        let (tx, _rx) = mpsc::unbounded_channel::<String>();
3405        a.research_steer_tx = Some(tx);
3406
3407        a.steer_research("first");
3408        a.steer_research("second");
3409        a.steer_research("third");
3410        assert_eq!(
3411            a.research_steer_log,
3412            vec![
3413                (1, "first".into()),
3414                (2, "second".into()),
3415                (3, "third".into())
3416            ]
3417        );
3418
3419        // The pipeline drains 1 and 2: acknowledged entries are dropped on
3420        // the next queue (positions are never renumbered).
3421        a.research_steer_acked = std::collections::HashSet::from([1, 2]);
3422        a.steer_research("fourth");
3423        assert_eq!(
3424            a.research_steer_log,
3425            vec![(3, "third".into()), (4, "fourth".into())]
3426        );
3427
3428        // An ack that arrives while the job is parked prunes the log
3429        // immediately — no need to wait for the next `/steer`.
3430        a.research_steer_acked.insert(3);
3431        a.on_research_done(Some((
3432            "s".to_string(),
3433            "sp".to_string(),
3434            "sn".to_string(),
3435            ResearchUpdate::Stage {
3436                label: "steer #3".to_string(),
3437                detail: "third".to_string(),
3438            },
3439        )));
3440        assert_eq!(a.research_steer_log, vec![(4, "fourth".into())]);
3441
3442        // Stopping the job drops the whole retained log.
3443        let (_tx, rx) = mpsc::unbounded_channel::<ResearchMsg>();
3444        a.research_rx = Some(rx);
3445        a.research_running = Some(("s".to_string(), "t".to_string()));
3446        a.stop_research();
3447        assert!(a.research_steer_log.is_empty());
3448        assert!(a.research_steer_acked.is_empty());
3449    }
3450
3451    #[test]
3452    fn steer_queue_is_hard_bound() {
3453        let mut a = test_app();
3454        let (tx, _rx) = mpsc::unbounded_channel::<String>();
3455        a.research_steer_tx = Some(tx);
3456
3457        // Fill the queue to the bound; further steers are refused with a
3458        // status message (which also bounds the unbounded channel and the
3459        // retained log).
3460        for i in 0..MAX_QUEUED_STEERS {
3461            a.steer_research(&format!("steer {i}"));
3462        }
3463        assert_eq!(a.research_steer_log.len(), MAX_QUEUED_STEERS);
3464        a.steer_research("overflow");
3465        assert_eq!(a.research_steer_log.len(), MAX_QUEUED_STEERS);
3466        assert!(
3467            a.last_status().contains("steer queue full"),
3468            "{}",
3469            a.last_status()
3470        );
3471    }
3472
3473    #[tokio::test]
3474    async fn plan_ready_in_incognito_mode_writes_no_plan_file() {
3475        let mut a = test_app();
3476        a.incognito = true;
3477        a.start_research("rust async runtimes");
3478        let session_id = a.session.as_ref().unwrap().id.clone();
3479        let space_id = a.active_space.id.clone();
3480        let space_name = a.active_space.name.clone();
3481        let (tx, _rx) = mpsc::unbounded_channel();
3482        a.survey_reply_tx = Some(tx);
3483
3484        a.on_research_done(Some((
3485            session_id.clone(),
3486            space_id,
3487            space_name.clone(),
3488            ResearchUpdate::PlanReady {
3489                questions: vec![PlanQuestion::bare("q1".to_string())],
3490                rework: false,
3491            },
3492        )));
3493
3494        // "Nothing persists": the plan (which folds in the user's survey
3495        // replies) must not land on disk, and must not be written to the
3496        // message db either — the in-memory transcript still shows it while
3497        // the session is viewed.
3498        let dir = a.space.files_dir(&space_name);
3499        let _ = std::fs::create_dir_all(&dir);
3500        let saved: Vec<String> = std::fs::read_dir(&dir)
3501            .unwrap()
3502            .filter_map(std::result::Result::ok)
3503            .map(|e| e.file_name().to_string_lossy().into_owned())
3504            .filter(|n| n.starts_with("plan-"))
3505            .collect();
3506        assert!(saved.is_empty(), "no plan files in incognito: {saved:?}");
3507        let stored = a.db.load_messages(&session_id).unwrap();
3508        assert!(
3509            stored.iter().all(|m| m.role != "research_plan"),
3510            "the plan must not be persisted to the message db in incognito"
3511        );
3512        assert!(
3513            a.messages.iter().any(|m| m.role == "research_plan"),
3514            "the in-memory transcript still shows the plan while viewed"
3515        );
3516    }
3517
3518    #[tokio::test]
3519    async fn incognito_gate_rows_follow_the_mode_captured_at_job_start() {
3520        let mut a = test_app();
3521        a.incognito = true;
3522        a.start_research("private topic");
3523        let session_id = a.session.as_ref().unwrap().id.clone();
3524        assert!(a.research_incognito);
3525
3526        // A later UI-mode change must not make this already-private job start
3527        // persisting its survey or the user's answer.
3528        a.incognito = false;
3529        let (tx, mut rx) = mpsc::unbounded_channel();
3530        a.survey_reply_tx = Some(tx);
3531        a.on_research_done(Some((
3532            session_id.clone(),
3533            a.active_space.id.clone(),
3534            a.active_space.name.clone(),
3535            ResearchUpdate::SurveyReady {
3536                questions: vec!["Which confidential product?".to_string()],
3537                round: 1,
3538            },
3539        )));
3540        a.reply_to_survey_gate("Project Juniper");
3541        assert_eq!(rx.recv().await.unwrap(), "Project Juniper");
3542
3543        let stored = a.db.load_messages(&session_id).unwrap();
3544        assert!(stored.iter().all(|m| m.role != "survey"));
3545        assert!(stored.iter().all(|m| m.role != "gate_reply"));
3546    }
3547
3548    #[tokio::test]
3549    async fn off_screen_incognito_plan_is_restored_when_its_session_opens() {
3550        let mut a = test_app();
3551        a.incognito = true;
3552        a.start_research("private topic");
3553        let session_id = a.session.as_ref().unwrap().id.clone();
3554        let other =
3555            a.db.create_session("other", "m", &a.active_space.id, "chat")
3556                .unwrap();
3557        a.session = Some(other);
3558        a.messages.clear();
3559        let (tx, _rx) = mpsc::unbounded_channel();
3560        a.survey_reply_tx = Some(tx);
3561
3562        a.on_research_done(Some((
3563            session_id.clone(),
3564            a.active_space.id.clone(),
3565            a.active_space.name.clone(),
3566            ResearchUpdate::PlanReady {
3567                questions: vec![PlanQuestion::bare("private question".to_string())],
3568                rework: true,
3569            },
3570        )));
3571        assert!(!a.survey_gate_targets_current_session());
3572        assert!(
3573            a.db.load_messages(&session_id)
3574                .unwrap()
3575                .iter()
3576                .all(|m| m.role != "research_plan")
3577        );
3578
3579        a.switch_to_session_by_id(&session_id).unwrap();
3580
3581        assert!(a.survey_gate_targets_current_session());
3582        let plan = a
3583            .messages
3584            .iter()
3585            .find(|m| m.role == "research_plan")
3586            .expect("pending incognito plan restored in memory");
3587        assert!(plan.content.contains("private question"));
3588        assert!(plan.content.contains("reply \"approve\""));
3589        assert!(!plan.content.contains("tell me what to change"));
3590    }
3591
3592    #[tokio::test]
3593    async fn undelivered_gate_reply_is_rolled_back_and_restored_to_composer() {
3594        let mut a = test_app();
3595        a.start_research("rust async runtimes");
3596        let session_id = a.session.as_ref().unwrap().id.clone();
3597        // The gate's receiver is already gone: persisting succeeds, but
3598        // channel delivery fails — the persisted reply must be rolled back
3599        // so a retry can't duplicate it.
3600        let (reply_tx, rx) = mpsc::unbounded_channel::<String>();
3601        drop(rx);
3602        a.survey_reply_tx = Some(reply_tx.clone());
3603        a.on_research_done(Some((
3604            session_id.clone(),
3605            a.active_space.id.clone(),
3606            a.active_space.name.clone(),
3607            ResearchUpdate::PlanReady {
3608                questions: vec![PlanQuestion::bare("q1".to_string())],
3609                rework: false,
3610            },
3611        )));
3612        assert!(a.survey_gate.is_some());
3613
3614        a.reply_to_survey_gate("drop q2");
3615
3616        assert!(a.survey_gate.is_none());
3617        // Composer restored, nothing persisted, nothing mirrored in memory.
3618        let (sets, _) = a.drain_ui_events();
3619        assert_eq!(sets, vec!["drop q2".to_string()]);
3620        let stored = a.db.load_messages(&session_id).unwrap();
3621        assert!(
3622            stored.iter().all(|m| m.role != "gate_reply"),
3623            "undelivered reply must not remain persisted"
3624        );
3625        assert!(!a.messages.iter().any(|m| m.role == "gate_reply"));
3626    }
3627
3628    #[tokio::test]
3629    async fn off_screen_gate_marks_the_session_unread_and_notifies() {
3630        let mut a = test_app();
3631        a.start_research("rust async runtimes");
3632        let session_id = a.session.as_ref().unwrap().id.clone();
3633        // Navigate away before the gate arrives.
3634        let other =
3635            a.db.create_session("other", "m", &a.active_space.id, "chat")
3636                .unwrap();
3637        a.session = Some(other);
3638        a.messages.clear();
3639        let (tx, _rx) = mpsc::unbounded_channel();
3640        a.survey_reply_tx = Some(tx);
3641
3642        a.on_research_done(Some((
3643            session_id.clone(),
3644            a.active_space.id.clone(),
3645            a.active_space.name.clone(),
3646            ResearchUpdate::SurveyReady {
3647                questions: vec!["Depth or breadth?".to_string()],
3648                round: 1,
3649            },
3650        )));
3651
3652        // The gate is armed for the job's session and the user is told where
3653        // input is needed — a silently parked pipeline can't block later
3654        // research unnoticed.
3655        assert!(a.survey_gate.is_some());
3656        assert!(!a.survey_gate_targets_current_session());
3657        assert!(
3658            a.unread.contains(&session_id),
3659            "session must be marked unread"
3660        );
3661        let (_, status) = a.drain_ui_events();
3662        assert!(status.contains("waiting on you"), "{status}");
3663        assert!(status.contains("survey round 1"), "{status}");
3664    }
3665
3666    #[tokio::test]
3667    async fn on_research_done_final_report_posts_message_saves_file_and_notifies_when_away() {
3668        let mut a = test_app();
3669        a.start_research("rust async runtimes");
3670        let session_id = a.session.as_ref().unwrap().id.clone();
3671        let space_id = a.active_space.id.clone();
3672        let space_name = a.active_space.name.clone();
3673
3674        // Simulate the user navigating away before the job finishes.
3675        a.session = None;
3676        a.messages.clear();
3677
3678        a.on_research_done(Some((
3679            session_id.clone(),
3680            space_id,
3681            space_name.clone(),
3682            ResearchUpdate::Done(Ok(
3683                "# Rust Async Runtimes\n\nBody text. [1]\n\n## Sources\n1. https://a".to_string(),
3684            )),
3685        )));
3686
3687        assert!(a.unread.contains(&session_id));
3688        let stored = a.db.load_messages(&session_id).unwrap();
3689        assert!(
3690            stored
3691                .iter()
3692                .any(|m| m.role == "assistant" && m.content.contains("Rust Async Runtimes"))
3693        );
3694
3695        // Saved into the space's files dir and picked up by a rescan.
3696        let dir = a.space.files_dir(&space_name);
3697        let saved = std::fs::read_dir(&dir)
3698            .unwrap()
3699            .filter_map(std::result::Result::ok)
3700            .count();
3701        assert_eq!(
3702            saved, 1,
3703            "expected exactly one saved report file in {dir:?}"
3704        );
3705    }
3706
3707    #[tokio::test]
3708    async fn on_research_done_saves_report_to_original_space_even_if_user_switched() {
3709        let mut a = test_app();
3710
3711        // Start research in the default space (space A)
3712        a.start_research("rust async runtimes");
3713        let session_id = a.session.as_ref().unwrap().id.clone();
3714        let original_space_id = a.active_space.id.clone();
3715        let original_space_name = a.active_space.name.clone();
3716
3717        // Create a second space (space B) and switch to it
3718        let second_space = a.db.create_space("research-test-space-2").unwrap();
3719        a.space.ensure_space_dir(&second_space.name).unwrap();
3720        a.active_space = second_space.clone();
3721        a.session = None;
3722        a.messages.clear();
3723        a.files_cache.clear();
3724
3725        // Verify we're now in space B
3726        assert_eq!(a.active_space.id, second_space.id);
3727        assert_ne!(a.active_space.id, original_space_id);
3728
3729        // Simulate the research job completing while we're in space B
3730        a.on_research_done(Some((
3731            session_id.clone(),
3732            original_space_id.clone(),
3733            original_space_name.clone(),
3734            ResearchUpdate::Done(Ok(
3735                "# Rust Async Runtimes\n\nBody text. [1]\n\n## Sources\n1. https://a".to_string(),
3736            )),
3737        )));
3738
3739        // Assert: the report file lands in the ORIGINAL space's files_dir
3740        let original_dir = a.space.files_dir(&original_space_name);
3741        let original_files = std::fs::read_dir(&original_dir)
3742            .unwrap()
3743            .filter_map(std::result::Result::ok)
3744            .count();
3745        assert_eq!(
3746            original_files, 1,
3747            "expected exactly one report file in original space {original_dir:?}"
3748        );
3749
3750        // Assert: the report file did NOT land in the second (now-active) space's files_dir
3751        let second_dir = a.space.files_dir(&second_space.name);
3752        let second_files = std::fs::read_dir(&second_dir)
3753            .map_or(0, |d| d.filter_map(std::result::Result::ok).count());
3754        assert_eq!(
3755            second_files, 0,
3756            "expected no files in second (active) space {second_dir:?}"
3757        );
3758
3759        // Assert: files_cache is still empty (rescan_files was NOT called for space B,
3760        // because the report was saved to space A, not space B)
3761        assert_eq!(
3762            a.files_cache.len(),
3763            0,
3764            "files_cache should be empty since rescan was not triggered"
3765        );
3766    }
3767
3768    #[tokio::test]
3769    async fn on_research_done_failure_posts_error_message() {
3770        let mut a = test_app();
3771        a.start_research("rust async runtimes");
3772        let session_id = a.session.as_ref().unwrap().id.clone();
3773        let space_id = a.active_space.id.clone();
3774        let space_name = a.active_space.name.clone();
3775
3776        a.on_research_done(Some((
3777            session_id.clone(),
3778            space_id,
3779            space_name,
3780            ResearchUpdate::Done(Err("planner: network down".to_string())),
3781        )));
3782
3783        let (_, status) = a.drain_ui_events();
3784        assert!(status.contains("network down"));
3785        let stored = a.db.load_messages(&session_id).unwrap();
3786        assert!(
3787            stored
3788                .iter()
3789                .any(|m| m.role == "assistant" && m.content.contains("network down"))
3790        );
3791    }
3792
3793    #[tokio::test]
3794    async fn on_research_done_none_clears_domain_state() {
3795        let mut a = test_app();
3796        a.start_research("t");
3797        assert!(a.research_rx.is_some());
3798        a.research_live_input = "late steer".to_string();
3799        a.research_stage_rows = vec!["writer: done".to_string()];
3800
3801        a.on_research_done(None);
3802
3803        assert!(a.research_rx.is_none());
3804        assert!(a.research_running.is_none());
3805        assert!(a.research_live_input.is_empty());
3806        assert!(a.research_stage_rows.is_empty());
3807    }
3808
3809    #[test]
3810    fn parse_subquestions_reads_a_clean_json_array() {
3811        let qs = parse_subquestions(r#"["what is X", "how does Y work"]"#);
3812        assert_eq!(
3813            qs,
3814            vec!["what is X".to_string(), "how does Y work".to_string()]
3815        );
3816    }
3817
3818    #[test]
3819    fn parse_subquestions_strips_markdown_fences() {
3820        let qs = parse_subquestions("```json\n[\"a\", \"b\"]\n```");
3821        assert_eq!(qs, vec!["a".to_string(), "b".to_string()]);
3822    }
3823
3824    #[test]
3825    fn parse_subquestions_falls_back_to_bullet_lines() {
3826        let qs = parse_subquestions("- what is X\n- how does Y work\n* a third one");
3827        assert_eq!(
3828            qs,
3829            vec![
3830                "what is X".to_string(),
3831                "how does Y work".to_string(),
3832                "a third one".to_string()
3833            ]
3834        );
3835    }
3836
3837    #[test]
3838    fn parse_subquestions_falls_back_to_numbered_lines() {
3839        let qs = parse_subquestions("1. what is X\n2) how does Y work");
3840        assert_eq!(
3841            qs,
3842            vec!["what is X".to_string(), "how does Y work".to_string()]
3843        );
3844    }
3845
3846    #[test]
3847    fn parse_subquestions_caps_at_max() {
3848        let lines: Vec<String> = (0..10).map(|i| format!("- q{i}")).collect();
3849        let qs = parse_subquestions(&lines.join("\n"));
3850        assert_eq!(qs.len(), MAX_SUBQUESTIONS);
3851    }
3852
3853    #[test]
3854    fn parse_critique_recognizes_satisfied() {
3855        assert_eq!(parse_critique("SATISFIED"), Critique::Satisfied);
3856        assert_eq!(parse_critique("  satisfied  "), Critique::Satisfied);
3857    }
3858
3859    #[test]
3860    fn parse_critique_recognizes_gaps() {
3861        let c = parse_critique("GAPS:\n- what about pricing?\n- any recent incidents?");
3862        assert_eq!(
3863            c,
3864            Critique::Gaps(vec![
3865                "what about pricing?".to_string(),
3866                "any recent incidents?".to_string()
3867            ])
3868        );
3869    }
3870
3871    #[test]
3872    fn parse_critique_recognizes_contradiction() {
3873        let c = parse_critique("CONTRADICTION: source A says X, source B says not-X");
3874        assert_eq!(
3875            c,
3876            Critique::Contradiction("source A says X, source B says not-X".to_string())
3877        );
3878    }
3879
3880    #[test]
3881    fn parse_critique_falls_back_to_satisfied_on_garbage() {
3882        assert_eq!(
3883            parse_critique("uh, looks fine I guess?"),
3884            Critique::Satisfied
3885        );
3886        assert_eq!(parse_critique("GAPS:\n"), Critique::Satisfied);
3887    }
3888
3889    #[test]
3890    fn synthesizer_messages_includes_topic_and_all_findings() {
3891        let msgs = synthesizer_messages(
3892            "rust async runtimes",
3893            &["finding one".to_string(), "finding two".to_string()],
3894            &[],
3895        );
3896        assert_eq!(msgs[0].role, "system");
3897        assert!(msgs[1].content.contains("rust async runtimes"));
3898        assert!(msgs[1].content.contains("finding one"));
3899        assert!(msgs[1].content.contains("finding two"));
3900    }
3901
3902    #[test]
3903    fn synthesizer_messages_lists_pinned_sources_when_present() {
3904        let msgs = synthesizer_messages(
3905            "topic",
3906            &["finding one".to_string()],
3907            &["https://a.example".to_string()],
3908        );
3909        let user = msgs.iter().find(|m| m.role == "user").unwrap();
3910        assert!(
3911            user.content.contains("https://a.example"),
3912            "{}",
3913            user.content
3914        );
3915        assert!(
3916            user.content.to_lowercase().contains("prioritize"),
3917            "{}",
3918            user.content
3919        );
3920    }
3921
3922    #[test]
3923    fn synthesizer_messages_omits_pinned_section_when_empty() {
3924        let msgs = synthesizer_messages("topic", &["finding one".to_string()], &[]);
3925        let user = msgs.iter().find(|m| m.role == "user").unwrap();
3926        assert!(
3927            !user.content.to_lowercase().contains("prioritize"),
3928            "{}",
3929            user.content
3930        );
3931    }
3932
3933    #[test]
3934    fn critic_messages_includes_topic_and_draft() {
3935        let msgs = critic_messages("topic X", "draft text");
3936        assert!(msgs[1].content.contains("topic X"));
3937        assert!(msgs[1].content.contains("draft text"));
3938    }
3939
3940    #[test]
3941    fn resolver_messages_includes_contradiction_description() {
3942        let msgs = resolver_messages("t", "draft", &["f1".to_string()], "A vs B");
3943        assert!(msgs[1].content.contains("A vs B"));
3944        assert!(msgs[1].content.contains("f1"));
3945    }
3946
3947    #[test]
3948    fn writer_messages_includes_verified_draft() {
3949        let msgs = writer_messages("t", "verified content", &[]);
3950        assert!(msgs[1].content.contains("verified content"));
3951    }
3952}