1#![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#[derive(Clone)]
24pub enum ResearchUpdate {
25 Stage {
28 label: String,
29 detail: String,
30 },
31 SurveyReady {
35 questions: Vec<String>,
36 round: u8,
37 },
38 PlanReady {
42 questions: Vec<PlanQuestion>,
43 rework: bool,
44 },
45 Done(std::result::Result<String, String>),
46}
47
48const MAX_SUBQUESTIONS: usize = 6;
50const MAX_QUEUED_STEERS: usize = 64;
54const MAX_SURVEY_QUESTIONS: usize = 4;
56pub const MAX_SURVEY_ROUNDS: u8 = 3;
58pub const RESEARCH_SEARCHER_MAX_ITERS: usize = 6;
61
62#[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 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 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
109pub 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#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum SurveyReply {
138 Complete,
139 Questions(Vec<String>),
140 Malformed,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Approval {
146 Approved,
147 Revised(Vec<PlanQuestion>),
148 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 RESEARCH_CACHE_PREFIX: &str = "You are operating inside Nexus's deterministic research workflow. Treat this request as one step in a structured pipeline: obey the stage instruction that follows, use only evidence supplied by the current messages or tools, preserve exact URLs and citation meaning, and never invent a source or claim. Earlier messages may be workflow context; do not repeat them unless the current stage asks for it. Keep output within the requested format because the next pipeline stage parses it mechanically. This protocol is fixed across research stages so the reusable input prefix remains stable.";
166
167fn research_stage_prompt(stage: &str) -> String {
168 format!("{RESEARCH_CACHE_PREFIX}\n\n{stage}")
169}
170
171const SYNTHESIZER_PROMPT: &str = "You are the synthesis stage of a research pipeline. You'll be given the original topic and findings from several searcher agents, each already citing their own sources. Combine them into a single coherent draft report on the topic: organize by theme (not by sub-question), resolve obvious overlaps, keep every citation but you may renumber them consistently as you merge. Do not invent facts not present in the findings. Output the draft report in markdown, no preamble.";
172
173const CRITIC_PROMPT: &str = "You are the critic stage of a research pipeline. Given the original topic and a draft report, decide if it's ready. Respond in exactly one of these forms:\n- the single word SATISFIED, if the draft thoroughly covers the topic with no notable gaps or contradictions.\n- GAPS: followed by a newline-separated bullet list (each line starting with '- ') of specific missing sub-topics or unanswered angles, each phrased as a searchable question.\n- CONTRADICTION: followed by one line describing a specific factual contradiction between sources in the draft that isn't resolved.\nUse CONTRADICTION only for an actual conflict between sources, not a missing angle — missing angles are always GAPS. Respond with nothing else.";
174
175const RESOLVER_PROMPT: &str = "You are resolving a contradiction found in a research draft. You are given the topic, the draft, the full set of source findings gathered so far, and a description of the contradiction. Determine which claim the evidence better supports (or that both apply in different contexts) and write one paragraph resolving it, citing the [n] sources involved. Output only that paragraph.";
176
177const VERIFIER_PROMPT: &str = "You are the verifier stage. Given the topic, the gathered source findings (with their citations), and a draft report, check every factual claim in the draft against the source findings. Rewrite the draft unchanged except: (1) remove or mark with '⚠ unverifiable:' any claim not actually supported by the gathered findings; (2) immediately after a claim's citations, judge its confidence from citation count and cross-source agreement and, only for low or medium confidence, append the tag ‹low› or ‹med› right after the citation (high confidence is the default and stays untagged — do not tag it). Output the corrected draft in markdown, nothing else. You have a fetch_url tool restricted to already-cached pages: use it to check any direct quote in the draft against the cached source text, and mark a quote that doesn't actually match with '‹unverified quote›' immediately after it.";
178
179const WRITER_PROMPT: &str = "You are the final writer stage. Given the topic and a verified draft report (with inline [n] citations and prose from earlier stages, possibly including a contradiction-resolution paragraph to fold in), produce the final report: clean markdown, a short introductory paragraph, organized sections with headers, inline [n] citations preserved/renumbered consistently, and a trailing '## Sources' section listing every cited URL as 'n. url'. Output only the final report markdown, nothing else — it will be saved and shown to the user as-is.";
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum Critique {
184 Satisfied,
185 Gaps(Vec<String>),
186 Contradiction(String),
187}
188
189pub fn parse_subquestions(text: &str) -> Vec<String> {
194 let trimmed = text
195 .trim()
196 .trim_start_matches("```json")
197 .trim_start_matches("```")
198 .trim_end_matches("```")
199 .trim();
200 if let Ok(v) = serde_json::from_str::<Vec<String>>(trimmed) {
201 return v
202 .into_iter()
203 .map(|s| s.trim().to_string())
204 .filter(|s| !s.is_empty())
205 .take(MAX_SUBQUESTIONS)
206 .collect();
207 }
208 trimmed
209 .lines()
210 .map(strip_list_prefix)
211 .filter(|l| !l.is_empty())
212 .take(MAX_SUBQUESTIONS)
213 .collect()
214}
215
216fn strip_list_prefix(line: &str) -> String {
218 let s = line.trim().trim_start_matches(['-', '*']).trim();
219 let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(0);
220 if digits_end > 0
221 && let Some(rest) = s[digits_end..].strip_prefix(['.', ')'])
222 {
223 return rest.trim().to_string();
224 }
225 s.to_string()
226}
227
228pub fn parse_survey_reply(text: &str) -> SurveyReply {
237 let t = text.trim();
238 let head = t
241 .split_whitespace()
242 .next()
243 .unwrap_or("")
244 .trim_end_matches(|c: char| !c.is_ascii_alphanumeric());
245 if head.eq_ignore_ascii_case("COMPLETE") {
246 return SurveyReply::Complete;
247 }
248 let mut qs: Vec<String> = Vec::new();
249 for line in t.lines() {
250 let s = line.trim();
251 if s.is_empty() {
252 continue;
253 }
254 let marked =
257 s.starts_with(['-', '*']) || s.chars().next().is_some_and(|c| c.is_ascii_digit());
258 let q = strip_list_prefix(s);
259 if q.is_empty() {
260 continue;
261 }
262 if !marked && !q.ends_with('?') {
263 continue;
264 }
265 qs.push(q);
266 if qs.len() >= MAX_SURVEY_QUESTIONS {
267 break;
268 }
269 }
270 if qs.is_empty() {
271 SurveyReply::Malformed
272 } else {
273 SurveyReply::Questions(qs)
274 }
275}
276
277fn json_start(s: &str) -> Option<usize> {
281 s.find(['[', '{'])
282}
283
284pub fn parse_plan_blocks(text: &str) -> Vec<PlanQuestion> {
296 let trimmed = text
297 .trim()
298 .trim_start_matches("```json")
299 .trim_start_matches("```")
300 .trim_end_matches("```")
301 .trim();
302 if let Some(start) = json_start(trimmed) {
303 let candidate = &trimmed[start..];
304 if let Ok(v) = serde_json::from_str::<Vec<PlanQuestion>>(candidate) {
305 let qs: Vec<PlanQuestion> = v
306 .into_iter()
307 .map(|mut q| {
308 q.question = q.question.trim().to_string();
309 q
310 })
311 .filter(|q| !q.question.is_empty())
312 .take(MAX_SUBQUESTIONS)
313 .collect();
314 if !qs.is_empty() {
315 return qs;
316 }
317 }
318 if let Ok(v) = serde_json::from_str::<Vec<String>>(candidate) {
320 let qs: Vec<PlanQuestion> = v
321 .into_iter()
322 .map(|s| s.trim().to_string())
323 .filter(|s| !s.is_empty())
324 .map(PlanQuestion::bare)
325 .take(MAX_SUBQUESTIONS)
326 .collect();
327 if !qs.is_empty() {
328 return qs;
329 }
330 }
331 return Vec::new();
333 }
334 parse_subquestions(text)
335 .into_iter()
336 .map(PlanQuestion::bare)
337 .collect()
338}
339
340pub fn parse_approval(text: &str) -> Approval {
346 let upper = text.trim().to_ascii_uppercase();
347 if upper == "APPROVED"
348 || upper.starts_with("APPROVED:")
349 || upper.starts_with("APPROVED —")
350 || upper.starts_with("APPROVED\n")
351 {
352 return Approval::Approved;
353 }
354 let trimmed = text
359 .trim()
360 .trim_start_matches("```json")
361 .trim_start_matches("```")
362 .trim_end_matches("```")
363 .trim();
364 if json_start(trimmed).is_some() {
365 let qs = parse_plan_blocks(text);
366 return if qs.is_empty() {
367 Approval::Malformed
368 } else {
369 Approval::Revised(qs)
370 };
371 }
372 let has_markers = trimmed.lines().any(|l| {
374 let s = l.trim();
375 s.starts_with(['-', '*']) || s.chars().next().is_some_and(|c| c.is_ascii_digit())
376 });
377 if !has_markers {
378 return Approval::Malformed;
379 }
380 let qs = parse_plan_blocks(text);
381 if qs.is_empty() {
382 Approval::Malformed
383 } else {
384 Approval::Revised(qs)
385 }
386}
387
388pub fn parse_critique(text: &str) -> Critique {
392 let t = text.trim();
393 if t.eq_ignore_ascii_case("SATISFIED") {
394 return Critique::Satisfied;
395 }
396 if let Some(rest) = t.strip_prefix("CONTRADICTION:") {
397 let desc = rest.trim();
398 if !desc.is_empty() {
399 return Critique::Contradiction(desc.to_string());
400 }
401 }
402 if let Some(rest) = t.strip_prefix("GAPS:") {
403 let gaps: Vec<String> = rest
404 .lines()
405 .map(str::trim)
406 .filter_map(|l| l.strip_prefix('-'))
407 .map(|l| l.trim().to_string())
408 .filter(|l| !l.is_empty())
409 .take(MAX_SUBQUESTIONS)
410 .collect();
411 if !gaps.is_empty() {
412 return Critique::Gaps(gaps);
413 }
414 }
415 Critique::Satisfied
416}
417
418fn planner_messages_with_context(
423 topic: &str,
424 answers: &[(String, String)],
425 known: &[String],
426) -> Vec<ChatMessage> {
427 let mut user = String::new();
428 if !answers.is_empty() {
429 user.push_str("The user answered clarifying questions before planning:\n");
430 for (i, (qs, reply)) in answers.iter().enumerate() {
431 let _ = write!(user, "Round {} — asked: {}\nAnswered: {reply}\n", i + 1, qs);
432 }
433 user.push('\n');
434 }
435 if known.is_empty() {
436 user.push_str(topic);
437 } else {
438 let _ = write!(
439 user,
440 "Topic: {topic}\n\nAlready known (from local files and/or a preliminary web survey) — \
441 plan sub-questions for the gaps, not what's already covered:\n{}",
442 known.join("\n\n")
443 );
444 }
445 vec![
446 ChatMessage::text("system", research_stage_prompt(PLANNER_PROMPT)),
447 ChatMessage::text("user", user),
448 ]
449}
450
451fn survey_messages(topic: &str, rounds: &[(String, String)]) -> Vec<ChatMessage> {
456 let mut user = format!("Research topic: {topic}\n");
457 if !rounds.is_empty() {
458 user.push_str("\nSo far:\n");
459 for (i, (qs, reply)) in rounds.iter().enumerate() {
460 let _ = write!(
461 user,
462 "Round {} — I asked:\n{qs}\nThe user answered: {reply}\n",
463 i + 1
464 );
465 }
466 }
467 vec![
468 ChatMessage::text("system", research_stage_prompt(SURVEY_AGENT_PROMPT)),
469 ChatMessage::text("user", user),
470 ]
471}
472
473fn plan_approval_messages(
477 topic: &str,
478 questions: &[PlanQuestion],
479 user_reply: &str,
480) -> Vec<ChatMessage> {
481 vec![
482 ChatMessage::text("system", research_stage_prompt(PLAN_APPROVAL_PROMPT)),
483 ChatMessage::text(
484 "user",
485 format!(
486 "Topic: {topic}\n\nPlan:\n{}\n\nUser reply: {user_reply}",
487 plan_text(questions)
488 ),
489 ),
490 ]
491}
492
493fn synthesizer_messages(topic: &str, findings: &[String], pinned: &[String]) -> Vec<ChatMessage> {
494 let body = findings
495 .iter()
496 .enumerate()
497 .map(|(i, f)| format!("--- Searcher {} findings ---\n{f}", i + 1))
498 .collect::<Vec<_>>()
499 .join("\n\n");
500 let mut user = format!("Topic: {topic}\n\n");
501 if !pinned.is_empty() {
502 let _ = write!(
503 user,
504 "Prioritize these pinned sources in the synthesis if their content is present in the findings below:\n{}\n\n",
505 pinned.join("\n")
506 );
507 }
508 user.push_str(&body);
509 vec![
510 ChatMessage::text("system", research_stage_prompt(SYNTHESIZER_PROMPT)),
511 ChatMessage::text("user", user),
512 ]
513}
514
515fn critic_messages(topic: &str, draft: &str) -> Vec<ChatMessage> {
516 vec![
517 ChatMessage::text("system", research_stage_prompt(CRITIC_PROMPT)),
518 ChatMessage::text("user", format!("Topic: {topic}\n\nDraft:\n{draft}")),
519 ]
520}
521
522fn resolver_messages(
523 topic: &str,
524 draft: &str,
525 findings: &[String],
526 contradiction: &str,
527) -> Vec<ChatMessage> {
528 let body = findings.join("\n\n");
529 vec![
530 ChatMessage::text("system", research_stage_prompt(RESOLVER_PROMPT)),
531 ChatMessage::text(
532 "user",
533 format!(
534 "Topic: {topic}\n\nContradiction: {contradiction}\n\nDraft:\n{draft}\n\nSource findings:\n{body}"
535 ),
536 ),
537 ]
538}
539
540fn verifier_messages(topic: &str, draft: &str, findings: &[String]) -> Vec<ChatMessage> {
541 let body = findings.join("\n\n");
542 vec![
543 ChatMessage::text("system", research_stage_prompt(VERIFIER_PROMPT)),
544 ChatMessage::text(
545 "user",
546 format!("Topic: {topic}\n\nSource findings:\n{body}\n\nDraft:\n{draft}"),
547 ),
548 ]
549}
550
551fn writer_messages(topic: &str, verified_draft: &str, pinned: &[String]) -> Vec<ChatMessage> {
552 let mut user = format!("Topic: {topic}\n\n");
553 if !pinned.is_empty() {
554 let _ = write!(
555 user,
556 "Prioritize these pinned sources in the final report if their content is present in the verified draft below:\n{}\n\n",
557 pinned.join("\n")
558 );
559 }
560 let _ = write!(user, "Verified draft:\n{verified_draft}");
561 vec![
562 ChatMessage::text("system", research_stage_prompt(WRITER_PROMPT)),
563 ChatMessage::text("user", user),
564 ]
565}
566
567use std::sync::Arc;
568
569use tokio::sync::mpsc;
570
571use crate::db::Db;
572use crate::provider::openrouter::OpenRouter;
573use crate::provider::{ChatParams, StreamEvent, Usage};
574use crate::tools::{ToolBox, ToolExecutor};
575
576use super::ResearchMsg;
577use super::{SurveyGate, SurveyPhase};
578
579#[derive(Clone)]
583struct ResearchUsage {
584 db_path: std::path::PathBuf,
585 session_id: String,
586 prompt_cache_key: String,
587 space_id: String,
588 backend: &'static str,
589}
590
591impl ResearchUsage {
592 fn chat_params(&self) -> ChatParams {
593 ChatParams {
594 prompt_cache_key: Some(self.prompt_cache_key.clone()),
595 ..ChatParams::default()
596 }
597 }
598
599 fn record(&self, model: &str, usage: Usage) {
600 if usage.prompt_tokens == 0 && usage.completion_tokens == 0 {
604 return;
605 }
606 let Ok(db) = Db::open(&self.db_path) else {
607 return;
608 };
609 let cost_is_provider = usage.cost.is_some();
610 let cost = usage.cost.or_else(|| {
611 db.request_cost(
612 model,
613 usage.prompt_tokens,
614 usage.completion_tokens,
615 usage.cache_read_tokens,
616 usage.cache_creation_tokens,
617 )
618 });
619 let _ = db.log_usage(
620 self.backend,
621 model,
622 usage.prompt_tokens,
623 usage.completion_tokens,
624 usage.cache_read_tokens,
625 usage.cache_creation_tokens,
626 cost,
627 cost_is_provider,
628 Some(&self.session_id),
629 Some(&self.space_id),
630 );
631 }
632}
633
634fn send_stage(
636 tx: &mpsc::UnboundedSender<ResearchMsg>,
637 ids: &(String, String, String),
638 label: impl Into<String>,
639 detail: impl Into<String>,
640) {
641 let _ = tx.send((
642 ids.0.clone(),
643 ids.1.clone(),
644 ids.2.clone(),
645 ResearchUpdate::Stage {
646 label: label.into(),
647 detail: detail.into(),
648 },
649 ));
650}
651
652pub fn drain_steers(rx: &mut mpsc::UnboundedReceiver<String>) -> Vec<String> {
656 let mut out = Vec::new();
657 while let Ok(s) = rx.try_recv() {
658 out.push(s);
659 }
660 out
661}
662
663async fn complete_text(
664 provider: &OpenRouter,
665 model: &str,
666 messages: Vec<ChatMessage>,
667 usage: &ResearchUsage,
668) -> Result<String, String> {
669 let params = usage.chat_params();
670 provider
671 .complete_with_params(model, messages, ¶ms)
672 .await
673 .map(|completion| {
674 if let Some(reported) = completion.usage {
675 usage.record(model, reported);
676 }
677 completion.text.trim().to_string()
678 })
679 .map_err(|e| e.to_string())
680}
681
682async fn complete_agent(
685 provider: &OpenRouter,
686 model: &str,
687 messages: Vec<ChatMessage>,
688 usage: &ResearchUsage,
689 tx: &mpsc::UnboundedSender<ResearchMsg>,
690 ids: &(String, String, String),
691 label: &str,
692) -> Result<String, String> {
693 match complete_text(provider, model, messages, usage).await {
694 Ok(text) => Ok(text),
695 Err(e) => {
696 send_stage(tx, ids, label, format!("error — {e}"));
697 Err(e)
698 }
699 }
700}
701
702async fn plan(
703 provider: &OpenRouter,
704 model: &str,
705 topic: &str,
706 answers: &[(String, String)],
707 known: &[String],
708 usage: &ResearchUsage,
709) -> Result<Vec<PlanQuestion>, String> {
710 let text = complete_text(
711 provider,
712 model,
713 planner_messages_with_context(topic, answers, known),
714 usage,
715 )
716 .await?;
717 let qs = parse_plan_blocks(&text);
718 if qs.is_empty() {
719 return Err(format!(
720 "planner returned no usable sub-questions (raw reply: {text:.200})"
721 ));
722 }
723 Ok(qs)
724}
725
726#[allow(clippy::too_many_arguments)]
741pub struct SearcherCtx<'a> {
745 pub toolbox: Arc<dyn ToolExecutor>,
746 usage: Arc<ResearchUsage>,
747 pub tx: &'a mpsc::UnboundedSender<ResearchMsg>,
748 pub ids: &'a (String, String, String),
749}
750
751pub struct SearcherSlot {
754 pub batch: String,
755 pub idx: usize,
756 pub total: usize,
757}
758
759async fn run_searcher(
760 provider: &OpenRouter,
761 model: &str,
762 prompt: &str,
763 display: &str,
764 ctx: SearcherCtx<'_>,
765 slot: SearcherSlot,
766) -> String {
767 let label = format!("searcher {} {}/{}", slot.batch, slot.idx + 1, slot.total);
770 send_stage(
771 ctx.tx,
772 ctx.ids,
773 &label,
774 format!("working — investigating \"{display}\""),
775 );
776 let messages = vec![
777 ChatMessage::text("system", research_stage_prompt(SEARCHER_PROMPT)),
778 ChatMessage::text("user", prompt),
779 ];
780 let tools = ctx.toolbox.defs();
781 let (mut rx, abort) = provider.stream_chat(
782 model.to_string(),
783 messages,
784 ctx.usage.chat_params(),
785 tools,
786 ctx.toolbox,
787 RESEARCH_SEARCHER_MAX_ITERS,
788 );
789 let _abort = super::AbortOnDrop(abort);
790 let mut buf = String::new();
791 while let Some(ev) = rx.recv().await {
792 match ev {
793 StreamEvent::Token(t) => buf.push_str(&t),
794 StreamEvent::Status(s) => {
795 send_stage(ctx.tx, ctx.ids, &label, format!("working — {s}"));
796 }
797 StreamEvent::ToolCall {
798 name,
799 arguments,
800 result,
801 ..
802 } => {
803 let summary = crate::app::tool_call_summary(&name, &arguments, &result);
804 send_stage(ctx.tx, ctx.ids, &label, format!("working — {summary}"));
805 }
806 StreamEvent::Error(e) => {
807 send_stage(ctx.tx, ctx.ids, &label, format!("error — {e}"));
808 return format!("[search agent error on \"{display}\": {e}]");
809 }
810 StreamEvent::Usage(reported) => ctx.usage.record(model, reported),
811 StreamEvent::Done => break,
812 StreamEvent::Reasoning(_) => {}
813 }
814 }
815 let text = buf.trim();
816 if text.is_empty() {
817 send_stage(ctx.tx, ctx.ids, &label, "error — no findings returned");
818 format!("[no findings for \"{display}\"]")
819 } else {
820 send_stage(
821 ctx.tx,
822 ctx.ids,
823 &label,
824 format!("done — answered \"{display}\""),
825 );
826 text.to_string()
827 }
828}
829
830async fn verify_with_quote_check(
838 provider: &OpenRouter,
839 model: &str,
840 messages: Vec<ChatMessage>,
841 cache_only_toolbox: Arc<dyn ToolExecutor>,
842 usage: &ResearchUsage,
843 tx: &mpsc::UnboundedSender<ResearchMsg>,
844 ids: &(String, String, String),
845) -> String {
846 let tools = cache_only_toolbox.defs();
847 let (mut rx, abort) = provider.stream_chat(
848 model.to_string(),
849 messages,
850 usage.chat_params(),
851 tools,
852 cache_only_toolbox,
853 RESEARCH_SEARCHER_MAX_ITERS,
854 );
855 let _abort = super::AbortOnDrop(abort);
856 let mut buf = String::new();
857 let mut failed = false;
858 while let Some(ev) = rx.recv().await {
859 match ev {
860 StreamEvent::Token(t) => buf.push_str(&t),
861 StreamEvent::Status(s) => send_stage(tx, ids, "verifier", format!("working — {s}")),
862 StreamEvent::ToolCall {
863 name,
864 arguments,
865 result,
866 ..
867 } => {
868 let summary = crate::app::tool_call_summary(&name, &arguments, &result);
869 send_stage(tx, ids, "verifier", format!("working — {summary}"));
870 }
871 StreamEvent::Error(e) => {
872 failed = true;
873 send_stage(tx, ids, "verifier", format!("error — {e}"));
874 break;
875 }
876 StreamEvent::Usage(reported) => usage.record(model, reported),
877 StreamEvent::Done => break,
878 StreamEvent::Reasoning(_) => {}
879 }
880 }
881 if !failed {
882 if buf.trim().is_empty() {
883 send_stage(
884 tx,
885 ids,
886 "verifier",
887 "error — no verification output returned",
888 );
889 } else {
890 send_stage(tx, ids, "verifier", "done — source checks complete");
891 }
892 }
893 buf
894}
895
896struct SearcherBatch<'a> {
898 provider: &'a OpenRouter,
899 model: &'a str,
900 toolbox: &'a Arc<dyn ToolExecutor>,
901 usage: &'a Arc<ResearchUsage>,
902 tx: &'a mpsc::UnboundedSender<ResearchMsg>,
903 ids: &'a (String, String, String),
904 batch: &'a str,
905}
906
907async fn run_searchers(batch_ctx: SearcherBatch<'_>, items: &[(String, String)]) -> Vec<String> {
914 let SearcherBatch {
915 provider,
916 model,
917 toolbox,
918 usage,
919 tx,
920 ids,
921 batch,
922 } = batch_ctx;
923 let total = items.len();
924 send_stage(
925 tx,
926 ids,
927 format!("search {batch}"),
928 format!("working — 0/{total} agents complete"),
929 );
930 let mut set = tokio::task::JoinSet::new();
931 for (idx, (prompt, display)) in items.iter().cloned().enumerate() {
932 let provider = provider.clone();
933 let model = model.to_string();
934 let toolbox = toolbox.clone();
935 let usage = usage.clone();
936 let tx = tx.clone();
937 let ids = ids.clone();
938 let batch = batch.to_string();
939 set.spawn(async move {
940 let ctx = SearcherCtx {
941 toolbox,
942 usage,
943 tx: &tx,
944 ids: &ids,
945 };
946 let slot = SearcherSlot { batch, idx, total };
947 run_searcher(&provider, &model, &prompt, &display, ctx, slot).await
948 });
949 }
950 let mut done = 0usize;
951 let mut findings = Vec::with_capacity(total);
952 while let Some(res) = set.join_next().await {
953 done += 1;
954 send_stage(
955 tx,
956 ids,
957 format!("search {batch}"),
958 format!("working — {done}/{total} agents complete"),
959 );
960 findings.push(res.unwrap_or_else(|e| format!("[search agent panicked: {e}]")));
961 }
962 send_stage(
963 tx,
964 ids,
965 format!("search {batch}"),
966 format!("done — {done}/{total} agents complete"),
967 );
968 findings
969}
970
971pub struct ResearchOptions {
976 pub research_provider: OpenRouter,
977 pub research_model: String,
978 pub embedding_provider: OpenRouter,
979 pub embedding_model: String,
980 pub db_path: std::path::PathBuf,
981 pub topic: String,
982 pub reply_rx: Option<mpsc::UnboundedReceiver<String>>,
983 pub steer_rx: mpsc::UnboundedReceiver<String>,
984 pub toolbox: Arc<dyn ToolExecutor>,
985 pub tx: mpsc::UnboundedSender<ResearchMsg>,
986 pub session_id: String,
987 pub prompt_cache_key: String,
988 pub space_id: String,
989 pub space_name: String,
990}
991
992pub async fn run_research(mut opts: ResearchOptions) {
993 let result = run_research_inner(&mut opts).await;
994 let _ = opts.tx.send((
995 opts.session_id,
996 opts.space_id,
997 opts.space_name,
998 ResearchUpdate::Done(result),
999 ));
1000}
1001
1002async fn local_known_chunks(
1007 provider: &OpenRouter,
1008 embedding_model: &str,
1009 db_path: &std::path::Path,
1010 space_id: &str,
1011 topic: &str,
1012) -> Vec<String> {
1013 if embedding_model.trim().is_empty() {
1014 return Vec::new();
1015 }
1016 let Ok(mut vecs) = provider
1017 .embed(embedding_model, vec![topic.to_string()])
1018 .await
1019 else {
1020 return Vec::new();
1021 };
1022 if vecs.is_empty() {
1023 return Vec::new();
1024 }
1025 let query = vecs.remove(0);
1026 let Ok(conn) = crate::db::open_attached(db_path) else {
1027 return Vec::new();
1028 };
1029 crate::db::semantic_chunks(&conn, space_id, &query, 5)
1030 .map(|hits| {
1031 hits.into_iter()
1032 .map(|(name, loc, text, _)| format!("{name} ({loc}): {text}"))
1033 .collect()
1034 })
1035 .unwrap_or_default()
1036}
1037
1038async fn await_survey_reply(
1043 tx: &mpsc::UnboundedSender<ResearchMsg>,
1044 ids: &(String, String, String),
1045 reply_rx: &mut mpsc::UnboundedReceiver<String>,
1046 questions: &[String],
1047 round: u8,
1048) -> Option<String> {
1049 let _ = tx.send((
1050 ids.0.clone(),
1051 ids.1.clone(),
1052 ids.2.clone(),
1053 ResearchUpdate::SurveyReady {
1054 questions: questions.to_vec(),
1055 round,
1056 },
1057 ));
1058 reply_rx.recv().await.map(|r| r.trim().to_string())
1059}
1060
1061async fn run_user_survey(
1071 provider: &OpenRouter,
1072 model: &str,
1073 topic: &str,
1074 reply_rx: &mut mpsc::UnboundedReceiver<String>,
1075 tx: &mpsc::UnboundedSender<ResearchMsg>,
1076 ids: &(String, String, String),
1077 usage: &ResearchUsage,
1078) -> Result<Vec<(String, String)>, String> {
1079 let mut rounds: Vec<(String, String)> = Vec::new();
1080 let mut history = survey_messages(topic, &[]);
1081 let initial = complete_text(provider, model, history.clone(), usage)
1082 .await
1083 .map_err(|e| format!("survey agent failed: {e}"))?;
1084 history.push(ChatMessage::text("assistant", initial.clone()));
1085 let mut questions = parse_survey_reply(&initial);
1086 let mut raw = initial;
1087 let mut round: u8 = 1;
1088 loop {
1089 match questions {
1090 SurveyReply::Complete => return Ok(rounds),
1091 SurveyReply::Malformed => {
1092 return Err(format!(
1093 "survey agent returned unusable output (raw reply: {raw:.200})"
1094 ));
1095 }
1096 SurveyReply::Questions(qs) if qs.is_empty() || round > MAX_SURVEY_ROUNDS => {
1097 return Ok(rounds);
1098 }
1099 SurveyReply::Questions(qs) => {
1100 let Some(reply) = await_survey_reply(tx, ids, reply_rx, &qs, round).await else {
1101 return Err("survey cancelled — the reply channel closed".to_string());
1105 };
1106 if reply.is_empty() {
1107 return Ok(rounds); }
1109 let asked = qs.join("\n");
1110 rounds.push((asked.clone(), reply.clone()));
1111 round += 1;
1112 if round > MAX_SURVEY_ROUNDS {
1113 return Ok(rounds);
1114 }
1115 history.push(ChatMessage::text(
1120 "user",
1121 format!("Questions asked:\n{asked}\nUser answer: {reply}"),
1122 ));
1123 raw = complete_text(provider, model, history.clone(), usage)
1124 .await
1125 .map_err(|e| format!("survey follow-up failed: {e}"))?;
1126 history.push(ChatMessage::text("assistant", raw.clone()));
1127 questions = parse_survey_reply(&raw);
1128 }
1129 }
1130 }
1131}
1132
1133struct PlanApprovalContext<'a> {
1135 provider: &'a OpenRouter,
1136 model: &'a str,
1137 topic: &'a str,
1138 usage: &'a ResearchUsage,
1139 tx: &'a mpsc::UnboundedSender<ResearchMsg>,
1140 ids: &'a (String, String, String),
1141}
1142
1143async fn await_plan_approval(
1151 ctx: PlanApprovalContext<'_>,
1152 questions: &mut Vec<PlanQuestion>,
1153 reply_rx: &mut mpsc::UnboundedReceiver<String>,
1154) -> Result<(), String> {
1155 let PlanApprovalContext {
1156 provider,
1157 model,
1158 topic,
1159 usage,
1160 tx,
1161 ids,
1162 } = ctx;
1163 let mut rework = false;
1164 loop {
1165 let _ = tx.send((
1166 ids.0.clone(),
1167 ids.1.clone(),
1168 ids.2.clone(),
1169 ResearchUpdate::PlanReady {
1170 questions: questions.clone(),
1171 rework,
1172 },
1173 ));
1174 let Some(reply) = reply_rx.recv().await else {
1175 return Err(
1179 "plan approval cancelled — the reply channel closed before the plan was approved"
1180 .to_string(),
1181 );
1182 };
1183 if reply.trim().is_empty() {
1184 return Ok(()); }
1186 let text = complete_text(
1187 provider,
1188 model,
1189 plan_approval_messages(topic, questions, &reply),
1190 usage,
1191 )
1192 .await
1193 .map_err(|e| format!("plan approval agent failed: {e}"))?;
1194 match parse_approval(&text) {
1195 Approval::Approved => return Ok(()),
1196 Approval::Revised(revised) if !revised.is_empty() => {
1197 if rework {
1198 return Err(
1202 "plan was revised twice — rework cap reached; re-run /research \
1203 with the final plan"
1204 .to_string(),
1205 );
1206 }
1207 *questions = revised;
1208 rework = true;
1209 }
1210 Approval::Revised(_) | Approval::Malformed => {
1211 return Err(format!(
1212 "plan approval agent returned an unusable verdict (raw reply: {text:.200})"
1213 ));
1214 }
1215 }
1216 }
1217}
1218
1219#[allow(clippy::too_many_lines)]
1221async fn run_research_inner(opts: &mut ResearchOptions) -> Result<String, String> {
1222 let ids = &(
1223 opts.session_id.clone(),
1224 opts.space_id.clone(),
1225 opts.space_name.clone(),
1226 );
1227 let prompt_cache_key = opts.prompt_cache_key.clone();
1228 let ResearchOptions {
1229 research_provider,
1230 research_model,
1231 embedding_provider,
1232 embedding_model,
1233 db_path,
1234 topic,
1235 reply_rx,
1236 steer_rx,
1237 toolbox,
1238 tx,
1239 ..
1240 } = &mut *opts;
1241 let db_path = db_path.as_path();
1242 let usage = Arc::new(ResearchUsage {
1243 db_path: db_path.to_path_buf(),
1244 session_id: ids.0.clone(),
1245 prompt_cache_key,
1246 space_id: ids.1.clone(),
1247 backend: research_provider.backend_tag().name(),
1248 });
1249 let gather_task = {
1254 let provider = embedding_provider.clone();
1255 let model = embedding_model.clone();
1256 let db_path = db_path.to_path_buf();
1257 let space_id = ids.1.clone();
1258 let topic = topic.clone();
1259 let research_provider = research_provider.clone();
1260 let research_model = research_model.clone();
1261 let toolbox = toolbox.clone();
1262 let usage = usage.clone();
1263 let tx = tx.clone();
1264 let ids = ids.clone();
1265 tokio::spawn(async move {
1266 let known =
1267 async { local_known_chunks(&provider, &model, &db_path, &space_id, &topic).await };
1268 let survey = async {
1269 send_stage(
1270 &tx,
1271 &ids,
1272 "web survey",
1273 "working — mapping the topic, debates, evidence, and source landscape",
1274 );
1275 let survey_question = format!(
1276 "Conduct a broad preliminary survey of this research topic before planning: {topic}. \
1277 Identify the major concepts, current debates, useful source types, and important \
1278 evidence gaps."
1279 );
1280 let ctx = SearcherCtx {
1281 toolbox,
1282 usage,
1283 tx: &tx,
1284 ids: &ids,
1285 };
1286 let slot = SearcherSlot {
1287 batch: "web survey".to_string(),
1288 idx: 0,
1289 total: 1,
1290 };
1291 let survey = run_searcher(
1292 &research_provider,
1293 &research_model,
1294 &survey_question,
1295 &survey_question,
1296 ctx,
1297 slot,
1298 )
1299 .await;
1300 persist_session_sources(&db_path, &ids.0, std::slice::from_ref(&survey));
1301 survey
1302 };
1303 let (known, survey) = tokio::join!(known, survey);
1304 (known, survey)
1305 })
1306 };
1307 let gather_guard = super::AbortOnDrop(gather_task.abort_handle());
1312
1313 let answers: Vec<(String, String)> = if let Some(rx) = reply_rx.as_mut() {
1317 run_user_survey(
1318 research_provider,
1319 research_model,
1320 topic,
1321 rx,
1322 tx,
1323 ids,
1324 &usage,
1325 )
1326 .await
1327 .map_err(|e| {
1328 send_stage(tx, ids, "survey", format!("error — {e}"));
1329 e
1330 })?
1331 } else {
1332 Vec::new()
1333 };
1334
1335 let (known, web_survey) = match gather_task.await {
1340 Ok(v) => v,
1341 Err(e) if e.is_panic() => return Err(format!("context gathering panicked: {e}")),
1342 Err(e) => return Err(format!("context gathering was cancelled: {e}")),
1343 };
1344 drop(gather_guard);
1345 let mut planning_context = known;
1346 if !web_survey.is_empty() && !web_survey.starts_with('[') {
1347 planning_context.push(format!("Preliminary web survey:\n{web_survey}"));
1348 send_stage(
1349 tx,
1350 ids,
1351 "web survey",
1352 "done — landscape mapped for planning",
1353 );
1354 } else {
1355 send_stage(
1356 tx,
1357 ids,
1358 "web survey",
1359 "error — survey failed; planning from local context only",
1360 );
1361 }
1362
1363 send_stage(
1364 tx,
1365 ids,
1366 "planner",
1367 "working — decomposing the surveyed landscape into focused questions",
1368 );
1369 let mut questions = match plan(
1370 research_provider,
1371 research_model,
1372 topic,
1373 &answers,
1374 &planning_context,
1375 &usage,
1376 )
1377 .await
1378 {
1379 Ok(questions) => questions,
1380 Err(e) => {
1381 send_stage(tx, ids, "planner", format!("error — {e}"));
1382 return Err(e);
1383 }
1384 };
1385 send_stage(
1386 tx,
1387 ids,
1388 "planner",
1389 format!("done — proposed {} questions", questions.len()),
1390 );
1391
1392 if let Some(rx) = reply_rx.as_mut() {
1398 await_plan_approval(
1399 PlanApprovalContext {
1400 provider: research_provider,
1401 model: research_model,
1402 topic,
1403 usage: &usage,
1404 tx,
1405 ids,
1406 },
1407 &mut questions,
1408 rx,
1409 )
1410 .await?;
1411 }
1412
1413 let pinned = rusqlite::Connection::open(db_path)
1414 .ok()
1415 .and_then(|conn| crate::db::pinned_urls(&conn, &ids.0).ok())
1416 .unwrap_or_default();
1417
1418 let mut findings: Vec<String> = if !web_survey.is_empty() && !web_survey.starts_with('[') {
1419 vec![format!("--- Survey overview ---\n{web_survey}")]
1422 } else {
1423 Vec::new()
1424 };
1425 let searcher_items: Vec<(String, String)> = questions
1429 .iter()
1430 .map(|q| (q.prompt(topic), q.question.clone()))
1431 .collect();
1432 findings.extend(
1433 run_searchers(
1434 SearcherBatch {
1435 provider: research_provider,
1436 model: research_model,
1437 toolbox,
1438 usage: &usage,
1439 tx,
1440 ids,
1441 batch: "round 1",
1442 },
1443 &searcher_items,
1444 )
1445 .await,
1446 );
1447 persist_session_sources(db_path, &ids.0, &findings);
1448
1449 let mut steer_seq: usize = 0;
1457 let steers = drain_steers(steer_rx);
1458 if !steers.is_empty() {
1459 let steer_items: Vec<(String, String)> =
1460 steers.iter().map(|s| (s.clone(), s.clone())).collect();
1461 for s in &steers {
1462 steer_seq += 1;
1463 send_stage(tx, ids, format!("steer #{steer_seq}"), s.clone());
1464 }
1465 let steered = run_searchers(
1466 SearcherBatch {
1467 provider: research_provider,
1468 model: research_model,
1469 toolbox,
1470 usage: &usage,
1471 tx,
1472 ids,
1473 batch: "round 1 steer",
1474 },
1475 &steer_items,
1476 )
1477 .await;
1478 persist_session_sources(db_path, &ids.0, &steered);
1479 findings.extend(steered);
1480 }
1481
1482 send_stage(
1483 tx,
1484 ids,
1485 "synthesizer",
1486 format!("working — combining {} agent findings", findings.len()),
1487 );
1488 let mut synthesizer_history =
1489 synthesizer_messages(topic, &crate::tools::dedup_source_lines(&findings), &pinned);
1490 let mut draft = complete_agent(
1491 research_provider,
1492 research_model,
1493 synthesizer_history.clone(),
1494 &usage,
1495 tx,
1496 ids,
1497 "synthesizer",
1498 )
1499 .await?;
1500 send_stage(tx, ids, "synthesizer", "done — draft assembled");
1501
1502 send_stage(
1503 tx,
1504 ids,
1505 "critic",
1506 "working — checking coverage and contradictions",
1507 );
1508 let mut critique = parse_critique(
1509 &complete_agent(
1510 research_provider,
1511 research_model,
1512 critic_messages(topic, &draft),
1513 &usage,
1514 tx,
1515 ids,
1516 "critic",
1517 )
1518 .await?,
1519 );
1520 let critic_detail = match &critique {
1521 Critique::Satisfied => "done — draft is sufficiently complete".to_string(),
1522 Critique::Gaps(gaps) => {
1525 let list = gaps
1526 .iter()
1527 .enumerate()
1528 .map(|(i, g)| format!("{}. {g}", i + 1))
1529 .collect::<Vec<_>>()
1530 .join("\n");
1531 format!("done — found {} coverage gaps:\n{list}", gaps.len())
1532 }
1533 Critique::Contradiction(_) => "done — found a source contradiction".to_string(),
1534 };
1535 send_stage(tx, ids, "critic", critic_detail);
1536
1537 if let Critique::Gaps(gaps) = &critique {
1538 let more = run_searchers(
1539 SearcherBatch {
1540 provider: research_provider,
1541 model: research_model,
1542 toolbox,
1543 usage: &usage,
1544 tx,
1545 ids,
1546 batch: "round 2",
1547 },
1548 &gaps
1549 .iter()
1550 .map(|g| (g.clone(), g.clone()))
1551 .collect::<Vec<_>>(),
1552 )
1553 .await;
1554 persist_session_sources(db_path, &ids.0, &more);
1555 let mut follow_up_findings = more;
1556
1557 let steers = drain_steers(steer_rx);
1558 if !steers.is_empty() {
1559 let steer_items: Vec<(String, String)> =
1560 steers.iter().map(|s| (s.clone(), s.clone())).collect();
1561 for s in &steers {
1562 steer_seq += 1;
1563 send_stage(tx, ids, format!("steer #{steer_seq}"), s.clone());
1564 }
1565 let steered = run_searchers(
1566 SearcherBatch {
1567 provider: research_provider,
1568 model: research_model,
1569 toolbox,
1570 usage: &usage,
1571 tx,
1572 ids,
1573 batch: "round 2 steer",
1574 },
1575 &steer_items,
1576 )
1577 .await;
1578 persist_session_sources(db_path, &ids.0, &steered);
1579 follow_up_findings.extend(steered);
1580 }
1581 findings.extend(follow_up_findings.clone());
1582
1583 send_stage(
1584 tx,
1585 ids,
1586 "synthesizer r2",
1587 "working — merging follow-up findings",
1588 );
1589 synthesizer_history.push(ChatMessage::text("assistant", draft.clone()));
1593 synthesizer_history.push(ChatMessage::text(
1594 "user",
1595 format!(
1596 "Additional searcher findings to incorporate:\n{}",
1597 crate::tools::dedup_source_lines(&follow_up_findings).join("\n\n")
1598 ),
1599 ));
1600 draft = complete_agent(
1601 research_provider,
1602 research_model,
1603 synthesizer_history.clone(),
1604 &usage,
1605 tx,
1606 ids,
1607 "synthesizer r2",
1608 )
1609 .await?;
1610 send_stage(tx, ids, "synthesizer r2", "done — revised draft assembled");
1611 send_stage(
1612 tx,
1613 ids,
1614 "critic r2",
1615 "working — reviewing the revised draft",
1616 );
1617 critique = parse_critique(
1618 &complete_agent(
1619 research_provider,
1620 research_model,
1621 critic_messages(topic, &draft),
1622 &usage,
1623 tx,
1624 ids,
1625 "critic r2",
1626 )
1627 .await?,
1628 );
1629 let detail = match &critique {
1630 Critique::Satisfied => "done — revised draft is complete".to_string(),
1631 Critique::Gaps(gaps) => {
1633 let list = gaps
1634 .iter()
1635 .enumerate()
1636 .map(|(i, g)| format!("{}. {g}", i + 1))
1637 .collect::<Vec<_>>()
1638 .join("\n");
1639 format!("done — {} gaps remain:\n{list}", gaps.len())
1640 }
1641 Critique::Contradiction(_) => "done — contradiction remains".to_string(),
1642 };
1643 send_stage(tx, ids, "critic r2", detail);
1644 }
1645
1646 if let Critique::Contradiction(desc) = &critique {
1647 send_stage(
1648 tx,
1649 ids,
1650 "resolver",
1651 "working — reconciling conflicting source claims",
1652 );
1653 let resolution = complete_agent(
1654 research_provider,
1655 research_model,
1656 resolver_messages(topic, &draft, &findings, desc),
1657 &usage,
1658 tx,
1659 ids,
1660 "resolver",
1661 )
1662 .await?;
1663 draft.push_str("\n\n");
1664 draft.push_str(&resolution);
1665 send_stage(tx, ids, "resolver", "done — contradiction reconciled");
1666 }
1667
1668 send_stage(
1669 tx,
1670 ids,
1671 "verifier",
1672 "working — checking claims, citations, and direct quotes",
1673 );
1674 let verify_toolbox = Arc::new(
1675 ToolBox::research(
1676 None,
1677 None,
1678 "auto".to_string(),
1679 Vec::new(),
1680 Some(db_path.to_path_buf()),
1681 )
1682 .cache_only(),
1683 );
1684 let verified_raw = verify_with_quote_check(
1685 research_provider,
1686 research_model,
1687 verifier_messages(topic, &draft, &findings),
1688 verify_toolbox,
1689 &usage,
1690 tx,
1691 ids,
1692 )
1693 .await;
1694 let verified = if verified_raw.trim().is_empty() {
1695 draft.clone()
1696 } else {
1697 verified_raw
1698 };
1699
1700 send_stage(
1701 tx,
1702 ids,
1703 "writer",
1704 "working — polishing structure and citations",
1705 );
1706 match complete_text(
1707 research_provider,
1708 research_model,
1709 writer_messages(topic, &verified, &pinned),
1710 &usage,
1711 )
1712 .await
1713 {
1714 Ok(report) => {
1715 send_stage(tx, ids, "writer", "done — final report ready");
1716 Ok(report)
1717 }
1718 Err(e) => {
1719 send_stage(tx, ids, "writer", format!("error — {e}"));
1720 Err(e)
1721 }
1722 }
1723}
1724
1725fn persist_session_sources(db_path: &std::path::Path, session_id: &str, findings: &[String]) {
1729 let url_norms = crate::tools::cited_url_norms(findings);
1730 if url_norms.is_empty() {
1731 return;
1732 }
1733 if let Ok(conn) = rusqlite::Connection::open(db_path) {
1734 let _ = crate::db::add_session_sources(&conn, session_id, &url_norms);
1735 }
1736}
1737
1738impl super::App {
1739 pub fn start_research(&mut self, topic: &str) {
1743 self.start_research_with_gate(topic, true);
1744 }
1745
1746 pub fn steer_research(&mut self, text: &str) {
1750 if text.is_empty() {
1751 self.push_status("usage: /steer <what to also look into>".to_string());
1752 return;
1753 }
1754 match &self.research_steer_tx {
1755 Some(_) if self.research_steer_log.len() >= MAX_QUEUED_STEERS => {
1758 self.push_status(format!(
1759 "steer queue full ({MAX_QUEUED_STEERS} pending) — wait for the next round"
1760 ));
1761 }
1762 Some(tx) if tx.send(text.to_string()).is_ok() => {
1763 let pos = self
1771 .research_steer_acked
1772 .iter()
1773 .chain(self.research_steer_log.iter().map(|(p, _)| p))
1774 .max()
1775 .map_or(0, |&p| p)
1776 + 1;
1777 self.research_steer_log.push((pos, text.to_string()));
1778 self.research_steer_log
1779 .retain(|(p, _)| !self.research_steer_acked.contains(p));
1780 self.push_status(format!("queued steer: {text}"));
1781 }
1782 _ => self.push_status("no research job is running".to_string()),
1783 }
1784 }
1785
1786 pub fn start_research_from_chat(&mut self) {
1792 if self.research_topic_rx.is_some() {
1793 self.push_status("already scoping a topic from this chat…".to_string());
1794 return;
1795 }
1796 if self.research_rx.is_some() {
1797 self.push_status("a research job is already running".to_string());
1798 return;
1799 }
1800 let Some(model) = self.current_model.clone() else {
1801 self.push_status("no model configured — set one in /login or /model".to_string());
1802 return;
1803 };
1804 let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
1805 self.push_status(format!(
1806 "model backend unavailable: {model} — pick another with /model"
1807 ));
1808 return;
1809 };
1810 let convo: String = self
1811 .messages
1812 .iter()
1813 .filter(|m| m.role == "user" || m.role == "assistant")
1814 .rev()
1815 .take(20)
1816 .collect::<Vec<_>>()
1817 .into_iter()
1818 .rev()
1819 .map(|m| {
1820 format!(
1821 "{}: {}",
1822 m.role,
1823 m.content.chars().take(500).collect::<String>()
1824 )
1825 })
1826 .collect::<Vec<_>>()
1827 .join("\n");
1828 if convo.trim().is_empty() {
1829 self.push_status(
1830 "nothing to scope yet — chat first, or use /research <topic>".to_string(),
1831 );
1832 return;
1833 }
1834 let (tx, rx) = mpsc::unbounded_channel();
1835 self.research_topic_rx = Some(rx);
1836 self.push_status("scoping a research topic from this chat…".to_string());
1837 tokio::spawn(async move {
1838 let prompt = format!(
1839 "Based on this conversation, reply with ONLY a single-line research topic \
1840 or question suitable for a multi-source research task. No preamble, no \
1841 quotes, no markdown.\n\n{convo}"
1842 );
1843 let msgs = vec![ChatMessage::text("user", prompt)];
1844 let result = provider
1845 .complete(&raw_model, msgs)
1846 .await
1847 .map(|s| s.trim().to_string())
1848 .map_err(|e| e.to_string());
1849 let _ = tx.send(result);
1850 });
1851 }
1852
1853 pub fn on_research_topic_derived(&mut self, r: Option<Result<String, String>>) {
1856 self.research_topic_rx = None;
1857 let Some(result) = r else { return };
1858 match result {
1859 Ok(topic) if !topic.is_empty() => self.start_research_with_gate(&topic, true),
1860 Ok(_) => {
1861 self.push_status("couldn't derive a topic — try /research <topic>".to_string());
1862 }
1863 Err(e) => self.push_status(format!("topic scoping failed: {e}")),
1864 }
1865 }
1866
1867 #[allow(clippy::too_many_lines)]
1869 pub fn start_research_with_gate(&mut self, topic: &str, gated: bool) {
1870 let topic = topic.trim().to_string();
1871 if topic.is_empty() {
1872 self.push_status("usage: /research <topic>".to_string());
1873 return;
1874 }
1875 if self.research_rx.is_some() {
1876 self.push_status("a research job is already running".to_string());
1877 return;
1878 }
1879 let Some(model) = self.current_model.clone() else {
1882 self.push_status("no model configured — set one in /login or /model".to_string());
1883 return;
1884 };
1885 let Some((provider, raw_research_model)) = self.resolve_model_backend(&model) else {
1886 self.push_status(format!(
1887 "model backend unavailable: {model} — pick another with /model"
1888 ));
1889 return;
1890 };
1891 let title = super::chat::title_from(&topic);
1892 self.set_survey_gate(None);
1894 self.survey_reply_tx = None;
1895
1896 let parent_id = self.session.as_ref().and_then(|s| {
1898 let has_content = self
1899 .messages
1900 .iter()
1901 .any(|m| m.role == "user" || m.role == "assistant");
1902 if has_content {
1903 Some(s.id.clone())
1904 } else {
1905 None
1906 }
1907 });
1908 let parent_title = parent_id
1909 .as_ref()
1910 .and_then(|pid| self.db.get_session(pid).ok()?.map(|s| s.title));
1911
1912 let session =
1913 match self
1914 .db
1915 .create_session(&title, &model, &self.active_space.id, "research")
1916 {
1917 Ok(s) => s,
1918 Err(e) => {
1919 self.push_status(format!("could not start research session: {e}"));
1920 return;
1921 }
1922 };
1923
1924 if let Some(ref pid) = parent_id {
1925 let _ = self.db.set_research_parent(&session.id, pid);
1926
1927 let compact_summary = self
1929 .session
1930 .as_ref()
1931 .and_then(|s| s.compact_summary.clone());
1932 let compact_through = self
1933 .session
1934 .as_ref()
1935 .map_or(0, |s| s.compact_through as usize);
1936 let mut ctx = String::new();
1937 if let Some(ref summary) = compact_summary {
1938 ctx.push_str("Previous conversation summary:\n");
1939 ctx.push_str(summary);
1940 }
1941 let tail: Vec<&crate::db::Message> = self.messages[compact_through..]
1942 .iter()
1943 .filter(|m| m.role == "user" || m.role == "assistant")
1944 .collect();
1945 if !tail.is_empty() {
1946 if !ctx.is_empty() {
1947 ctx.push_str("\n\n");
1948 }
1949 ctx.push_str("Recent messages:\n");
1950 for m in tail {
1951 let t = m.content.chars().take(300).collect::<String>();
1952 let _ = writeln!(ctx, "{}: {t}", m.role);
1953 }
1954 }
1955 let msg = if ctx.is_empty() {
1956 format!("/research {topic}")
1957 } else {
1958 format!("/research {topic}\n\n{ctx}")
1959 };
1960 let _ = self.db.add_user_message(&session.id, &msg);
1961
1962 let _ = self.db.insert_message(
1964 pid,
1965 "session_link",
1966 &format!("{}\n🔗 Research session started for: {topic}", session.id),
1967 None,
1968 None,
1969 None,
1970 None,
1971 None,
1972 None,
1973 );
1974
1975 let back_title = parent_title.as_deref().unwrap_or("previous chat");
1977 let _ = self.db.insert_message(
1978 &session.id,
1979 "session_link",
1980 &format!("{pid}\n↩ Originally from: {back_title}"),
1981 None,
1982 None,
1983 None,
1984 None,
1985 None,
1986 None,
1987 );
1988
1989 self.messages = self.db.load_messages(&session.id).unwrap_or_default();
1990 } else {
1991 let _ = self
1992 .db
1993 .add_user_message(&session.id, &format!("/research {topic}"));
1994 self.messages = self.db.load_messages(&session.id).unwrap_or_default();
1995 }
1996
1997 let searxng_url =
1998 (!self.searxng_url.trim().is_empty()).then(|| self.searxng_url.trim().to_string());
1999 let langsearch_key = (!self.langsearch_key.trim().is_empty())
2000 .then(|| self.langsearch_key.trim().to_string());
2001 let toolbox = Arc::new(ToolBox::research(
2002 searxng_url,
2003 langsearch_key,
2004 self.search_provider.clone(),
2005 self.blocked_domains(),
2006 Some(self.space.db_path()),
2007 ));
2008
2009 let (tx, rx) = mpsc::unbounded_channel();
2010 self.research_rx = Some(rx);
2011 self.research_running = Some((session.id.clone(), topic.clone()));
2012 self.push_status(format!("researching: {topic} · Ctrl+↑ agents"));
2013
2014 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
2015 self.research_steer_tx = Some(steer_tx);
2016 self.research_steer_log.clear();
2017 self.research_steer_acked.clear();
2018 self.research_stage_rows.clear();
2019 self.research_incognito = self.incognito;
2022
2023 let reply_rx = if gated {
2028 let (reply_tx, reply_rx) = mpsc::unbounded_channel();
2029 self.survey_reply_tx = Some(reply_tx);
2030 Some(reply_rx)
2031 } else {
2032 None
2033 };
2034
2035 let space_id = self.active_space.id.clone();
2036 let space_name = self.active_space.name.clone();
2037 self.session = Some(session.clone());
2038 self.refresh_memory_snapshot();
2039 let prompt_cache_key = format!("research:{}", self.prompt_cache_key_for(&session.id));
2040 self.context_total = None;
2041 self.push_viewport_reset();
2044 self.refresh_toolbox();
2045
2046 let embedding_model = self.embedding_model.trim().to_string();
2047 let (embedding_provider, raw_embedding_model) = self
2048 .resolve_model_backend(&embedding_model)
2049 .unwrap_or_else(|| (provider.clone(), embedding_model.clone()));
2050
2051 let task = tokio::spawn(run_research(crate::app::research::ResearchOptions {
2052 research_provider: provider,
2053 research_model: raw_research_model,
2054 embedding_provider,
2055 embedding_model: raw_embedding_model,
2056 db_path: self.space.db_path(),
2057 topic,
2058 reply_rx,
2059 steer_rx,
2060 toolbox,
2061 tx,
2062 session_id: session.id,
2063 prompt_cache_key,
2064 space_id,
2065 space_name,
2066 }));
2067 self.research_abort = Some(task.abort_handle());
2068 }
2069
2070 pub fn stop_research(&mut self) {
2073 self.set_survey_gate(None);
2074 if let Some(abort) = self.research_abort.take() {
2075 abort.abort();
2076 }
2077 if self.research_rx.take().is_some() {
2078 if let Some((session_id, _)) = self.research_running.take() {
2079 let _ = self.db.upsert_research_stage_message(
2080 &session_id,
2081 "research",
2082 "stopped by user",
2083 );
2084 }
2085 self.set_survey_gate(None);
2086 self.survey_reply_tx = None;
2087 self.research_steer_tx = None;
2088 self.research_steer_log.clear();
2091 self.research_steer_acked.clear();
2092 self.push_status("research stopped".to_string());
2093 } else {
2094 self.push_status("no research job is running".to_string());
2095 }
2096 }
2097
2098 pub fn survey_gate_targets_current_session(&self) -> bool {
2104 self.survey_gate
2105 .as_ref()
2106 .is_some_and(|g| self.session.as_ref().is_some_and(|s| s.id == g.session_id))
2107 }
2108
2109 pub fn restore_survey_gate_prompt(&mut self) {
2113 let pending = self.survey_gate.as_ref().and_then(|gate| {
2114 self.session
2115 .as_ref()
2116 .filter(|session| session.id == gate.session_id)
2117 .map(|_| (gate.prompt_role.clone(), gate.prompt_content.clone()))
2118 });
2119 let Some((role, content)) = pending else {
2120 return;
2121 };
2122 if self
2123 .messages
2124 .iter()
2125 .any(|message| message.role == role && message.content == content)
2126 {
2127 return;
2128 }
2129 self.messages.push(crate::db::Message {
2130 role,
2131 content,
2132 model: None,
2133 reasoning: None,
2134 tokens: None,
2135 secs: None,
2136 cost: None,
2137 phrase: None,
2138 persona: None,
2139 created_at: None,
2140 });
2141 }
2142
2143 pub fn set_survey_gate(&mut self, gate: Option<SurveyGate>) {
2154 let state = gate.as_ref().map(|g| super::GateState {
2155 session_id: g.session_id.clone(),
2156 phase: g.phase.clone(),
2157 });
2158 self.survey_gate = gate;
2159 self.pending_events.push_back(super::AppEvent::Gate(state));
2160 }
2161
2162 pub fn reply_to_survey_gate(&mut self, text: &str) {
2163 let Some(gate) = self.survey_gate.take() else {
2164 return;
2165 };
2166 self.pending_events.push_back(super::AppEvent::Gate(None));
2167 let saved_id = if !text.trim().is_empty() && !self.research_incognito {
2173 match self.db.add_gate_reply_message(&gate.session_id, text) {
2174 Ok(id) => Some(id),
2175 Err(e) => {
2176 self.set_survey_gate(Some(gate));
2177 self.push_composer_set(text);
2178 self.push_status(format!("couldn't save your reply — {e}"));
2179 return;
2180 }
2181 }
2182 } else {
2183 None
2184 };
2185 if gate.reply_tx.send(text.to_string()).is_err() {
2186 let rollback_error = saved_id.and_then(|id| self.db.delete_message(&id).err());
2190 self.push_composer_set(text);
2191 self.push_status(match rollback_error {
2192 Some(e) => format!(
2193 "the job stopped waiting and the saved reply could not be rolled back: {e} — text restored to the composer"
2194 ),
2195 None => "the job is no longer waiting for a reply — text restored to the composer"
2196 .to_string(),
2197 });
2198 return;
2199 }
2200 if !text.trim().is_empty()
2201 && self
2202 .session
2203 .as_ref()
2204 .is_some_and(|s| s.id == gate.session_id)
2205 {
2206 self.messages.push(crate::db::Message {
2207 role: "gate_reply".to_string(),
2208 content: text.to_string(),
2209 model: None,
2210 reasoning: None,
2211 tokens: None,
2212 secs: None,
2213 cost: None,
2214 phrase: None,
2215 persona: None,
2216 created_at: None,
2217 });
2218 }
2219 match gate.phase {
2220 SurveyPhase::Clarify { round } => {
2221 self.push_status(format!(
2222 "answer noted (round {round}) — checking for follow-ups… · Ctrl+↑ agents"
2223 ));
2224 }
2225 SurveyPhase::Approve { rework } => self.push_status(if rework {
2226 "revision folded in — continuing… · Ctrl+↑ agents".to_string()
2227 } else {
2228 "plan reply sent — continuing… · Ctrl+↑ agents".to_string()
2229 }),
2230 }
2231 }
2232
2233 fn mirror_stage(&mut self, session_id: &str, label: &str, detail: &str) {
2241 let _ = self
2242 .db
2243 .upsert_research_stage_message(session_id, label, detail);
2244 let text = crate::db::stage_content(label, detail);
2245 let prefix = format!("{label}:");
2246 if let Some(row) = self
2248 .research_stage_rows
2249 .iter_mut()
2250 .rev()
2251 .find(|c| c.as_str() == label || c.starts_with(prefix.as_str()))
2252 {
2253 row.clone_from(&text);
2254 } else {
2255 self.research_stage_rows.push(text.clone());
2256 }
2257 if self.session.as_ref().is_some_and(|s| s.id == session_id) {
2258 if let Some(row) = self.messages.iter_mut().rev().find(|m| {
2259 m.role == "research_stage" && (m.content == label || m.content.starts_with(&prefix))
2260 }) {
2261 row.content = text;
2262 self.push_history_invalidated();
2266 } else {
2267 self.messages.push(crate::db::Message {
2268 role: "research_stage".to_string(),
2269 content: text,
2270 model: None,
2271 reasoning: None,
2272 tokens: None,
2273 secs: None,
2274 cost: None,
2275 phrase: None,
2276 persona: None,
2277 created_at: None,
2278 });
2279 }
2280 }
2281 }
2282
2283 #[allow(clippy::too_many_lines)]
2287 pub fn on_research_done(&mut self, r: Option<ResearchMsg>) {
2288 let Some((session_id, space_id, space_name, update)) = r else {
2289 self.research_rx = None;
2290 self.research_abort = None;
2291 self.research_running = None;
2292 self.set_survey_gate(None);
2293 self.survey_reply_tx = None;
2294 self.research_steer_tx = None;
2295 self.research_steer_log.clear();
2298 self.research_steer_acked.clear();
2299 self.research_stage_rows.clear();
2300 self.research_incognito = false;
2301 self.research_live_input.clear();
2302 return;
2304 };
2305 let viewing = self.session.as_ref().is_some_and(|s| s.id == session_id);
2306 match update {
2307 ResearchUpdate::Stage { label, detail } => {
2308 if let Some(n) = label.strip_prefix("steer #").and_then(|n| n.parse().ok()) {
2313 self.research_steer_acked.insert(n);
2314 self.research_steer_log
2315 .retain(|(p, _)| !self.research_steer_acked.contains(p));
2316 }
2317 self.mirror_stage(&session_id, &label, &detail);
2318 if viewing {
2319 self.push_status(format!(
2320 "research: {} · Ctrl+↑ agents",
2321 crate::db::stage_content(&label, &detail)
2322 ));
2323 }
2324 }
2325 ResearchUpdate::SurveyReady { questions, round } => {
2326 let topic = self
2327 .research_running
2328 .as_ref()
2329 .map(|(_, t)| t.clone())
2330 .unwrap_or_default();
2331 let header = if round <= 1 {
2332 format!("For \"{topic}\":")
2333 } else {
2334 format!("Follow-up (round {round} of {MAX_SURVEY_ROUNDS}) for \"{topic}\":")
2335 };
2336 let qs = questions
2337 .iter()
2338 .enumerate()
2339 .map(|(i, q)| format!(" {}. {q}", i + 1))
2340 .collect::<Vec<_>>()
2341 .join("\n");
2342 let content = format!(
2343 "{header}\n{qs}\n\nAnswer in chat — I may ask follow-ups (up to {MAX_SURVEY_ROUNDS} rounds), \
2344 then say \"I approve\". (Enter on an empty input skips ahead.)"
2345 );
2346
2347 if !self.research_incognito
2351 && let Err(e) = self.db.add_survey_message(&session_id, &content)
2352 {
2353 self.stop_research();
2354 self.push_status(format!(
2355 "couldn't persist the survey — research stopped: {e}"
2356 ));
2357 return;
2358 }
2359 let Some(tx) = self.survey_reply_tx.clone() else {
2360 self.stop_research();
2361 self.push_status(
2362 "survey reply channel unavailable — research stopped".to_string(),
2363 );
2364 return;
2365 };
2366 self.set_survey_gate(Some(SurveyGate {
2367 session_id: session_id.clone(),
2368 reply_tx: tx,
2369 phase: SurveyPhase::Clarify { round },
2370 prompt_role: "survey".to_string(),
2371 prompt_content: content.clone(),
2372 }));
2373
2374 if viewing {
2375 self.messages.push(crate::db::Message {
2376 role: "survey".to_string(),
2377 content,
2378 model: None,
2379 reasoning: None,
2380 tokens: None,
2381 secs: None,
2382 cost: None,
2383 phrase: None,
2384 persona: None,
2385 created_at: None,
2386 });
2387 self.push_status(format!(
2388 "survey round {round} — answer in chat · Ctrl+↑ agents"
2389 ));
2390 } else {
2391 self.unread.insert(session_id.clone());
2395 self.push_status(format!(
2396 "research is waiting on you — survey round {round} for \"{topic}\": \
2397 open that session and answer in chat"
2398 ));
2399 }
2400 }
2401 ResearchUpdate::PlanReady { questions, rework } => {
2402 let topic = self
2403 .research_running
2404 .as_ref()
2405 .map(|(_, t)| t.clone())
2406 .unwrap_or_default();
2407 let plan = plan_text(&questions);
2408 let heading = if rework {
2409 "Research plan (revised with your feedback) — reply \"approve\" to continue:"
2410 } else {
2411 "Research plan — reply to approve, or tell me what to change (\"drop Q2\", \"also look into X\"):"
2412 };
2413 let content = format!("{heading}\n{plan}");
2414
2415 if !self.research_incognito
2418 && let Err(e) = self.db.add_research_plan_message(&session_id, &content)
2419 {
2420 self.stop_research();
2421 self.push_status(format!("couldn't persist the plan — research stopped: {e}"));
2422 return;
2423 }
2424 let Some(tx) = self.survey_reply_tx.clone() else {
2425 self.stop_research();
2426 self.push_status(
2427 "plan approval channel unavailable — research stopped".to_string(),
2428 );
2429 return;
2430 };
2431 self.set_survey_gate(Some(SurveyGate {
2432 session_id: session_id.clone(),
2433 reply_tx: tx,
2434 phase: SurveyPhase::Approve { rework },
2435 prompt_role: "research_plan".to_string(),
2436 prompt_content: content.clone(),
2437 }));
2438
2439 if let Err(e) = self.save_space_artifact(
2446 &space_id,
2447 &space_name,
2448 &topic,
2449 "plan",
2450 &format!("# Research plan: {topic}\n\n{plan}\n"),
2451 ) {
2452 self.mirror_stage(
2453 &session_id,
2454 "plan record",
2455 &format!("error — could not save plan record: {e}"),
2456 );
2457 }
2458 if viewing {
2459 self.messages.push(crate::db::Message {
2460 role: "research_plan".to_string(),
2461 content,
2462 model: None,
2463 reasoning: None,
2464 tokens: None,
2465 secs: None,
2466 cost: None,
2467 phrase: None,
2468 persona: None,
2469 created_at: None,
2470 });
2471 self.push_status(if rework {
2472 "revised plan ready — reply to approve".to_string()
2473 } else {
2474 "research plan ready — reply to approve or change · Ctrl+↑ agents"
2475 .to_string()
2476 });
2477 } else {
2478 self.unread.insert(session_id.clone());
2482 self.push_status(format!(
2483 "research is waiting on you — plan approval for \"{topic}\": \
2484 open that session and reply \"approve\""
2485 ));
2486 }
2487 }
2488 ResearchUpdate::Done(Ok(report)) => {
2489 let report = if let Ok(Some(prev_citations)) =
2492 self.previous_citations_for_watch_session(&session_id, &space_id)
2493 {
2494 let new_sources =
2495 crate::app::watches::new_sources_since(&report, &prev_citations);
2496 format!(
2497 "{}\n\n{}",
2498 crate::app::watches::diff_section("", &report, &new_sources),
2499 report
2500 )
2501 } else {
2502 report
2503 };
2504 let _ = self.db.add_assistant_message(
2505 &session_id,
2506 &report,
2507 None,
2508 None,
2509 None,
2510 None,
2511 None,
2512 None,
2513 );
2514 let topic = self
2515 .research_running
2516 .as_ref()
2517 .map(|(_, t)| t.clone())
2518 .unwrap_or_default();
2519 if let Err(e) = self.save_research_report(&space_id, &space_name, &topic, &report) {
2523 self.mirror_stage(
2524 &session_id,
2525 "report file",
2526 &format!("error — could not save report file: {e}"),
2527 );
2528 }
2529 if viewing {
2530 self.messages.push(crate::db::Message {
2531 role: "assistant".to_string(),
2532 content: report,
2533 model: None,
2534 reasoning: None,
2535 tokens: None,
2536 secs: None,
2537 cost: None,
2538 phrase: Some("Researched".to_string()),
2539 persona: None,
2540 created_at: None,
2541 });
2542 self.push_status("research complete".to_string());
2543 } else {
2544 self.unread.insert(session_id);
2545 if let Some((_, topic)) = &self.research_running {
2546 self.push_status(format!("✓ research ready: {topic}"));
2547 }
2548 }
2549 }
2550 ResearchUpdate::Done(Err(e)) => {
2551 let msg = format!("research failed: {e}");
2552 let _ = self.db.add_assistant_message(
2553 &session_id,
2554 &msg,
2555 None,
2556 None,
2557 None,
2558 None,
2559 None,
2560 None,
2561 );
2562 if viewing {
2563 self.messages.push(crate::db::Message {
2564 role: "assistant".to_string(),
2565 content: msg.clone(),
2566 model: None,
2567 reasoning: None,
2568 tokens: None,
2569 secs: None,
2570 cost: None,
2571 phrase: None,
2572 persona: None,
2573 created_at: None,
2574 });
2575 }
2576 self.push_status(msg);
2577 }
2578 }
2579 }
2580
2581 fn save_space_artifact(
2593 &mut self,
2594 space_id: &str,
2595 space_name: &str,
2596 topic: &str,
2597 prefix: &str,
2598 body: &str,
2599 ) -> std::io::Result<Option<std::path::PathBuf>> {
2600 if self.research_incognito {
2605 return Ok(None);
2606 }
2607 let dir = self.space.files_dir(space_name);
2608 std::fs::create_dir_all(&dir)?;
2609 let slug = super::sessions::slugify(topic);
2610 let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
2611 let name = format!("{prefix}-{slug}-{stamp}.md");
2612 let path = dir.join(&name);
2613 std::fs::write(&path, body)?;
2614 if space_id == self.active_space.id {
2615 self.rescan_files();
2616 }
2617 Ok(Some(path))
2618 }
2619
2620 fn save_research_report(
2626 &mut self,
2627 space_id: &str,
2628 space_name: &str,
2629 topic: &str,
2630 report: &str,
2631 ) -> std::io::Result<Option<std::path::PathBuf>> {
2632 let saved = self.save_space_artifact(space_id, space_name, topic, "research", report)?;
2633 if let Some(path) = &saved {
2634 let citations = crate::citations::parse_citations(report);
2636 if !citations.is_empty() {
2637 let rows: Vec<(String, Option<String>)> =
2639 citations.into_iter().map(|(_, url)| (url, None)).collect();
2640 let name = path
2641 .file_name()
2642 .and_then(|n| n.to_str())
2643 .unwrap_or_default()
2644 .to_string();
2645 let _ = self.db.add_citations(space_id, &name, &rows);
2646 }
2647 }
2648 Ok(saved)
2649 }
2650}
2651
2652#[cfg(test)]
2653mod tests {
2654 use super::*;
2655 use crate::app::App;
2656 use crate::db::Db;
2657 use crate::space::Space;
2658
2659 fn test_app() -> App {
2660 let db = Db::open_in_memory().unwrap();
2661 let root =
2662 std::env::temp_dir().join(format!("nexus-research-test-{}", uuid::Uuid::new_v4()));
2663 std::fs::create_dir_all(root.join("spaces")).unwrap();
2664 let space = Space { root };
2665 let mut a = App::new(db, Some("k"), space);
2666 a.current_model = Some("openai/gpt-5-mini".to_string());
2668 a
2669 }
2670
2671 #[tokio::test]
2672 async fn drain_steers_collects_all_queued_without_blocking() {
2673 let (tx, mut rx) = mpsc::unbounded_channel();
2674 tx.send("look into X".to_string()).unwrap();
2675 tx.send("also Y".to_string()).unwrap();
2676 let drained = drain_steers(&mut rx);
2677 assert_eq!(
2678 drained,
2679 vec!["look into X".to_string(), "also Y".to_string()]
2680 );
2681 let empty = drain_steers(&mut rx);
2683 assert!(empty.is_empty());
2684 }
2685
2686 #[test]
2687 fn planner_messages_with_context_includes_known_chunks_as_gap_guidance() {
2688 let msgs = planner_messages_with_context(
2689 "rust async runtimes",
2690 &[],
2691 &["Rust's async model uses a Future trait.".to_string()],
2692 );
2693 assert_eq!(msgs[0].role, "system");
2694 assert!(msgs[1].content.contains("rust async runtimes"));
2695 assert!(msgs[1].content.contains("Already known"));
2696 assert!(msgs[1].content.contains("Future trait"));
2697 }
2698
2699 #[test]
2700 fn planner_messages_with_context_falls_back_to_plain_prompt_when_empty() {
2701 let msgs = planner_messages_with_context("topic", &[], &[]);
2702 assert!(!msgs[1].content.contains("Already known"));
2703 assert_eq!(msgs[1].content, "topic");
2704 }
2705
2706 #[test]
2707 fn planner_messages_with_context_folds_user_answers_into_the_prompt() {
2708 let msgs = planner_messages_with_context(
2709 "topic",
2710 &[
2711 ("q1".to_string(), "depth first".to_string()),
2712 ("q2".to_string(), "current state only".to_string()),
2713 ],
2714 &[],
2715 );
2716 let user = &msgs[1].content;
2717 assert!(user.contains("answered clarifying questions"));
2718 assert!(user.contains("depth first"));
2719 assert!(user.contains("current state only"));
2720 assert!(user.contains("topic"));
2721 }
2722
2723 #[test]
2724 fn verifier_prompt_mentions_quote_checking() {
2725 assert!(VERIFIER_PROMPT.to_lowercase().contains("quote"));
2726 }
2727
2728 #[test]
2729 fn parse_plan_blocks_reads_json_objects_with_all_fields() {
2730 let qs = parse_plan_blocks(
2731 r#"[{"question":"what is X","why":"definitions matter","angles":["a1","a2"],"sources":["s1","s2"]}]"#,
2732 );
2733 assert_eq!(qs.len(), 1);
2734 assert_eq!(qs[0].question, "what is X");
2735 assert_eq!(qs[0].why, "definitions matter");
2736 assert_eq!(qs[0].angles, vec!["a1".to_string(), "a2".to_string()]);
2737 assert_eq!(qs[0].sources, vec!["s1".to_string(), "s2".to_string()]);
2738 }
2739
2740 #[test]
2741 fn parse_plan_blocks_defaults_missing_fields_and_strips_fences() {
2742 let qs = parse_plan_blocks("```json\n[{\"question\": \"what is X\"}]\n```");
2743 assert_eq!(qs.len(), 1);
2744 assert_eq!(qs[0].question, "what is X");
2745 assert!(qs[0].why.is_empty());
2746 assert!(qs[0].angles.is_empty());
2747 assert!(qs[0].sources.is_empty());
2748 }
2749
2750 #[test]
2751 fn parse_plan_blocks_falls_back_to_bare_questions_on_non_json() {
2752 let qs = parse_plan_blocks("- what is X\n2. how does Y work");
2753 assert_eq!(qs.len(), 2);
2754 assert_eq!(qs[0].question, "what is X");
2755 assert!(qs[0].why.is_empty());
2756 assert_eq!(qs[1].question, "how does Y work");
2757 }
2758
2759 #[test]
2760 fn parse_plan_blocks_filters_empty_questions_and_caps_at_max() {
2761 let qs = parse_plan_blocks(
2762 r#"[{"question":""},{"question":"q1"},{"question":"q2"},{"question":"q3"}]"#,
2763 );
2764 assert_eq!(qs.len(), 3);
2765 assert_eq!(qs[0].question, "q1");
2766 let lines: Vec<String> = (0..10).map(|i| format!("q{i}")).collect();
2767 assert_eq!(parse_plan_blocks(&lines.join("\n")).len(), MAX_SUBQUESTIONS);
2768 }
2769
2770 #[test]
2771 fn parse_plan_blocks_rejects_malformed_json_without_line_fallback() {
2772 assert!(parse_plan_blocks("[{}]").is_empty(), "[{{}}] must fail");
2777 assert!(
2778 parse_plan_blocks(r#"[{"question":""}]"#).is_empty(),
2779 "empty questions must fail"
2780 );
2781 assert!(
2782 parse_plan_blocks(r#"[{"question": 5}]"#).is_empty(),
2783 "wrong field types must fail"
2784 );
2785 assert!(
2786 parse_plan_blocks(r#"{"question":"q1"}"#).is_empty(),
2787 "a bare object is not the required array and must fail"
2788 );
2789 assert_eq!(parse_plan_blocks("- what is X").len(), 1);
2791 let prose = parse_plan_blocks("Here is the plan:\n[{\"question\":\"q1\"}]");
2793 assert_eq!(prose.len(), 1, "prose-prefixed JSON must parse as JSON");
2794 assert_eq!(prose[0].question, "q1");
2795 let legacy = parse_plan_blocks(r#"["what is X", "how does Y work"]"#);
2797 assert_eq!(legacy.len(), 2);
2798 assert_eq!(legacy[0].question, "what is X");
2799 }
2800
2801 #[test]
2802 fn parse_survey_reply_recognizes_complete_markers() {
2803 assert_eq!(parse_survey_reply("COMPLETE"), SurveyReply::Complete);
2804 assert_eq!(parse_survey_reply(" complete "), SurveyReply::Complete);
2805 assert_eq!(
2806 parse_survey_reply("COMPLETE: I have enough"),
2807 SurveyReply::Complete
2808 );
2809 assert_eq!(
2810 parse_survey_reply("COMPLETE — proceed"),
2811 SurveyReply::Complete
2812 );
2813 assert_eq!(parse_survey_reply("COMPLETE."), SurveyReply::Complete);
2815 assert_eq!(parse_survey_reply("COMPLETE!"), SurveyReply::Complete);
2816 }
2817
2818 #[test]
2819 fn parse_survey_reply_reads_numbered_questions() {
2820 assert_eq!(
2821 parse_survey_reply("1. Depth or breadth?\n2. History too?"),
2822 SurveyReply::Questions(vec![
2823 "Depth or breadth?".to_string(),
2824 "History too?".to_string()
2825 ])
2826 );
2827 assert_eq!(
2828 parse_survey_reply("- just one angle"),
2829 SurveyReply::Questions(vec!["just one angle".to_string()])
2830 );
2831 }
2832
2833 #[test]
2834 fn parse_survey_reply_marks_output_contract_violations_and_caps_questions() {
2835 assert_eq!(parse_survey_reply(""), SurveyReply::Malformed);
2839 assert_eq!(parse_survey_reply("\n\n"), SurveyReply::Malformed);
2840 assert_eq!(
2841 parse_survey_reply("I couldn't understand your last answer, please retry"),
2842 SurveyReply::Malformed
2843 );
2844 assert_eq!(
2845 parse_survey_reply("The model encountered an error processing the request."),
2846 SurveyReply::Malformed
2847 );
2848 assert_eq!(
2849 parse_survey_reply("No further questions are needed"),
2850 SurveyReply::Malformed
2851 );
2852 assert_eq!(
2854 parse_survey_reply("Depth or breadth?"),
2855 SurveyReply::Questions(vec!["Depth or breadth?".to_string()])
2856 );
2857 let mixed = "1. Depth or breadth?\nPlease be specific.\n2. History too?";
2859 assert_eq!(
2860 parse_survey_reply(mixed),
2861 SurveyReply::Questions(vec![
2862 "Depth or breadth?".to_string(),
2863 "History too?".to_string()
2864 ])
2865 );
2866 let lines: Vec<String> = (0..8).map(|i| format!("{}. q{i}?", i + 1)).collect();
2867 match parse_survey_reply(&lines.join("\n")) {
2868 SurveyReply::Questions(qs) => assert_eq!(qs.len(), MAX_SURVEY_QUESTIONS),
2869 _ => panic!("expected questions"),
2870 }
2871 }
2872
2873 #[test]
2874 fn parse_approval_recognizes_approved_and_revised_plans() {
2875 assert_eq!(parse_approval("APPROVED"), Approval::Approved);
2876 assert_eq!(parse_approval(" approved "), Approval::Approved);
2877 assert_eq!(parse_approval("APPROVED: run it"), Approval::Approved);
2878 let revised = parse_approval("[{\"question\": \"revised q\"}]");
2879 assert_eq!(
2880 revised,
2881 Approval::Revised(vec![PlanQuestion::bare("revised q".to_string())])
2882 );
2883 assert_eq!(parse_approval("huh?"), Approval::Malformed);
2886 assert_eq!(parse_approval(""), Approval::Malformed);
2887 assert_eq!(
2888 parse_approval("Here is the revised plan I prepared for you"),
2889 Approval::Malformed
2890 );
2891 assert_eq!(parse_approval("[{}]"), Approval::Malformed);
2894 assert_eq!(parse_approval("[{\"question\": 5}]"), Approval::Malformed);
2895 assert_eq!(
2897 parse_approval("Here is my revised plan:\n[{\"question\":\"q\"}]\n"),
2898 Approval::Revised(vec![PlanQuestion::bare("q".to_string())])
2899 );
2900 assert_eq!(
2902 parse_approval("- drop q2"),
2903 Approval::Revised(vec![PlanQuestion::bare("drop q2".to_string())])
2904 );
2905 }
2906
2907 #[test]
2908 fn plan_question_prompt_includes_topic_and_full_brief() {
2909 let q = PlanQuestion {
2910 question: "how does X work".to_string(),
2911 why: "mechanism matters".to_string(),
2912 angles: vec!["internals".to_string(), "benchmarks".to_string()],
2913 sources: vec!["papers".to_string()],
2914 };
2915 let p = q.prompt("rust async");
2916 assert!(p.contains("rust async"));
2917 assert!(p.contains("how does X work"));
2918 assert!(p.contains("mechanism matters"));
2919 assert!(p.contains("internals; benchmarks"));
2920 assert!(p.contains("papers"));
2921 assert!(PlanQuestion::bare("q".into()).prompt("t").contains('q'));
2923 }
2924
2925 #[test]
2926 fn plan_text_renders_numbered_questions_with_indented_briefs() {
2927 let qs = vec![
2928 PlanQuestion::bare("q1".to_string()),
2929 PlanQuestion {
2930 question: "q2".to_string(),
2931 why: "why2".to_string(),
2932 angles: vec!["a".to_string()],
2933 sources: vec!["s".to_string()],
2934 },
2935 ];
2936 let t = plan_text(&qs);
2937 assert!(t.contains("1. q1"));
2938 assert!(t.contains("2. q2"));
2939 assert!(t.contains("\n Why: why2"));
2940 assert!(t.contains("\n Angles: a"));
2941 assert!(t.contains("\n Sources: s"));
2942 }
2943
2944 #[test]
2945 fn research_stage_prompts_share_a_stable_cache_prefix() {
2946 let planner = research_stage_prompt(PLANNER_PROMPT);
2947 let writer = research_stage_prompt(WRITER_PROMPT);
2948 assert!(planner.starts_with(RESEARCH_CACHE_PREFIX));
2949 assert!(writer.starts_with(RESEARCH_CACHE_PREFIX));
2950 assert_ne!(planner, writer);
2951 }
2952
2953 #[test]
2954 fn survey_messages_include_topic_and_rounds() {
2955 let msgs = survey_messages("t", &[]);
2956 assert_eq!(msgs[0].role, "system");
2957 assert!(msgs[1].content.contains('t'));
2958 let msgs = survey_messages("t", &[("q".to_string(), "a".to_string())]);
2959 assert!(msgs[1].content.contains('q'));
2960 assert!(msgs[1].content.contains('a'));
2961 }
2962
2963 #[test]
2964 fn plan_approval_messages_include_plan_and_user_reply() {
2965 let msgs =
2966 plan_approval_messages("topic", &[PlanQuestion::bare("q1".to_string())], "drop q2");
2967 assert!(msgs[1].content.contains("topic"));
2968 assert!(msgs[1].content.contains("1. q1"));
2969 assert!(msgs[1].content.contains("drop q2"));
2970 }
2971
2972 #[tokio::test]
2973 async fn on_research_done_final_report_populates_citation_index() {
2974 let mut a = test_app();
2975 a.start_research("rust async runtimes");
2976 let session_id = a.session.as_ref().unwrap().id.clone();
2977 let space_id = a.active_space.id.clone();
2978 let space_name = a.active_space.name.clone();
2979
2980 a.on_research_done(Some((
2981 session_id,
2982 space_id.clone(),
2983 space_name,
2984 ResearchUpdate::Done(Ok(
2985 "# Report\n\nBody [1].\n\n## Sources\n1. https://example.com/a\n".to_string(),
2986 )),
2987 )));
2988
2989 let hits =
2990 a.db.search_citations(&space_id, Some("example.com"))
2991 .unwrap();
2992 assert_eq!(hits.len(), 1);
2993 assert_eq!(hits[0].1, "https://example.com/a");
2994 assert!(
2996 a.db.search_citations(&space_id, Some("nope.example"))
2997 .unwrap()
2998 .is_empty()
2999 );
3000 }
3001
3002 #[tokio::test]
3003 async fn plan_ready_arms_the_gate_and_reply_routes_into_the_pipeline() {
3004 let mut a = test_app();
3005 a.start_research("rust async runtimes");
3006 let session_id = a.session.as_ref().unwrap().id.clone();
3007 let space_id = a.active_space.id.clone();
3008 let space_name = a.active_space.name.clone();
3009
3010 let (tx, mut rx) = mpsc::unbounded_channel();
3012 a.survey_reply_tx = Some(tx);
3013 let q1 = PlanQuestion::bare("q1".to_string());
3014 let q2 = PlanQuestion::bare("q2".to_string());
3015
3016 a.on_research_done(Some((
3017 session_id.clone(),
3018 space_id,
3019 space_name,
3020 ResearchUpdate::PlanReady {
3021 questions: vec![q1.clone(), q2.clone()],
3022 rework: false,
3023 },
3024 )));
3025 assert!(a.survey_gate.is_some());
3026 assert!(a.survey_gate_targets_current_session());
3027 assert!(a.messages.iter().any(|m| m.role == "research_plan"));
3028 let stored = a.db.load_messages(&session_id).unwrap();
3029 assert!(stored.iter().any(|m| m.role == "research_plan"));
3030 let plan_msg = a
3032 .messages
3033 .iter()
3034 .find(|m| m.role == "research_plan")
3035 .unwrap();
3036 assert!(plan_msg.content.contains("1. q1"));
3037
3038 a.reply_to_survey_gate("drop q2");
3042 assert!(a.survey_gate.is_none());
3043 assert!(!a.survey_gate_targets_current_session());
3044 assert_eq!(rx.recv().await.unwrap(), "drop q2");
3045 assert!(
3046 a.messages
3047 .iter()
3048 .any(|m| m.role == "gate_reply" && m.content == "drop q2")
3049 );
3050 let stored = a.db.load_messages(&session_id).unwrap();
3051 assert!(
3052 stored
3053 .iter()
3054 .any(|m| m.role == "gate_reply" && m.content == "drop q2")
3055 );
3056 let history = a.build_history();
3058 assert!(
3059 !history.iter().any(|m| m.content == "drop q2"),
3060 "gate replies must be excluded from model history"
3061 );
3062 }
3063
3064 #[tokio::test]
3065 async fn plan_ready_saves_a_plan_file_record_in_the_space() {
3066 let mut a = test_app();
3067 a.start_research("rust async runtimes");
3068 let session_id = a.session.as_ref().unwrap().id.clone();
3069 let space_id = a.active_space.id.clone();
3070 let space_name = a.active_space.name.clone();
3071 let (tx, _rx) = mpsc::unbounded_channel();
3072 a.survey_reply_tx = Some(tx);
3073
3074 a.on_research_done(Some((
3075 session_id,
3076 space_id,
3077 space_name.clone(),
3078 ResearchUpdate::PlanReady {
3079 questions: vec![PlanQuestion::bare("q1".to_string())],
3080 rework: false,
3081 },
3082 )));
3083
3084 let dir = a.space.files_dir(&space_name);
3085 let saved: Vec<String> = std::fs::read_dir(&dir)
3086 .unwrap()
3087 .filter_map(std::result::Result::ok)
3088 .map(|e| e.file_name().to_string_lossy().into_owned())
3089 .filter(|n| {
3090 n.starts_with("plan-")
3091 && std::path::Path::new(n)
3092 .extension()
3093 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
3094 })
3095 .collect();
3096 assert_eq!(saved.len(), 1, "expected one plan file in {dir:?}");
3097 let body = std::fs::read_to_string(dir.join(&saved[0])).unwrap();
3098 assert!(body.contains("Research plan: rust async runtimes"));
3099 assert!(body.contains("1. q1"));
3100 }
3101
3102 #[tokio::test]
3103 async fn survey_ready_arms_the_gate_and_renders_a_survey_section() {
3104 let mut a = test_app();
3105 a.start_research("fine-tuning LLMs");
3106 let session_id = a.session.as_ref().unwrap().id.clone();
3107 let space_id = a.active_space.id.clone();
3108 let space_name = a.active_space.name.clone();
3109 let (tx, mut rx) = mpsc::unbounded_channel();
3110 a.survey_reply_tx = Some(tx);
3111
3112 a.on_research_done(Some((
3113 session_id.clone(),
3114 space_id,
3115 space_name,
3116 ResearchUpdate::SurveyReady {
3117 questions: vec!["Depth or breadth?".to_string()],
3118 round: 1,
3119 },
3120 )));
3121 assert!(a.survey_gate_targets_current_session());
3122 let survey = a.messages.iter().find(|m| m.role == "survey").unwrap();
3123 assert!(survey.content.contains("For \"fine-tuning LLMs\":"));
3124 assert!(survey.content.contains("1. Depth or breadth?"));
3125 assert!(survey.content.contains("I approve"));
3126 let stored = a.db.load_messages(&session_id).unwrap();
3127 assert!(stored.iter().any(|m| m.role == "survey"));
3128
3129 a.reply_to_survey_gate("depth first");
3130 assert!(a.survey_gate.is_none());
3131 assert_eq!(rx.recv().await.unwrap(), "depth first");
3132 let (_, status) = a.drain_ui_events();
3133 assert!(status.contains("follow-ups"));
3134 }
3135
3136 #[tokio::test]
3137 async fn gate_only_targets_the_viewed_gated_session() {
3138 let mut a = test_app();
3139 a.start_research("topic one");
3140 let gated_session = a.session.as_ref().unwrap().id.clone();
3141 let (tx, _rx) = mpsc::unbounded_channel();
3142 a.survey_reply_tx = Some(tx);
3143 a.on_research_done(Some((
3144 gated_session.clone(),
3145 a.active_space.id.clone(),
3146 a.active_space.name.clone(),
3147 ResearchUpdate::SurveyReady {
3148 questions: vec!["q?".to_string()],
3149 round: 1,
3150 },
3151 )));
3152 assert!(a.survey_gate_targets_current_session());
3153
3154 let other =
3156 a.db.create_session("other", "m", &a.active_space.id, "chat")
3157 .unwrap();
3158 a.session = Some(other);
3159 a.messages.clear();
3160 assert!(!a.survey_gate_targets_current_session());
3161 assert!(a.survey_gate.is_some(), "gate stays armed for its session");
3162 }
3163
3164 #[tokio::test]
3165 async fn closed_reply_channel_fails_plan_approval_closed() {
3166 let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::<String>();
3171 drop(reply_tx);
3172 let (tx, _rx) = mpsc::unbounded_channel::<ResearchMsg>();
3173 let ids = ("s".to_string(), "sp".to_string(), "sn".to_string());
3174 let mut questions = vec![PlanQuestion::bare("q1".to_string())];
3175 let provider = OpenRouter::openrouter_flavor("test-key".to_string());
3176 let usage = ResearchUsage {
3177 db_path: std::env::temp_dir().join("nexus-research-test.db"),
3178 session_id: ids.0.clone(),
3179 prompt_cache_key: "s:0".to_string(),
3180 space_id: ids.1.clone(),
3181 backend: provider.backend_tag().name(),
3182 };
3183 let result = await_plan_approval(
3184 PlanApprovalContext {
3185 provider: &provider,
3186 model: "a/b",
3187 topic: "topic",
3188 usage: &usage,
3189 tx: &tx,
3190 ids: &ids,
3191 },
3192 &mut questions,
3193 &mut reply_rx,
3194 )
3195 .await;
3196 let err = result.expect_err("closed channel must fail closed, not approve");
3197 assert!(err.contains("cancelled"), "{err}");
3198 }
3199
3200 #[test]
3201 fn start_research_rejects_blank_topic_and_missing_model() {
3202 let mut a = test_app();
3203 a.start_research(" ");
3204 let (_, status) = a.drain_ui_events();
3205 assert!(status.contains("usage:"));
3206 assert!(a.research_rx.is_none());
3207
3208 a.current_model = None;
3209 a.start_research("rust async runtimes");
3210 let (_, status) = a.drain_ui_events();
3211 assert!(status.contains("no model configured"));
3212 assert!(a.research_rx.is_none());
3213 }
3214
3215 #[tokio::test]
3216 async fn start_research_creates_and_switches_into_a_new_session() {
3217 let mut a = test_app();
3218 a.start_research("rust async runtimes");
3219 assert!(a.research_rx.is_some());
3220 assert!(a.research_running.is_some());
3221 let session = a
3222 .session
3223 .as_ref()
3224 .expect("switched into the research session");
3225 assert!(session.title.contains("rust async runtimes"));
3226 assert!(
3227 a.messages
3228 .iter()
3229 .any(|m| m.content.contains("/research rust async runtimes"))
3230 );
3231 }
3232
3233 #[tokio::test]
3234 async fn start_research_refuses_a_second_concurrent_job() {
3235 let mut a = test_app();
3236 a.start_research("topic one");
3237 assert!(a.research_rx.is_some());
3238 a.start_research("topic two");
3239 let (_, status) = a.drain_ui_events();
3240 assert!(status.contains("already running"));
3241 assert!(a.session.as_ref().unwrap().title.contains("topic one"));
3243 }
3244
3245 #[tokio::test]
3246 async fn on_research_done_stage_update_persists_and_shows_when_viewing() {
3247 let mut a = test_app();
3248 a.start_research("rust async runtimes");
3249 let session_id = a.session.as_ref().unwrap().id.clone();
3250 let space_id = a.active_space.id.clone();
3251 let space_name = a.active_space.name.clone();
3252
3253 a.on_research_done(Some((
3254 session_id.clone(),
3255 space_id,
3256 space_name,
3257 ResearchUpdate::Stage {
3258 label: "planning".to_string(),
3259 detail: String::new(),
3260 },
3261 )));
3262
3263 assert!(
3264 a.messages
3265 .iter()
3266 .any(|m| m.role == "research_stage" && m.content == "planning")
3267 );
3268 let stored = a.db.load_messages(&session_id).unwrap();
3269 assert!(
3270 stored
3271 .iter()
3272 .any(|m| m.role == "research_stage" && m.content == "planning")
3273 );
3274 let (_, status) = a.drain_ui_events();
3275 assert!(status.contains("planning"));
3276
3277 let space_id = a.active_space.id.clone();
3279 let space_name = a.active_space.name.clone();
3280 a.on_research_done(Some((
3281 session_id.clone(),
3282 space_id,
3283 space_name,
3284 ResearchUpdate::Stage {
3285 label: "planning".to_string(),
3286 detail: "revised".to_string(),
3287 },
3288 )));
3289 let stored = a.db.load_messages(&session_id).unwrap();
3290 let rows: Vec<_> = stored
3291 .iter()
3292 .filter(|m| m.role == "research_stage")
3293 .collect();
3294 assert_eq!(rows.len(), 1, "one row per label, updated in place");
3295 assert_eq!(rows[0].content, "planning: revised");
3296 let visible_rows: Vec<_> = a
3297 .messages
3298 .iter()
3299 .filter(|m| m.role == "research_stage")
3300 .collect();
3301 assert_eq!(visible_rows.len(), 1);
3302 assert_eq!(visible_rows[0].content, "planning: revised");
3303 let (_, status) = a.drain_ui_events();
3304 assert!(status.contains("Ctrl+↑ agents"));
3305 }
3306
3307 #[tokio::test]
3308 async fn multiple_drained_steers_each_keep_their_own_stage_row() {
3309 let mut a = test_app();
3310 a.start_research("rust async runtimes");
3311 let session_id = a.session.as_ref().unwrap().id.clone();
3312 let space_id = a.active_space.id.clone();
3313 let space_name = a.active_space.name.clone();
3314
3315 for (i, steer) in ["look into X", "also Y"].iter().enumerate() {
3320 a.on_research_done(Some((
3321 session_id.clone(),
3322 space_id.clone(),
3323 space_name.clone(),
3324 ResearchUpdate::Stage {
3325 label: format!("steer #{}", i + 1),
3326 detail: steer.to_string(),
3327 },
3328 )));
3329 }
3330 assert_eq!(
3333 a.research_steer_acked,
3334 std::collections::HashSet::from([1, 2])
3335 );
3336 let stored = a.db.load_messages(&session_id).unwrap();
3337 let steer_rows: Vec<_> = stored
3338 .iter()
3339 .filter(|m| m.role == "research_stage" && m.content.starts_with("steer #"))
3340 .collect();
3341 assert_eq!(steer_rows.len(), 2, "one persisted row per drained steer");
3342 let visible: Vec<_> = a
3343 .messages
3344 .iter()
3345 .filter(|m| m.role == "research_stage" && m.content.starts_with("steer #"))
3346 .collect();
3347 assert_eq!(visible.len(), 2, "both steers visible in the transcript");
3348 }
3349
3350 #[tokio::test]
3351 async fn steer_rows_do_not_collide_on_duplicate_prefix_or_wildcard_text() {
3352 let mut a = test_app();
3353 a.start_research("rust async runtimes");
3354 let session_id = a.session.as_ref().unwrap().id.clone();
3355 let space_id = a.active_space.id.clone();
3356 let space_name = a.active_space.name.clone();
3357
3358 let steers = ["a: b", "a", "same", "same", "100% done"];
3364 for (i, steer) in steers.iter().enumerate() {
3365 a.on_research_done(Some((
3366 session_id.clone(),
3367 space_id.clone(),
3368 space_name.clone(),
3369 ResearchUpdate::Stage {
3370 label: format!("steer #{}", i + 1),
3371 detail: steer.to_string(),
3372 },
3373 )));
3374 }
3375 let stored = a.db.load_messages(&session_id).unwrap();
3376 let rows: Vec<_> = stored
3377 .iter()
3378 .filter(|m| m.role == "research_stage" && m.content.starts_with("steer #"))
3379 .collect();
3380 assert_eq!(
3381 rows.len(),
3382 steers.len(),
3383 "one row per drained steer — no collapse on duplicate/prefix/wildcard text"
3384 );
3385 for (i, steer) in steers.iter().enumerate() {
3386 let want = format!("steer #{}: {steer}", i + 1);
3387 assert!(
3388 rows.iter().any(|m| m.content == want),
3389 "missing row for steer #{}: {want}",
3390 i + 1
3391 );
3392 }
3393 assert_eq!(
3395 a.research_steer_acked,
3396 (1..=steers.len()).collect::<std::collections::HashSet<usize>>(),
3397 "every steer position picked up"
3398 );
3399 }
3400
3401 #[test]
3402 fn steer_log_drops_acknowledged_entries_and_clears_on_stop() {
3403 let mut a = test_app();
3404 let (tx, _rx) = mpsc::unbounded_channel::<String>();
3405 a.research_steer_tx = Some(tx);
3406
3407 a.steer_research("first");
3408 a.steer_research("second");
3409 a.steer_research("third");
3410 assert_eq!(
3411 a.research_steer_log,
3412 vec![
3413 (1, "first".into()),
3414 (2, "second".into()),
3415 (3, "third".into())
3416 ]
3417 );
3418
3419 a.research_steer_acked = std::collections::HashSet::from([1, 2]);
3422 a.steer_research("fourth");
3423 assert_eq!(
3424 a.research_steer_log,
3425 vec![(3, "third".into()), (4, "fourth".into())]
3426 );
3427
3428 a.research_steer_acked.insert(3);
3431 a.on_research_done(Some((
3432 "s".to_string(),
3433 "sp".to_string(),
3434 "sn".to_string(),
3435 ResearchUpdate::Stage {
3436 label: "steer #3".to_string(),
3437 detail: "third".to_string(),
3438 },
3439 )));
3440 assert_eq!(a.research_steer_log, vec![(4, "fourth".into())]);
3441
3442 let (_tx, rx) = mpsc::unbounded_channel::<ResearchMsg>();
3444 a.research_rx = Some(rx);
3445 a.research_running = Some(("s".to_string(), "t".to_string()));
3446 a.stop_research();
3447 assert!(a.research_steer_log.is_empty());
3448 assert!(a.research_steer_acked.is_empty());
3449 }
3450
3451 #[test]
3452 fn steer_queue_is_hard_bound() {
3453 let mut a = test_app();
3454 let (tx, _rx) = mpsc::unbounded_channel::<String>();
3455 a.research_steer_tx = Some(tx);
3456
3457 for i in 0..MAX_QUEUED_STEERS {
3461 a.steer_research(&format!("steer {i}"));
3462 }
3463 assert_eq!(a.research_steer_log.len(), MAX_QUEUED_STEERS);
3464 a.steer_research("overflow");
3465 assert_eq!(a.research_steer_log.len(), MAX_QUEUED_STEERS);
3466 assert!(
3467 a.last_status().contains("steer queue full"),
3468 "{}",
3469 a.last_status()
3470 );
3471 }
3472
3473 #[tokio::test]
3474 async fn plan_ready_in_incognito_mode_writes_no_plan_file() {
3475 let mut a = test_app();
3476 a.incognito = true;
3477 a.start_research("rust async runtimes");
3478 let session_id = a.session.as_ref().unwrap().id.clone();
3479 let space_id = a.active_space.id.clone();
3480 let space_name = a.active_space.name.clone();
3481 let (tx, _rx) = mpsc::unbounded_channel();
3482 a.survey_reply_tx = Some(tx);
3483
3484 a.on_research_done(Some((
3485 session_id.clone(),
3486 space_id,
3487 space_name.clone(),
3488 ResearchUpdate::PlanReady {
3489 questions: vec![PlanQuestion::bare("q1".to_string())],
3490 rework: false,
3491 },
3492 )));
3493
3494 let dir = a.space.files_dir(&space_name);
3499 let _ = std::fs::create_dir_all(&dir);
3500 let saved: Vec<String> = std::fs::read_dir(&dir)
3501 .unwrap()
3502 .filter_map(std::result::Result::ok)
3503 .map(|e| e.file_name().to_string_lossy().into_owned())
3504 .filter(|n| n.starts_with("plan-"))
3505 .collect();
3506 assert!(saved.is_empty(), "no plan files in incognito: {saved:?}");
3507 let stored = a.db.load_messages(&session_id).unwrap();
3508 assert!(
3509 stored.iter().all(|m| m.role != "research_plan"),
3510 "the plan must not be persisted to the message db in incognito"
3511 );
3512 assert!(
3513 a.messages.iter().any(|m| m.role == "research_plan"),
3514 "the in-memory transcript still shows the plan while viewed"
3515 );
3516 }
3517
3518 #[tokio::test]
3519 async fn incognito_gate_rows_follow_the_mode_captured_at_job_start() {
3520 let mut a = test_app();
3521 a.incognito = true;
3522 a.start_research("private topic");
3523 let session_id = a.session.as_ref().unwrap().id.clone();
3524 assert!(a.research_incognito);
3525
3526 a.incognito = false;
3529 let (tx, mut rx) = mpsc::unbounded_channel();
3530 a.survey_reply_tx = Some(tx);
3531 a.on_research_done(Some((
3532 session_id.clone(),
3533 a.active_space.id.clone(),
3534 a.active_space.name.clone(),
3535 ResearchUpdate::SurveyReady {
3536 questions: vec!["Which confidential product?".to_string()],
3537 round: 1,
3538 },
3539 )));
3540 a.reply_to_survey_gate("Project Juniper");
3541 assert_eq!(rx.recv().await.unwrap(), "Project Juniper");
3542
3543 let stored = a.db.load_messages(&session_id).unwrap();
3544 assert!(stored.iter().all(|m| m.role != "survey"));
3545 assert!(stored.iter().all(|m| m.role != "gate_reply"));
3546 }
3547
3548 #[tokio::test]
3549 async fn off_screen_incognito_plan_is_restored_when_its_session_opens() {
3550 let mut a = test_app();
3551 a.incognito = true;
3552 a.start_research("private topic");
3553 let session_id = a.session.as_ref().unwrap().id.clone();
3554 let other =
3555 a.db.create_session("other", "m", &a.active_space.id, "chat")
3556 .unwrap();
3557 a.session = Some(other);
3558 a.messages.clear();
3559 let (tx, _rx) = mpsc::unbounded_channel();
3560 a.survey_reply_tx = Some(tx);
3561
3562 a.on_research_done(Some((
3563 session_id.clone(),
3564 a.active_space.id.clone(),
3565 a.active_space.name.clone(),
3566 ResearchUpdate::PlanReady {
3567 questions: vec![PlanQuestion::bare("private question".to_string())],
3568 rework: true,
3569 },
3570 )));
3571 assert!(!a.survey_gate_targets_current_session());
3572 assert!(
3573 a.db.load_messages(&session_id)
3574 .unwrap()
3575 .iter()
3576 .all(|m| m.role != "research_plan")
3577 );
3578
3579 a.switch_to_session_by_id(&session_id).unwrap();
3580
3581 assert!(a.survey_gate_targets_current_session());
3582 let plan = a
3583 .messages
3584 .iter()
3585 .find(|m| m.role == "research_plan")
3586 .expect("pending incognito plan restored in memory");
3587 assert!(plan.content.contains("private question"));
3588 assert!(plan.content.contains("reply \"approve\""));
3589 assert!(!plan.content.contains("tell me what to change"));
3590 }
3591
3592 #[tokio::test]
3593 async fn undelivered_gate_reply_is_rolled_back_and_restored_to_composer() {
3594 let mut a = test_app();
3595 a.start_research("rust async runtimes");
3596 let session_id = a.session.as_ref().unwrap().id.clone();
3597 let (reply_tx, rx) = mpsc::unbounded_channel::<String>();
3601 drop(rx);
3602 a.survey_reply_tx = Some(reply_tx.clone());
3603 a.on_research_done(Some((
3604 session_id.clone(),
3605 a.active_space.id.clone(),
3606 a.active_space.name.clone(),
3607 ResearchUpdate::PlanReady {
3608 questions: vec![PlanQuestion::bare("q1".to_string())],
3609 rework: false,
3610 },
3611 )));
3612 assert!(a.survey_gate.is_some());
3613
3614 a.reply_to_survey_gate("drop q2");
3615
3616 assert!(a.survey_gate.is_none());
3617 let (sets, _) = a.drain_ui_events();
3619 assert_eq!(sets, vec!["drop q2".to_string()]);
3620 let stored = a.db.load_messages(&session_id).unwrap();
3621 assert!(
3622 stored.iter().all(|m| m.role != "gate_reply"),
3623 "undelivered reply must not remain persisted"
3624 );
3625 assert!(!a.messages.iter().any(|m| m.role == "gate_reply"));
3626 }
3627
3628 #[tokio::test]
3629 async fn off_screen_gate_marks_the_session_unread_and_notifies() {
3630 let mut a = test_app();
3631 a.start_research("rust async runtimes");
3632 let session_id = a.session.as_ref().unwrap().id.clone();
3633 let other =
3635 a.db.create_session("other", "m", &a.active_space.id, "chat")
3636 .unwrap();
3637 a.session = Some(other);
3638 a.messages.clear();
3639 let (tx, _rx) = mpsc::unbounded_channel();
3640 a.survey_reply_tx = Some(tx);
3641
3642 a.on_research_done(Some((
3643 session_id.clone(),
3644 a.active_space.id.clone(),
3645 a.active_space.name.clone(),
3646 ResearchUpdate::SurveyReady {
3647 questions: vec!["Depth or breadth?".to_string()],
3648 round: 1,
3649 },
3650 )));
3651
3652 assert!(a.survey_gate.is_some());
3656 assert!(!a.survey_gate_targets_current_session());
3657 assert!(
3658 a.unread.contains(&session_id),
3659 "session must be marked unread"
3660 );
3661 let (_, status) = a.drain_ui_events();
3662 assert!(status.contains("waiting on you"), "{status}");
3663 assert!(status.contains("survey round 1"), "{status}");
3664 }
3665
3666 #[tokio::test]
3667 async fn on_research_done_final_report_posts_message_saves_file_and_notifies_when_away() {
3668 let mut a = test_app();
3669 a.start_research("rust async runtimes");
3670 let session_id = a.session.as_ref().unwrap().id.clone();
3671 let space_id = a.active_space.id.clone();
3672 let space_name = a.active_space.name.clone();
3673
3674 a.session = None;
3676 a.messages.clear();
3677
3678 a.on_research_done(Some((
3679 session_id.clone(),
3680 space_id,
3681 space_name.clone(),
3682 ResearchUpdate::Done(Ok(
3683 "# Rust Async Runtimes\n\nBody text. [1]\n\n## Sources\n1. https://a".to_string(),
3684 )),
3685 )));
3686
3687 assert!(a.unread.contains(&session_id));
3688 let stored = a.db.load_messages(&session_id).unwrap();
3689 assert!(
3690 stored
3691 .iter()
3692 .any(|m| m.role == "assistant" && m.content.contains("Rust Async Runtimes"))
3693 );
3694
3695 let dir = a.space.files_dir(&space_name);
3697 let saved = std::fs::read_dir(&dir)
3698 .unwrap()
3699 .filter_map(std::result::Result::ok)
3700 .count();
3701 assert_eq!(
3702 saved, 1,
3703 "expected exactly one saved report file in {dir:?}"
3704 );
3705 }
3706
3707 #[tokio::test]
3708 async fn on_research_done_saves_report_to_original_space_even_if_user_switched() {
3709 let mut a = test_app();
3710
3711 a.start_research("rust async runtimes");
3713 let session_id = a.session.as_ref().unwrap().id.clone();
3714 let original_space_id = a.active_space.id.clone();
3715 let original_space_name = a.active_space.name.clone();
3716
3717 let second_space = a.db.create_space("research-test-space-2").unwrap();
3719 a.space.ensure_space_dir(&second_space.name).unwrap();
3720 a.active_space = second_space.clone();
3721 a.session = None;
3722 a.messages.clear();
3723 a.files_cache.clear();
3724
3725 assert_eq!(a.active_space.id, second_space.id);
3727 assert_ne!(a.active_space.id, original_space_id);
3728
3729 a.on_research_done(Some((
3731 session_id.clone(),
3732 original_space_id.clone(),
3733 original_space_name.clone(),
3734 ResearchUpdate::Done(Ok(
3735 "# Rust Async Runtimes\n\nBody text. [1]\n\n## Sources\n1. https://a".to_string(),
3736 )),
3737 )));
3738
3739 let original_dir = a.space.files_dir(&original_space_name);
3741 let original_files = std::fs::read_dir(&original_dir)
3742 .unwrap()
3743 .filter_map(std::result::Result::ok)
3744 .count();
3745 assert_eq!(
3746 original_files, 1,
3747 "expected exactly one report file in original space {original_dir:?}"
3748 );
3749
3750 let second_dir = a.space.files_dir(&second_space.name);
3752 let second_files = std::fs::read_dir(&second_dir)
3753 .map_or(0, |d| d.filter_map(std::result::Result::ok).count());
3754 assert_eq!(
3755 second_files, 0,
3756 "expected no files in second (active) space {second_dir:?}"
3757 );
3758
3759 assert_eq!(
3762 a.files_cache.len(),
3763 0,
3764 "files_cache should be empty since rescan was not triggered"
3765 );
3766 }
3767
3768 #[tokio::test]
3769 async fn on_research_done_failure_posts_error_message() {
3770 let mut a = test_app();
3771 a.start_research("rust async runtimes");
3772 let session_id = a.session.as_ref().unwrap().id.clone();
3773 let space_id = a.active_space.id.clone();
3774 let space_name = a.active_space.name.clone();
3775
3776 a.on_research_done(Some((
3777 session_id.clone(),
3778 space_id,
3779 space_name,
3780 ResearchUpdate::Done(Err("planner: network down".to_string())),
3781 )));
3782
3783 let (_, status) = a.drain_ui_events();
3784 assert!(status.contains("network down"));
3785 let stored = a.db.load_messages(&session_id).unwrap();
3786 assert!(
3787 stored
3788 .iter()
3789 .any(|m| m.role == "assistant" && m.content.contains("network down"))
3790 );
3791 }
3792
3793 #[tokio::test]
3794 async fn on_research_done_none_clears_domain_state() {
3795 let mut a = test_app();
3796 a.start_research("t");
3797 assert!(a.research_rx.is_some());
3798 a.research_live_input = "late steer".to_string();
3799 a.research_stage_rows = vec!["writer: done".to_string()];
3800
3801 a.on_research_done(None);
3802
3803 assert!(a.research_rx.is_none());
3804 assert!(a.research_running.is_none());
3805 assert!(a.research_live_input.is_empty());
3806 assert!(a.research_stage_rows.is_empty());
3807 }
3808
3809 #[test]
3810 fn parse_subquestions_reads_a_clean_json_array() {
3811 let qs = parse_subquestions(r#"["what is X", "how does Y work"]"#);
3812 assert_eq!(
3813 qs,
3814 vec!["what is X".to_string(), "how does Y work".to_string()]
3815 );
3816 }
3817
3818 #[test]
3819 fn parse_subquestions_strips_markdown_fences() {
3820 let qs = parse_subquestions("```json\n[\"a\", \"b\"]\n```");
3821 assert_eq!(qs, vec!["a".to_string(), "b".to_string()]);
3822 }
3823
3824 #[test]
3825 fn parse_subquestions_falls_back_to_bullet_lines() {
3826 let qs = parse_subquestions("- what is X\n- how does Y work\n* a third one");
3827 assert_eq!(
3828 qs,
3829 vec![
3830 "what is X".to_string(),
3831 "how does Y work".to_string(),
3832 "a third one".to_string()
3833 ]
3834 );
3835 }
3836
3837 #[test]
3838 fn parse_subquestions_falls_back_to_numbered_lines() {
3839 let qs = parse_subquestions("1. what is X\n2) how does Y work");
3840 assert_eq!(
3841 qs,
3842 vec!["what is X".to_string(), "how does Y work".to_string()]
3843 );
3844 }
3845
3846 #[test]
3847 fn parse_subquestions_caps_at_max() {
3848 let lines: Vec<String> = (0..10).map(|i| format!("- q{i}")).collect();
3849 let qs = parse_subquestions(&lines.join("\n"));
3850 assert_eq!(qs.len(), MAX_SUBQUESTIONS);
3851 }
3852
3853 #[test]
3854 fn parse_critique_recognizes_satisfied() {
3855 assert_eq!(parse_critique("SATISFIED"), Critique::Satisfied);
3856 assert_eq!(parse_critique(" satisfied "), Critique::Satisfied);
3857 }
3858
3859 #[test]
3860 fn parse_critique_recognizes_gaps() {
3861 let c = parse_critique("GAPS:\n- what about pricing?\n- any recent incidents?");
3862 assert_eq!(
3863 c,
3864 Critique::Gaps(vec![
3865 "what about pricing?".to_string(),
3866 "any recent incidents?".to_string()
3867 ])
3868 );
3869 }
3870
3871 #[test]
3872 fn parse_critique_recognizes_contradiction() {
3873 let c = parse_critique("CONTRADICTION: source A says X, source B says not-X");
3874 assert_eq!(
3875 c,
3876 Critique::Contradiction("source A says X, source B says not-X".to_string())
3877 );
3878 }
3879
3880 #[test]
3881 fn parse_critique_falls_back_to_satisfied_on_garbage() {
3882 assert_eq!(
3883 parse_critique("uh, looks fine I guess?"),
3884 Critique::Satisfied
3885 );
3886 assert_eq!(parse_critique("GAPS:\n"), Critique::Satisfied);
3887 }
3888
3889 #[test]
3890 fn synthesizer_messages_includes_topic_and_all_findings() {
3891 let msgs = synthesizer_messages(
3892 "rust async runtimes",
3893 &["finding one".to_string(), "finding two".to_string()],
3894 &[],
3895 );
3896 assert_eq!(msgs[0].role, "system");
3897 assert!(msgs[1].content.contains("rust async runtimes"));
3898 assert!(msgs[1].content.contains("finding one"));
3899 assert!(msgs[1].content.contains("finding two"));
3900 }
3901
3902 #[test]
3903 fn synthesizer_messages_lists_pinned_sources_when_present() {
3904 let msgs = synthesizer_messages(
3905 "topic",
3906 &["finding one".to_string()],
3907 &["https://a.example".to_string()],
3908 );
3909 let user = msgs.iter().find(|m| m.role == "user").unwrap();
3910 assert!(
3911 user.content.contains("https://a.example"),
3912 "{}",
3913 user.content
3914 );
3915 assert!(
3916 user.content.to_lowercase().contains("prioritize"),
3917 "{}",
3918 user.content
3919 );
3920 }
3921
3922 #[test]
3923 fn synthesizer_messages_omits_pinned_section_when_empty() {
3924 let msgs = synthesizer_messages("topic", &["finding one".to_string()], &[]);
3925 let user = msgs.iter().find(|m| m.role == "user").unwrap();
3926 assert!(
3927 !user.content.to_lowercase().contains("prioritize"),
3928 "{}",
3929 user.content
3930 );
3931 }
3932
3933 #[test]
3934 fn critic_messages_includes_topic_and_draft() {
3935 let msgs = critic_messages("topic X", "draft text");
3936 assert!(msgs[1].content.contains("topic X"));
3937 assert!(msgs[1].content.contains("draft text"));
3938 }
3939
3940 #[test]
3941 fn resolver_messages_includes_contradiction_description() {
3942 let msgs = resolver_messages("t", "draft", &["f1".to_string()], "A vs B");
3943 assert!(msgs[1].content.contains("A vs B"));
3944 assert!(msgs[1].content.contains("f1"));
3945 }
3946
3947 #[test]
3948 fn writer_messages_includes_verified_draft() {
3949 let msgs = writer_messages("t", "verified content", &[]);
3950 assert!(msgs[1].content.contains("verified content"));
3951 }
3952}