quvyta-cli 0.1.0-alpha.5

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
//! The chat against a conversation, a provider or a terminal that is not what it expects: what a
//! person must still find open, drawn and usable afterwards.

use serde_json::json;

use super::tests::{Place, harness, read_card, say};
use super::*;
use crate::provider::fake::{FakeServer, calls, says, sse};

/// A saved conversation `text` for the folder of `place`, written the way qcli writes one.
fn save_conversation(place: &Place, text: &str) {
    let file = place.store().file().to_path_buf();
    std::fs::create_dir_all(file.parent().expect("a parent")).expect("the conversations folder");
    std::fs::write(file, text).expect("write the conversation");
}

/// Every file beside the conversation of `place`, the conversation itself left out.
fn beside(place: &Place) -> Vec<std::path::PathBuf> {
    let file = place.store().file().to_path_buf();
    let folder = file.parent().expect("a parent");
    std::fs::read_dir(folder)
        .expect("the conversations folder")
        .map(|entry| entry.expect("an entry").path())
        .filter(|path| path != &file)
        .collect()
}

/// The result the conversation holds for the call `id`: its sentence and whether it failed.
fn result_of(app: &QCli, id: &str) -> (String, bool) {
    app.conversation()
        .messages()
        .iter()
        .flat_map(conversation::blocks)
        .find(|block| block["type"] == "tool_result" && block["tool_use_id"] == id)
        .map(|block| (block["content"].as_str().unwrap_or_default().to_owned(), block["is_error"] == true))
        .unwrap_or_else(|| panic!("no result for the call {id}"))
}

