argonauts-app 0.1.0

Argonauts Code's app controller and run host: the conversation, routing, choosing Captain or crew, and the run host each run has.
//! Roles and prompts files that came with a repository are instructions for your agents that
//! someone else wrote. They wait until you trust them, and you are asked once.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::Context as _;
use argonauts_core::NoteLevel;
use argonauts_core::app::{AppEvent, ChoiceKind};
use argonauts_core::events::approval::AnswerAction;
use argonauts_core::names::ChoiceId;
use argonauts_crew::prompts::trust;

use super::{Ctx, Effect};
use crate::choices;

type Handled = anyhow::Result<Vec<Effect>>;

#[derive(Default)]
pub(crate) struct PromptTrustState {
    /// The open question's files.
    pending: HashMap<ChoiceId, Vec<PathBuf>>,
}

pub(crate) fn owns_choice(state: &PromptTrustState, id: &ChoiceId) -> bool {
    state.pending.contains_key(id)
}

/// At start: one question for the files that wait and that you weren't asked about yet.
pub(crate) fn ask_at_start(ctx: &mut Ctx, state: &mut PromptTrustState) {
    let files = trust::waiting(&ctx.env.repo_root, &ctx.env.state_dir).new;
    if files.is_empty() {
        return;
    }
    let mut body = vec![
        "This repository came with instructions, workflows or guide pages for your agents that \
         you haven't read:"
            .to_owned(),
    ];
    body.extend(
        files
            .iter()
            .map(|file| format!("  {}", ctx.env.shown(file))),
    );
    body.push(
        "Until you trust them they aren't used, and anyone who can change the repository could \
         have written them. /prompts trust says yes later."
            .to_owned(),
    );
    let id = ctx.next_choice_id();
    ctx.send(AppEvent::Choice {
        choice: choices::app_choice(
            id.clone(),
            ChoiceKind::TrustProject,
            "Use these instructions?",
            body,
            choices::options(ChoiceKind::TrustProject),
        ),
    });
    state.pending.insert(id, files);
}

pub(crate) fn answer_choice(
    ctx: &mut Ctx,
    state: &mut PromptTrustState,
    id: ChoiceId,
    action: AnswerAction,
) -> Handled {
    let Some(files) = state.pending.remove(&id) else {
        ctx.notice(NoteLevel::Info, "That question is already closed.");
        return Ok(Vec::new());
    };
    let outcome = match action {
        AnswerAction::Approve => {
            trust::trust(&ctx.env.state_dir, &files).context("trusting the files")?;
            format!(
                "Trusted {}. Agents that start from now on read them.",
                counted(&files)
            )
        }
        // "Show the file": print each, and leave the question open.
        AnswerAction::ShowDiff => {
            for file in &files {
                show_file(ctx, file);
            }
            state.pending.insert(id, files);
            return Ok(Vec::new());
        }
        _ => leave_unused(ctx, &files)?,
    };
    close(ctx, id, outcome);
    Ok(Vec::new())
}

/// A file a trust question is about, printed above the question as its path and its lines, so
/// you can read what your agents would. A long one stops after [`SHOWN_LINES`].
pub(crate) fn show_file(ctx: &Ctx, file: &Path) {
    let text = match std::fs::read_to_string(file) {
        Ok(text) => text,
        Err(err) => format!("It can't be read: {err}"),
    };
    let all: Vec<&str> = text.lines().collect();
    let mut lines: Vec<String> = all
        .iter()
        .take(SHOWN_LINES)
        .map(|line| (*line).to_owned())
        .collect();
    if all.len() > SHOWN_LINES {
        lines.push(format!(
            "… {} more lines in the file",
            all.len() - SHOWN_LINES
        ));
    }
    ctx.send(AppEvent::Report {
        title: ctx.env.shown(file),
        lines,
    });
}

/// How much of a file "Show the file" prints. The rest is the file's to show.
const SHOWN_LINES: usize = 200;

