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