#[test]
fn a_conversation_file_that_cannot_be_read_is_kept_and_the_new_thread_saved_in_its_place() {
    let place = Place::new("hostile-unreadable-thread");
    // What a person finds after a qcli that wrote a shape this one cannot read, or a disk that
    // gave half a file: bytes that are a file and are nobody's thread as far as this program
    // knows. The thread in them must not be lost by the first thing the person writes.
    let theirs = b"{\"messages\": [{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"yesterday\"}]}";
    save_conversation(&place, &String::from_utf8_lossy(theirs));

    let server = FakeServer::start(vec![(200, says("Here is the answer."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    assert!(harness.app().conversation().is_empty(), "nothing of it can be shown");
    assert!(harness.screen().contains("Ask anything about this folder"), "{}", harness.screen());

    say(&mut harness, "hello again");
    assert_eq!(harness.app().phase(), &Phase::Idle);
    assert!(harness.screen().contains("Here is the answer."), "{}", harness.screen());

    // The new thread is where the conversation belongs, and the file that was there is beside it.
    let kept = beside(&place);
    assert_eq!(kept.len(), 1, "the file that could not be read is kept, not written over: {kept:?}");
    assert_eq!(std::fs::read(&kept[0]).expect("read"), theirs, "the very same bytes are still there");
    let saved = std::fs::read_to_string(place.store().file()).expect("the conversation is saved");
    assert!(saved.contains("hello again") && saved.contains("Here is the answer."), "{saved}");
}

#[test]
fn a_conversation_of_a_hundred_thousand_characters_in_one_line_is_drawn_and_the_person_can_write() {
    let place = Place::new("hostile-long-line");
    let long = "z".repeat(100_000);
    save_conversation(
        &place,
        &json!({"version": 1, "messages": [{"role": "user", "content": [{"type": "text", "text": long}]}]}).to_string(),
    );
    let server = FakeServer::start(vec![(200, says("Short enough."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    assert!(
        harness.screen().contains("zzzz"),
        "the line is on screen, as much of it as fits: {}",
        &harness.screen()[..200.min(harness.screen().len())]
    );
    assert!(harness.screen().contains("Write a message"), "the writing field is there: {}", harness.screen());

    say(&mut harness, "and now?");
    assert!(harness.screen().contains("Short enough."), "{}", harness.screen());
    assert_eq!(harness.app().phase(), &Phase::Idle);
}

#[test]
fn a_diff_of_ten_thousand_lines_on_the_card_is_shown_and_the_person_can_still_say_no() {
    let place = Place::new("hostile-big-diff");
    let body: String = (1..=10_000).map(|number| format!("line {number}\n")).collect();
    let file = place.work().join("long.txt");
    std::fs::write(&file, &body).expect("write");
    let server = FakeServer::start(vec![
        (200, calls("d1", "edit_file", &json!({"path": "long.txt", "old_text": body, "new_text": "one line\n"}))),
        (200, says("Left it alone.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "shorten the file");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card waits for an answer");
    // Ten thousand lines are more than a screen holds, so the card is as long as the diff and
    // its end is what is in view, with the two buttons under it.
    assert!(harness.screen().contains("+ one line"), "the change is in view: {}", harness.screen());
    assert!(harness.screen().contains("Allow") && harness.screen().contains("Decline"), "{}", harness.screen());

    read_card(&mut harness);
    harness.click_text("Decline");
    assert_eq!(std::fs::read_to_string(&file).expect("read"), body, "the file is as it was");
    assert!(harness.screen().contains("Left it alone."), "{}", harness.screen());
}

#[test]
fn a_path_of_five_thousand_characters_is_reported_whole_and_the_person_can_write_again() {
    let place = Place::new("hostile-long-path");
    let path = "p".repeat(5000);
    let server = FakeServer::start(vec![
        (200, calls("p1", "read_file", &json!({"path": path}))),
        (200, says("That is not a file I can read.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "read a very long name");

    let (result, failed) = result_of(harness.app(), "p1");
    assert!(failed, "a path no file system will take is not read: {result}");
    assert!(result.contains(&path), "the path it was given is named whole: {}…", &result[..80]);
    assert!(harness.screen().contains("That is not a file I can read."), "{}", harness.screen());
    assert_eq!(harness.app().phase(), &Phase::Idle);
}

#[test]
fn a_reply_of_five_hundred_calls_is_all_taken_and_every_call_is_answered() {
    let place = Place::new("hostile-many-calls");
    std::fs::write(place.work().join("notes.txt"), "the word is here\n").expect("write");
    let events: Vec<_> = (0..500)
        .flat_map(|number| {
            [
                json!({"type": "content_block_start", "index": number, "content_block": {"type": "tool_use", "id": format!("c{number}"), "name": "read_file", "input": {}}}),
                json!({"type": "content_block_delta", "index": number, "delta": {"type": "input_json_delta", "partial_json": "{\"path\": \"notes.txt\"}"}}),
                json!({"type": "content_block_stop", "index": number}),
            ]
        })
        .chain([json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}), json!({"type": "message_stop"})])
        .collect();
    let server = FakeServer::start(vec![(200, sse(&events)), (200, says("All five hundred read."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "read it five hundred times");

    assert!(harness.screen().contains("All five hundred read."), "{}", harness.screen());
    let results: Vec<_> = harness
        .app()
        .conversation()
        .messages()
        .iter()
        .flat_map(conversation::blocks)
        .filter(|block| block["type"] == "tool_result")
        .collect();
    assert_eq!(results.len(), 500, "every call of the reply has a result");
    assert_eq!(results[499]["tool_use_id"], "c499");
    assert_eq!(results[0]["content"].as_str().map(|text| text.contains("the word is here")), Some(true));
}

#[test]
fn a_call_with_no_name_and_a_stop_reason_of_nothing_known_end_the_turn_with_a_sentence() {
    let place = Place::new("hostile-odd-reply");
    let events = vec![
        json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "x"}}),
        json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"path\": \"notes.txt\"}"}}),
        json!({"type": "content_block_stop", "index": 0}),
        json!({"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}),
        json!({"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "I looked."}}),
        json!({"type": "content_block_stop", "index": 1}),
        json!({"type": "message_delta", "delta": {"stop_reason": "something_new"}}),
        json!({"type": "message_stop"}),
    ];
    let server = FakeServer::start(vec![(200, sse(&events)), (200, says("Fine."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "look around");

    // The call names no tool, so it is not done; the reply ends in a way qcli does not know, so
    // the call is answered rather than left waiting, which the next request would be refused for.
    let (sentence, failed) = result_of(harness.app(), "x");
    assert!(failed, "a call that names no tool is not done");
    assert!(sentence.contains("no tool named"), "the model is told why: {sentence}");
    assert!(harness.screen().contains("I looked."), "the rest of the reply is still shown: {}", harness.screen());
    assert_eq!(harness.app().phase(), &Phase::Idle, "a stop reason of nothing known ends the turn");

    say(&mut harness, "go on");
    assert!(harness.screen().contains("Fine."), "{}", harness.screen());
    assert_eq!(server.received().len(), 2, "the next request went out and was answered");
}

#[test]
fn a_text_delta_of_a_megabyte_arrives_whole_and_the_thread_goes_on() {
    let place = Place::new("hostile-megabyte-reply");
    let long = "w ".repeat(512 * 1024);
    let answer = sse(&[
        json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
        json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": long}}),
        json!({"type": "content_block_stop", "index": 0}),
        json!({"type": "message_delta", "delta": {"stop_reason": "end_turn"}}),
        json!({"type": "message_stop"}),
    ]);
    let server = FakeServer::start(vec![(200, answer), (200, says("And a little more."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "write a lot");
    assert_eq!(harness.app().phase(), &Phase::Idle);
    let said = harness.app().conversation().messages()[1]["content"][0]["text"].as_str().expect("the answer");
    assert_eq!(said.len(), long.len(), "every character of the delta is kept");
    assert!(harness.screen().contains("Write a message"), "the writing field is under it: {}", harness.screen());

    say(&mut harness, "and a little?");
    assert!(harness.screen().contains("And a little more."), "{}", harness.screen());
}

#[test]
fn a_reply_cut_in_the_middle_of_a_line_says_so_and_the_person_can_write_again() {
    let place = Place::new("hostile-cut-reply");
    let whole = sse(&[
        json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
        json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Half an ans"}}),
    ]);
    let server = FakeServer::start(vec![(200, format!("{whole}data: {{\"type\": \"mess")), (200, says("Back again."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "answer me");

    assert!(
        matches!(harness.app().notice(), Some(Notice::Provider(said)) if said.contains("ended before the message was complete")),
        "{:?}",
        harness.app().notice()
    );
    assert!(harness.screen().contains("The provider did not answer"), "{}", harness.screen());
    assert_eq!(harness.app().phase(), &Phase::Idle);
    say(&mut harness, "try again");
    assert!(harness.screen().contains("Back again."), "{}", harness.screen());
}

#[test]
fn a_settings_file_that_is_a_folder_opens_the_form_and_writes_nothing() {
    let place = Place::new("hostile-settings-folder");
    let conf = place.base().join(format!("config/{}", config::APP));
    std::fs::create_dir_all(&conf).expect("the settings file is a folder");
    let mut harness = harness(place.app(None));
    assert!(harness.screen().contains("Provider"), "{}", harness.screen());
    assert!(harness.is_focused(form::FIRST));

    harness.click_text("Cancel");
    assert!(!harness.screen().contains("Provider"), "the form closes and the chat is there: {}", harness.screen());
    assert!(conf.is_dir(), "the folder that stands where the settings file was is still a folder");
    assert!(std::fs::read_dir(&conf).expect("read").next().is_none(), "and nothing was written into it");
}

#[test]
fn a_key_of_a_megabyte_in_one_line_is_refused_by_the_transport_and_the_person_is_told() {
    let place = Place::new("hostile-megabyte-key");
    let key = "k".repeat(1024 * 1024);
    std::fs::write(place.base().join("key"), &key).expect("write the key file");
    let server = FakeServer::start(vec![(200, says("Hello."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "hello");

    // The key file does hold a key — the file reading test says so — but a request cannot carry
    // a header this long, so the transport refuses and the person is told in its own words.
    assert!(matches!(harness.app().notice(), Some(Notice::Provider(_))), "{:?}", harness.app().notice());
    assert!(harness.screen().contains("The provider did not answer"), "{}", harness.screen());
    assert!(!harness.screen().contains("kkkk"), "and the key is nowhere on the screen");
    assert_eq!(harness.app().phase(), &Phase::Idle);
    assert!(server.received().is_empty(), "nothing reached the server, so nothing came back from it");
    say(&mut harness, "are you there");
    assert!(server.received().is_empty(), "the thread is still the person's to write on");
    assert!(harness.screen().contains("The provider did not answer"), "{}", harness.screen());
}

#[test]
fn a_file_that_goes_between_the_card_and_the_allow_is_not_written() {
    let place = Place::new("hostile-vanished");
    let file = place.work().join("target.txt");
    std::fs::write(&file, "before\n").expect("write");
    let server = FakeServer::start(vec![
        (200, calls("v1", "edit_file", &json!({"path": "target.txt", "old_text": "before", "new_text": "after"}))),
        (200, says("Understood.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "change it");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card is waiting");

    // The file goes while the card is on the screen and before the person allows it.
    std::fs::remove_file(&file).expect("the file is gone");
    read_card(&mut harness);
    harness.press("y");

    assert!(!file.exists(), "an allowed edit does not make the file that went away");
    let (sentence, failed) = result_of(harness.app(), "v1");
    assert!(failed, "the model is told it was not done: {sentence}");
    assert!(sentence.contains("does not exist"), "{sentence}");
    assert!(harness.screen().contains("Understood."), "{}", harness.screen());
}

#[test]
fn a_terminal_of_one_column_and_a_resize_to_one_by_one_keep_the_chat_usable() {
    let place = Place::new("hostile-tiny-terminal");
    std::fs::write(place.work().join("target.txt"), "before\n").expect("write");
    let server = FakeServer::start(vec![
        (200, calls("t1", "edit_file", &json!({"path": "target.txt", "old_text": "before", "new_text": "after"}))),
        (200, says("Done.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    harness.resize(1, 40);
    say(&mut harness, "change it");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card waits in one column");

    // The terminal shrinks to nothing at all while the card is waiting.
    harness.resize(1, 1);
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card is still waiting");
    harness.resize(100, 40);
    read_card(&mut harness);
    harness.press("y");
    assert_eq!(std::fs::read_to_string(place.work().join("target.txt")).expect("read"), "after\n");
    assert!(harness.screen().contains("Done."), "{}", harness.screen());
    say(&mut harness, "and again");
    assert_eq!(harness.app().phase(), &Phase::Idle);
}

#[test]
fn a_terminal_with_no_cells_at_all_is_not_a_crash() {
    let place = Place::new("hostile-no-screen");
    let mut harness = harness(place.app(None));
    harness.resize(0, 0);
    assert!(harness.screen().is_empty(), "no cells, no rows: {:?}", harness.screen());
    harness.resize(1, 1);
    assert_eq!(harness.screen().lines().count(), 1, "one cell, one row: {:?}", harness.screen());
    harness.resize(100, 40);
    assert!(harness.screen().contains("qcli"), "the chat is back: {}", harness.screen());
}

#[test]
fn a_working_folder_that_is_gone_while_the_app_is_open_answers_every_call_with_a_sentence() {
    let place = Place::new("hostile-folder-gone");
    std::fs::write(place.work().join("notes.txt"), "the word is here\n").expect("write");
    let reply = sse(&[
        json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "g1", "name": "read_file", "input": {}}}),
        json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"path\": \"notes.txt\"}"}}),
        json!({"type": "content_block_stop", "index": 0}),
        json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
        json!({"type": "message_stop"}),
    ]);
    let server = FakeServer::start(vec![(200, reply), (200, says("There is no folder to read."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    let root = place.work();
    std::fs::remove_dir_all(&root).expect("the working folder is gone");

    say(&mut harness, "read the notes");
    let (sentence, failed) = result_of(harness.app(), "g1");
    assert!(failed, "nothing could be read out of a folder that is not there");
    assert!(
        sentence.contains("outside the working folder") || sentence.contains("does not exist"),
        "the model is told the folder is not there: {sentence}"
    );
    assert!(harness.screen().contains("There is no folder to read."), "{}", harness.screen());
    assert_eq!(harness.app().phase(), &Phase::Idle);
    assert!(!root.exists(), "nothing was made where the folder was");
}

#[test]
fn a_file_changed_between_the_card_and_the_allow_is_not_written_over_what_was_shown() {
    let place = Place::new("hostile-changed");
    let file = place.work().join("target.txt");
    std::fs::write(&file, "first\nhello world\nlast\n").expect("write");
    let server = FakeServer::start(vec![
        (200, calls("c1", "edit_file", &json!({"path": "target.txt", "old_text": "world", "new_text": "moon"}))),
        (200, says("I will read it again.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "change it");
    assert!(harness.screen().contains("last"), "the card shows the lines around the change: {}", harness.screen());

    // Something else changes the lines the card showed, while it waits.
    std::fs::write(&file, "first\nhello world\nsomebody else's line\n").expect("changed elsewhere");
    read_card(&mut harness);
    harness.press("y");

    assert_eq!(
        std::fs::read_to_string(&file).expect("read"),
        "first\nhello world\nsomebody else's line\n",
        "what the person allowed is not what would be written, so nothing is"
    );
    let (sentence, failed) = result_of(harness.app(), "c1");
    assert!(failed && sentence.contains("changed after"), "the model is told why: {sentence}");
}