/// Esc: not now. The files stay unused, and you aren't asked again.
pub(crate) fn set_aside(ctx: &mut Ctx, state: &mut PromptTrustState, id: ChoiceId) -> Handled {
    if let Some(files) = state.pending.remove(&id) {
        let outcome = leave_unused(ctx, &files)?;
        close(ctx, id, outcome);
    }
    Ok(Vec::new())
}

/// `/prompts trust`: trusts every file that waits, asked about before or not.
pub(crate) fn trust_all(ctx: &Ctx) -> anyhow::Result<()> {
    let files: Vec<PathBuf> = trust::waiting(&ctx.env.repo_root, &ctx.env.state_dir)
        .all()
        .collect();
    if files.is_empty() {
        ctx.notice(
            NoteLevel::Info,
            "No roles or prompts file in this repository waits for your trust.",
        );
        return Ok(());
    }
    trust::trust(&ctx.env.state_dir, &files).context("trusting the files")?;
    let names: Vec<String> = files.iter().map(|file| ctx.env.shown(file)).collect();
    ctx.notice(
        NoteLevel::Info,
        format!(
            "Trusted {}. Agents that start from now on read them.",
            names.join(", ")
        ),
    );
    Ok(())
}

fn leave_unused(ctx: &Ctx, files: &[PathBuf]) -> anyhow::Result<String> {
    trust::decline(&ctx.env.state_dir, files).context("remembering your answer")?;
    Ok(format!(
        "{} unused. /prompts trust uses them.",
        match files.len() {
            1 => "The file stays".to_owned(),
            n => format!("The {n} files stay"),
        }
    ))
}

fn counted(files: &[PathBuf]) -> String {
    match files.len() {
        1 => "1 file".to_owned(),
        n => format!("{n} files"),
    }
}

fn close(ctx: &Ctx, id: ChoiceId, outcome: impl Into<String>) {
    ctx.send(AppEvent::ChoiceClosed {
        id,
        outcome: outcome.into(),
    });
}

#[cfg(test)]
mod tests {
    use argonauts_core::app::{AnswerTarget, UiCommand};
    use argonauts_core::input::{PromptsAction, SlashCommand};
    use argonauts_core::names::RoleName;
    use argonauts_testkit::TempRepo;

    use super::super::test_support::{CliOptions, TestDriver};
    use super::*;
    use crate::prompts::catalog;

    const ROLE: &str = ".argonauts/roles/reviewer.md";

    /// A repository as a clone has it: the role file is committed, and nothing here wrote it.
    fn cloned(repo: &TempRepo) {
        repo.write(ROLE, "Go easy on this code.\n").unwrap();
        repo.commit_all("add a reviewers file").unwrap();
    }

    async fn start(prepare: fn(&TempRepo)) -> TestDriver {
        TestDriver::start_with(CliOptions {
            prepare: Some(Box::new(prepare)),
            ..CliOptions::default()
        })
        .await
    }

