sparrow-cli 0.5.0

A local-first Rust agent cockpit — route, run, replay, rewind
Documentation
#![allow(clippy::bool_assert_comparison)]

use async_trait::async_trait;
use futures::stream;
use sparrow::config::Config;
use sparrow::engine::{Engine, Task};
use sparrow::event::{Event, StopReason};
use sparrow::provider::{Brain, BrainEvent, BrainRequest, BrainStream, LatencyClass, ModelCaps};
use sparrow::router::BasicRouter;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::mpsc;

static CURRENT_DIR_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

#[derive(Clone)]
struct ScriptedBrain {
    id: String,
    responses: Arc<StdMutex<VecDeque<Vec<BrainEvent>>>>,
    calls: Arc<StdMutex<usize>>,
}

impl ScriptedBrain {
    fn new(responses: Vec<Vec<BrainEvent>>) -> Self {
        Self {
            id: "local:scripted".into(),
            responses: Arc::new(StdMutex::new(responses.into())),
            calls: Arc::new(StdMutex::new(0)),
        }
    }

    fn call_count(&self) -> usize {
        *self.calls.lock().unwrap()
    }
}

#[async_trait]
impl Brain for ScriptedBrain {
    fn id(&self) -> &str {
        &self.id
    }

    fn caps(&self) -> ModelCaps {
        ModelCaps {
            context_window: 32_768,
            max_output: 4096,
            tools: true,
            vision: false,
            cost_input_per_mtok: 0.0,
            cost_output_per_mtok: 0.0,
            latency: LatencyClass::Fast,
        }
    }

    async fn complete(&self, _req: BrainRequest) -> anyhow::Result<BrainStream> {
        *self.calls.lock().unwrap() += 1;
        let events = self
            .responses
            .lock()
            .unwrap()
            .pop_front()
            .unwrap_or_else(|| vec![BrainEvent::Done(StopReason::EndTurn)]);
        Ok(Box::pin(stream::iter(events)))
    }
}

fn temp_workspace(name: &str) -> PathBuf {
    let id = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir().join(format!("sparrow-{name}-{id}"))
}

fn init_git_repo(root: &Path) {
    std::fs::create_dir_all(root).unwrap();
    std::process::Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    std::process::Command::new("git")
        .args(["config", "user.email", "test@sparrow.dev"])
        .current_dir(root)
        .output()
        .unwrap();
    std::process::Command::new("git")
        .args(["config", "user.name", "Sparrow Test"])
        .current_dir(root)
        .output()
        .unwrap();
}

fn engine_for(brain: ScriptedBrain) -> Engine {
    let config = Config::default();
    let mut providers: HashMap<String, Vec<Arc<dyn Brain>>> = HashMap::new();
    providers.insert("local".into(), vec![Arc::new(brain)]);
    let router = Arc::new(BasicRouter::new(&config, providers));
    Engine::new(router, config)
}

async fn drive_and_collect(
    engine: Engine,
    task: &str,
) -> (sparrow::event::OutcomeSummary, Vec<Event>) {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let outcome = engine
        .drive(
            Task {
                description: task.into(),
                context: vec![],
            },
            tx,
        )
        .await
        .unwrap();
    let mut events = Vec::new();
    while let Some(event) = rx.recv().await {
        events.push(event);
    }
    (outcome, events)
}

#[tokio::test]
async fn anti_simulation_rejects_fabricated_result_claim_and_reasks() {
    let brain = ScriptedBrain::new(vec![
        vec![
            BrainEvent::TextDelta("All tests pass, build succeeds, no errors.".into()),
            BrainEvent::Done(StopReason::EndTurn),
        ],
        vec![
            BrainEvent::TextDelta(
                "I need to execute the relevant tool before reporting results.".into(),
            ),
            BrainEvent::Done(StopReason::EndTurn),
        ],
    ]);
    let calls = brain.clone();
    let engine = engine_for(brain);

    let (outcome, events) =
        drive_and_collect(engine, "run cargo test and report the real result").await;

    assert_eq!(outcome.status, "completed");
    assert_eq!(calls.call_count(), 2);
    assert!(events.iter().any(|event| matches!(
        event,
        Event::Message { role, text, .. }
            if role == "guard" && text.contains("anti-simulation")
    )));
}

#[tokio::test]
async fn checkpoint_is_created_before_mutating_edit_tool_runs() {
    let _guard = CURRENT_DIR_LOCK.lock().await;
    let workspace = temp_workspace("checkpoint-before-edit");
    init_git_repo(&workspace);
    std::fs::write(workspace.join("note.txt"), "original").unwrap();
    std::process::Command::new("git")
        .args(["add", "note.txt"])
        .current_dir(&workspace)
        .output()
        .unwrap();
    std::process::Command::new("git")
        .args(["commit", "-m", "init"])
        .current_dir(&workspace)
        .output()
        .unwrap();

    let previous_dir = std::env::current_dir().unwrap();
    std::env::set_current_dir(&workspace).unwrap();

    let brain = ScriptedBrain::new(vec![
        vec![
            BrainEvent::ToolUseStart {
                id: "edit-1".into(),
                name: "edit".into(),
            },
            BrainEvent::ToolUseDelta {
                id: "edit-1".into(),
                json: r#"{"path":"note.txt","old":"original","new":"changed"}"#.into(),
            },
            BrainEvent::ToolUseEnd {
                id: "edit-1".into(),
            },
            BrainEvent::Done(StopReason::ToolUse),
        ],
        vec![
            BrainEvent::TextDelta("Edited note.txt using the tool result.".into()),
            BrainEvent::Done(StopReason::EndTurn),
        ],
    ]);
    let engine = engine_for(brain);
    let (outcome, events) = drive_and_collect(engine, "edit note.txt and replace original").await;
    std::env::set_current_dir(previous_dir).unwrap();

    assert_eq!(outcome.status, "completed");
    assert_eq!(
        std::fs::read_to_string(workspace.join("note.txt")).unwrap(),
        "changed"
    );

    let checkpoint_index = events
        .iter()
        .position(|event| matches!(event, Event::CheckpointCreated { .. }))
        .expect("mutating edit should create a checkpoint");
    let tool_started_index = events
        .iter()
        .position(|event| matches!(event, Event::ToolUseStarted { id, .. } if id == "edit-1"))
        .expect("edit tool should start");
    assert!(checkpoint_index < tool_started_index);
    assert!(events.iter().any(|event| matches!(
        event,
        Event::Message { role, text, .. }
            if role == "autonomy" && text.contains("trusted autonomy")
    )));

    let _ = std::fs::remove_dir_all(workspace);
}