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