    async fn question(driver: &mut TestDriver) -> argonauts_core::app::AppChoice {
        let asked = driver
            .until(|event| {
                matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::TrustProject)
            })
            .await;
        match asked {
            AppEvent::Choice { choice } => choice,
            _ => unreachable!(),
        }
    }

    async fn answer(driver: &TestDriver, id: &ChoiceId, action: AnswerAction) {
        driver
            .send(UiCommand::Answer {
                to: AnswerTarget::Choice { id: id.clone() },
                action,
                text: None,
                evidence_sha256: None,
                tasks: Vec::new(),
                remember: false,
            })
            .await;
    }

    fn reviewers_file_is_used(driver: &TestDriver) -> bool {
        catalog::dirs(&driver.env)
            .role(RoleName::Reviewer)
            .is_some()
    }

    #[tokio::test]
    async fn a_cloned_role_file_asks_once_and_is_used_after_a_yes() {
        let mut driver = start(cloned).await;
        let asked = question(&mut driver).await;
        assert_eq!(asked.title, "Use these instructions?");
        assert!(
            asked
                .body
                .iter()
                .any(|line| line.contains("roles/reviewer.md"))
        );
        assert!(!reviewers_file_is_used(&driver));

        // "Show the file" prints what it says above the question, which stays open.
        answer(&driver, &asked.id, AnswerAction::ShowDiff).await;
        let shown = driver
            .until(|event| matches!(event, AppEvent::Report { .. }))
            .await;
        let AppEvent::Report { title, lines } = shown else {
            unreachable!()
        };
        assert!(title.ends_with("roles/reviewer.md"), "{title}");
        assert_eq!(lines, ["Go easy on this code."]);

        answer(&driver, &asked.id, AnswerAction::Approve).await;
        let closed = driver
            .until(|event| matches!(event, AppEvent::ChoiceClosed { .. }))
            .await;
        let AppEvent::ChoiceClosed { outcome, .. } = closed else {
            unreachable!()
        };
        assert!(outcome.starts_with("Trusted 1 file"), "{outcome}");
        assert!(reviewers_file_is_used(&driver));
    }

    #[tokio::test]
    async fn a_no_leaves_it_unused_until_prompts_trust() {
        let mut driver = start(cloned).await;
        let asked = question(&mut driver).await;
        answer(&driver, &asked.id, AnswerAction::Cancel).await;
        driver
            .until(|event| matches!(event, AppEvent::ChoiceClosed { .. }))
            .await;
        assert!(!reviewers_file_is_used(&driver));
        // Asked once: the file now waits as one you said no to.
        let waiting = trust::waiting(&driver.env.repo_root, &driver.env.state_dir);
        assert!(waiting.new.is_empty());
        assert_eq!(waiting.declined.len(), 1);

        driver
            .send(UiCommand::Slash {
                command: SlashCommand::Prompts {
                    action: PromptsAction::Trust,
                },
            })
            .await;
        let trusted = driver
            .until(|event| matches!(event, AppEvent::Notice { text, .. } if text.starts_with("Trusted")))
            .await;
        assert!(matches!(trusted, AppEvent::Notice { .. }));
        assert!(reviewers_file_is_used(&driver));
    }

    /// The first start runs the setup, which asks about the folder's settings and not about
    /// its prompt files. They are asked about once the setup ends.
    #[tokio::test]
    async fn the_question_comes_after_the_setup_on_a_first_start() {
        use crate::controller::Opening;

        // Nothing real runs for the setup's checks: every CLI points nowhere.
        let ids = [
            "claude-code",
            "codex",
            "antigravity-cli",
            "opencode",
            "opencode-acp",
        ];
        let mut driver = TestDriver::start_with(CliOptions {
            flags: ids
                .iter()
                .map(|id| format!(r#"agents.{id}.binary="/nowhere/{id}""#))
                .collect(),
            opening: Opening {
                guided_setup: true,
                ..Opening::default()
            },
            prepare: Some(Box::new(cloned)),
            ..CliOptions::default()
        })
        .await;
        let welcome = driver
            .until(|event| matches!(event, AppEvent::Choice { .. }))
            .await;
        let AppEvent::Choice { choice: welcome } = welcome else {
            unreachable!()
        };
        assert_ne!(welcome.kind, ChoiceKind::TrustProject, "{welcome:?}");
        // Esc takes the setup's recommended answers.
        driver
            .send(UiCommand::SetAside {
                to: AnswerTarget::Choice { id: welcome.id },
            })
            .await;
        let asked = question(&mut driver).await;
        assert_eq!(asked.title, "Use these instructions?");
        assert!(!reviewers_file_is_used(&driver));
        // The setup left every template where you can edit it, and none in the repository.
        let yours = driver.env.paths.home().join("prompts");
        assert!(yours.join("README.md").is_file());
        assert!(yours.join("frames/judge.md").is_file());
        assert!(!driver.env.repo_root.join(".argonauts/prompts").exists());
    }

    #[tokio::test]
    async fn a_file_you_wrote_here_is_never_asked_about() {
        let driver = start(|repo| {
            repo.write(ROLE, "Check error handling.\n").unwrap();
        })
        .await;
        assert!(trust::waiting(&driver.env.repo_root, &driver.env.state_dir).is_empty());
        assert!(reviewers_file_is_used(&driver));
    }
}