use std::sync::Arc;
use argonauts_core::NoteLevel;
use argonauts_core::app::{AppEvent, AppMode, TurnReply};
use argonauts_core::names::{ModelRef, RoleName, TurnId};
use argonauts_core::session_runner::{RunnerError, SessionRunner};
use super::excerpt::{Exchange, reply_text};
use super::lifecycle::{begin, begin_side};
use super::{ConversationState, Queued};
use crate::controller::recap::{recap_from_summary, title_from_request};
use crate::controller::turn::{self, TurnStep};
use crate::controller::workspace::review_changes;
use crate::controller::{Ctx, Effect};
const SIDE_PREFIX: &str = "t-side-";
const COMPACT: &str = "/compact";
const COMPACTED: &str = "Summarize the conversation so far.";
const TITLE_CHARS: usize = 60;
const RECAP_CHARS: usize = 140;
pub(super) fn side_turn_id(n: u32) -> TurnId {
TurnId::parse(&format!("{SIDE_PREFIX}{n:03}")).unwrap_or_else(|| TurnId::numbered(n))
}
pub(super) fn is_side_turn(turn: &TurnId) -> bool {
turn.as_str().starts_with(SIDE_PREFIX)
}
pub(super) fn is_compact_request(request: &str) -> bool {
request == COMPACT || request.starts_with("/compact ")
}
pub(crate) fn btw(
ctx: &mut Ctx,
conversation: &mut ConversationState,
question: Option<String>,
) -> anyhow::Result<Vec<Effect>> {
let question = question.unwrap_or_default().trim().to_owned();
if question.is_empty() {
ctx.notice(
NoteLevel::Info,
"Put the side question after /btw. Its answer stays out of the conversation.",
);
return Ok(Vec::new());
}
if conversation.turn.is_some() {
conversation.queue(Queued::Side(question));
return Ok(Vec::new());
}
begin_side(ctx, conversation, question, TurnStep::Side)?;
Ok(Vec::new())
}
pub(crate) fn rename(
ctx: &mut Ctx,
conversation: &mut ConversationState,
name: Option<String>,
) -> anyhow::Result<Vec<Effect>> {
if let Some(name) = name
.map(|name| clean_title(&name))
.filter(|n| !n.is_empty())
{
return Ok(set_title(ctx, conversation, name));
}
if conversation.history.is_empty() {
ctx.notice(
NoteLevel::Info,
"Nothing to name yet. /rename <name> names it.",
);
return Ok(Vec::new());
}
if conversation.turn.is_some() {
ctx.notice(
NoteLevel::Info,
"A turn is still running. /rename <name> names it now, or /rename once it ends.",
);
return Ok(Vec::new());
}
begin_side(
ctx,
conversation,
"Name this conversation".to_owned(),
TurnStep::Title,
)?;
Ok(Vec::new())
}
fn set_title(ctx: &Ctx, conversation: &mut ConversationState, title: String) -> Vec<Effect> {
if let Err(err) = conversation.store.save_title(&title) {
ctx.notice(
NoteLevel::Warn,
format!("The name lasts only until you close the app: {err:#}"),
);
}
ctx.notice(
NoteLevel::Info,
format!("Renamed this conversation to \"{title}\"."),
);
conversation.title = title;
vec![Effect::Status]
}
fn clean_title(text: &str) -> String {
let line = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or_default();
let line = line
.trim_start_matches('#')
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | '`' | '*' | '“' | '”'))
.trim_end_matches('.')
.trim();
cut(line, TITLE_CHARS)
}
pub(crate) fn compact(
ctx: &mut Ctx,
conversation: &mut ConversationState,
instructions: Option<String>,
) -> anyhow::Result<Vec<Effect>> {
if conversation.history.is_empty() {
ctx.notice(NoteLevel::Info, "Nothing to compact yet.");
return Ok(Vec::new());
}
if conversation.turn.is_some() {
ctx.notice(
NoteLevel::Info,
"A turn is still running. Esc stops it, then /compact sums up the conversation.",
);
return Ok(Vec::new());
}
let text = match &instructions {
Some(instructions) => format!("{COMPACT} {instructions}"),
None => COMPACT.to_owned(),
};
begin(ctx, conversation, text, |_| TurnStep::Compact {
instructions,
})?;
Ok(Vec::new())
}
pub(super) fn after(
ctx: &Ctx,
conversation: &mut ConversationState,
step: &TurnStep,
reply: &TurnReply,
) -> Vec<Effect> {
match step {
TurnStep::Title => {
let title = clean_title(reply_text(reply));
if title.is_empty() {
ctx.notice(
NoteLevel::Info,
"No name came back. /rename <name> names the conversation.",
);
return Vec::new();
}
set_title(ctx, conversation, title)
}
TurnStep::Compact { .. } => {
conversation.history = vec![Exchange::new(COMPACTED.to_owned(), reply)];
super::carry::forget_summary(conversation);
conversation.session = None;
if let Err(err) = conversation.store.forget_session() {
ctx.notice(NoteLevel::Warn, format!("{err:#}"));
}
if let Some(row) = conversation.context.as_mut() {
row.used = 0;
}
ctx.notice(
NoteLevel::Info,
"Compacted. The next turn starts a fresh session from this summary.",
);
Vec::new()
}
_ => Vec::new(),
}
}
pub(crate) async fn recap(
ctx: &mut Ctx,
conversation: &mut ConversationState,
) -> anyhow::Result<Vec<Effect>> {
if let AppMode::Working { run } | AppMode::Pausing { run } | AppMode::Waiting { run, .. } =
&ctx.mode
{
let run = run.clone();
match ctx.services.conductor.status(&run).await {
Ok(summary) => {
ctx.send(AppEvent::Recap {
text: recap_from_summary(&summary, None, None),
model: None,
});
return Ok(Vec::new());
}
Err(err) => ctx.notice(
NoteLevel::Warn,
format!("Couldn't read {run}'s status, so this recaps the conversation: {err}"),
),
}
}
let Some(last) = conversation.history.last() else {
ctx.notice(NoteLevel::Info, "Nothing to recap yet.");
return Ok(Vec::new());
};
ctx.send(AppEvent::Recap {
text: conversation_recap(&conversation.title, conversation.history.len(), last),
model: None,
});
Ok(Vec::new())
}
fn conversation_recap(title: &str, exchanges: usize, last: &Exchange) -> String {
let asked = title_from_request(last.request());
let title = if title.is_empty() { &asked } else { title };
let count = if exchanges == 1 {
"1 exchange".to_owned()
} else {
format!("{exchanges} exchanges")
};
let answer = first_sentence(last.answer());
format!("{title}, {count}. Last you asked \"{asked}\": {answer}")
}
fn first_sentence(markdown: &str) -> String {
let line = markdown
.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#'))
.map(|line| line.trim_start_matches(['-', '*', '>', ' ']))
.find(|line| !line.is_empty())
.unwrap_or_default();
let sentence = match line.find(". ") {
Some(end) => &line[..=end],
None => line,
};
cut(&sentence.replace(['*', '`'], ""), RECAP_CHARS)
}
fn cut(text: &str, chars: usize) -> String {
if text.chars().count() <= chars {
return text.to_owned();
}
let mut short: String = text.chars().take(chars - 1).collect();
short.push('…');
short
}
pub(crate) fn review(
ctx: &mut Ctx,
conversation: &mut ConversationState,
security: bool,
target: Option<String>,
) -> anyhow::Result<Vec<Effect>> {
let command = if security {
"/security-review"
} else {
"/review"
};
if conversation.turn.is_some() {
ctx.notice(
NoteLevel::Info,
format!("A turn is still running. Esc stops it, or {command} again once it ends."),
);
return Ok(Vec::new());
}
let folder = conversation.folder(ctx).to_path_buf();
let Some(changes) = review_changes(&folder, target.as_deref())? else {
ctx.notice(
NoteLevel::Info,
"Nothing to review: the working tree has no changes. Name a branch, a commit range, \
or a pull request.",
);
return Ok(Vec::new());
};
let other_provider = other_provider(ctx, &[]).is_some();
if !other_provider {
ctx.notice(
NoteLevel::Info,
format!(
"No agent from another provider is in the crew and ready, so {} reviews.",
ctx.settings().crew.main
),
);
}
let text = match &target {
Some(target) => format!("{command} {target}"),
None => command.to_owned(),
};
begin(ctx, conversation, text, |_| TurnStep::Review {
security,
target: changes.target,
changes: changes.text,
other_provider,
})?;
Ok(Vec::new())
}
pub(super) fn reviewer(
ctx: &Ctx,
tried: &[ModelRef],
) -> Result<(ModelRef, Arc<dyn SessionRunner>), RunnerError> {
match other_provider(ctx, tried) {
Some(found) => Ok(found),
None => turn::pick(ctx, tried),
}
}
fn other_provider(ctx: &Ctx, tried: &[ModelRef]) -> Option<(ModelRef, Arc<dyn SessionRunner>)> {
let settings = ctx.settings();
let registry = &ctx.services.registry;
let main = registry.provider(&settings.crew.main);
let mut pool = argonauts_crew::roles::candidates(settings, RoleName::Reviewer);
pool.extend(ctx.services.workers.available());
pool.into_iter().find_map(|worker| {
let fit = !tried.contains(&worker)
&& registry.provider(&worker) != main
&& !turn::agent_out(ctx, &worker);
let handle = ctx.services.workers.get(&worker).ok().filter(|_| fit)?;
Some((worker, handle))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_suggested_name_loses_its_quotes_and_full_stop() {
assert_eq!(
clean_title("\"Rate limiting after auth.\"\n"),
"Rate limiting after auth"
);
assert_eq!(clean_title("\n# Login loop\nmore"), "Login loop");
assert_eq!(
clean_title(&"word ".repeat(40)).chars().count(),
TITLE_CHARS
);
}
#[test]
fn side_turns_number_apart_and_compact_requests_are_known() {
let side = side_turn_id(2);
assert_eq!(side.as_str(), "t-side-002");
assert!(is_side_turn(&side));
assert!(!is_side_turn(&TurnId::numbered(2)));
assert!(is_compact_request("/compact"));
assert!(is_compact_request("/compact keep the plan"));
assert!(!is_compact_request("/compactly"));
}
#[test]
fn a_recap_names_the_conversation_and_quotes_the_last_answer() {
let last = Exchange::new(
"why does login loop?".to_owned(),
&TurnReply::Answer {
markdown: "## Cause\n**The cookie** is set twice. Then it redirects.".to_owned(),
},
);
assert_eq!(
conversation_recap("Login loop", 3, &last),
"Login loop, 3 exchanges. Last you asked \"why does login loop?\": The cookie is set \
twice."
);
assert!(conversation_recap("", 1, &last).starts_with("why does login loop?, 1 exchange."));
}
}