lernie 0.0.4

A git-backed agent harness
Documentation
//! Multi-step exchange-loop tests. Drives the loop through
//! [`StubToolExecutor`] to assert ยง2.5 pairing, per-step on-disk shape,
//! and the `Finish{!ToolUse}` termination rule over brazen `v=1` events.

use super::fixtures::*;
use crate::prompt::run;
use brazen::FinishReason;
use serde_json::{Value, json};

fn tool_use_stream(id: &str, name: &str, cmd_key: &str, cmd_val: &str) -> Vec<u8> {
    stream_of(
        FinishReason::ToolUse,
        &[Block::ToolUse {
            id,
            name,
            input: json!({ cmd_key: cmd_val }),
        }],
    )
}

fn final_stream() -> Vec<u8> {
    stream_of(FinishReason::Stop, &[Block::Text("done")])
}

fn last_line_type(bytes: &[u8]) -> String {
    let lines = parse_jsonl(bytes);
    lines.last().unwrap()["type"].as_str().unwrap().to_string()
}

fn finish_reason(bytes: &[u8]) -> String {
    parse_jsonl(bytes)
        .into_iter()
        .find(|e| e["type"] == "finish")
        .unwrap()["reason"]
        .as_str()
        .unwrap()
        .to_string()
}

#[test]
fn loop_runs_two_steps_when_first_completion_is_tool_use() {
    let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
    let harness = scaffold_harness_root();
    let r1 = tool_use_stream("toolu_01", "bash", "cmd", "ls");
    let r2 = final_stream();
    let adapter = StubAdapter::scripted([
        StubAdapter::reply_ok(&version_line()),
        StubAdapter::reply_ok(&r1),
        StubAdapter::reply_ok(&r2),
    ]);
    let git = StubGit::ok();
    let (clock, id) = (FixedClock::default(), FixedIdGen);
    let (sleeper, tool_executor) = (
        StubSleeper::default(),
        StubToolExecutor::with_reply("bash", "files: a b"),
    );

    let branch = run(
        repo.path(),
        "list files",
        None,
        None,
        None,
        crate::prompt::PinnedDocs::none(),
        &valid_deps(
            &adapter,
            &sleeper,
            &git,
            &clock,
            &id,
            &tool_executor,
            harness.path(),
        ),
    )
    .unwrap();
    assert_eq!(branch, "ct-1-deadbeef");
    let worktree = worktree_path(repo.path());

    // Executor saw one call in step 1 with the emitted tool_use.
    let tool_calls = tool_executor.invocations.borrow().clone();
    assert_eq!(tool_calls.len(), 1);
    let (step_dir, tid, name, input) = &tool_calls[0];
    assert_eq!(step_dir, &repo.path().join("steps/ct-1-deadbeef/001"));
    assert_eq!(
        (tid.as_str(), name.as_str(), &input["cmd"]),
        ("toolu_01", "bash", &json!("ls"))
    );

    assert!(!worktree.join("steps").exists());
    let step1_dir = repo.path().join("steps/ct-1-deadbeef/001");
    let step2_dir = repo.path().join("steps/ct-1-deadbeef/002");

    // Step 1 request: one user message, the front-door delivery of the
    // initial message (ยง2.11) โ€” its deposit frontmatter travels with the
    // body and is model-visible (`deposited_at` is the first clock tick).
    let req1: Value =
        serde_json::from_slice(&std::fs::read(step1_dir.join("request.json")).unwrap()).unwrap();
    assert_eq!(req1["messages"].as_array().unwrap().len(), 1);
    assert_eq!(
        req1["messages"][0]["content"][0]["text"],
        "---\nfrom: user\ndeposited_at: iso-1\n---\nlist files"
    );

    // Step 2 request: ยง2.5 pairing โ€” assistant tool_use + tool-side
    // tool_result (canonical `Role::Tool`, whose content is a canonical
    // `Content` array; the provider protocol projects the role, ยง2.3).
    let req2: Value =
        serde_json::from_slice(&std::fs::read(step2_dir.join("request.json")).unwrap()).unwrap();
    let msgs = req2["messages"].as_array().unwrap();
    assert_eq!(msgs.len(), 3);
    assert_eq!(msgs[1]["role"], "assistant");
    assert_eq!(msgs[1]["content"][0]["id"], "toolu_01");
    assert_eq!(msgs[2]["role"], "tool");
    assert_eq!(msgs[2]["content"][0]["tool_use_id"], "toolu_01");
    assert_eq!(msgs[2]["content"][0]["content"][0]["text"], "files: a b");

    assert!(worktree.join("goal.md").exists());
    // Step 2's response terminal is brazen's `end`; the finish reason
    // is `stop`.
    let resp2 = std::fs::read(step2_dir.join("response.json")).unwrap();
    assert_eq!(last_line_type(&resp2), "end");
    assert_eq!(finish_reason(&resp2), "stop");

    // Git op log: 8 (the start's preamble โ€” the fork-point lineage
    // query, ยง2.3; then control resolution, ยง2.2 โ€” the `config/*` head
    // enumeration and its merge-base, plus five `show` reads, `version`
    // first for the ยง10 schema-version guard, manifest.yaml before the
    // soul, ยง5.2) + 10 (step 1 setup: spawn, control rm, the descriptor
    // derivation's five ops โ€” four `cat-file -e` existence reads against
    // the governing config commit and one `checkout`, ยง3.3 โ€” the
    // settled-name stage (ยง2.3), add, commit) + 1 (step-1 drain
    // stray-probe) + 2 (user-message delivery add+commit) + 1 (step 1
    // rev-parse) + 2 (step-1 model-output transcript entry add+commit)
    // + 1 (the tool window's unconditional hold-mark probe, ยง3.3 *Tool
    // control*) + 2 (the tool transcript entry add+commit) + 1 (step-2
    // drain stray-probe) + 1 (step 2 rev-parse) + 2 (step-2 model-output
    // entry add+commit) + 1 (terminal result-deposit rev-parse, ยง2.6) +
    // 1 (its durable returned-mark `update-ref`, ยง8 โ€” this rig's compact
    // ids make the agent parse as a child, so the deposit is real) = 33.
    // Merge-back is gone (ยง2.6). The version guard runs no git.
    let runs = git.runs.borrow();
    assert_eq!(runs.len(), 33);
    assert_eq!(runs[15].1, vec!["add", "name"]);
    assert_eq!(runs[16].1, vec!["add", "goal.md", "soul.md"]);
    assert!(runs[17].1[2].contains("step 001: dispatch"));
    // Step-1 drain (ยง2.11): the clean stray-probe, then the initial user
    // message delivered from the inbox as the first transcript entry (001).
    assert_eq!(runs[18].1, vec!["status", "--porcelain", "--", "messages"]);
    assert_eq!(runs[19].1, vec!["add", "messages/001-user.md"]);
    assert!(runs[20].1[2].contains("transcript 001: user"));
    assert_eq!(runs[21].1, vec!["rev-parse", "HEAD"]);
    // Step 1's transcript: model-output entry (002), then the tool result
    // entry (003) โ€” the ยง2.3 ordering (model output before its tool
    // results). Counters are max-present-plus-one from the messages/
    // listing, so they never collide with the step number. The model
    // output's origin token is the authoring model id (ยง2.3).
    assert_eq!(runs[22].1, vec!["add", "messages/002-claude-sonnet-5.json"]);
    assert!(runs[23].1[2].contains("transcript 002: claude-sonnet-5"));
    // The tool window opens with the unconditional hold-mark probe
    // (ยง3.3 *Tool control* โ€” the mark, not the config, asserts a park).
    assert_eq!(runs[24].1[..2], ["cat-file", "blob"]);
    // A tool commit stages the whole worktree (`git add -A`, ยง2.3) so any
    // worktree side effect the tool produced lands with its result entry.
    assert_eq!(runs[25].1, vec!["add", "-A"]);
    assert!(runs[26].1[2].contains("transcript 003: tool"));
    // Step 2 opens with its own boundary drain (empty inbox โ†’ stray-probe
    // only), then the branch-tip capture (advanced by step 1's transcript
    // commits), then commits its own model-output entry (004).
    assert_eq!(runs[27].1, vec!["status", "--porcelain", "--", "messages"]);
    assert_eq!(runs[28].1, vec!["rev-parse", "HEAD"]);
    assert_eq!(runs[29].1, vec!["add", "messages/004-claude-sonnet-5.json"]);
    assert!(runs[30].1[2].contains("transcript 004: claude-sonnet-5"));
    // The terminal result deposit reads the branch tip (ยง2.6); no
    // merge-back follows.
    assert_eq!(runs[31].1, vec!["rev-parse", "HEAD"]);

    // The tool entry on disk is the canonical tool_result block.
    let tool_entry = worktree.join("messages/003-tool.json");
    let blocks: Value = serde_json::from_slice(&std::fs::read(&tool_entry).unwrap()).unwrap();
    assert_eq!(blocks[0]["type"], "tool_result");
    assert_eq!(blocks[0]["tool_use_id"], "toolu_01");
    assert_eq!(blocks[0]["content"][0]["text"], "files: a b");
}

#[test]
fn loop_runs_three_steps_when_two_completions_in_a_row_are_tool_use() {
    let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
    let harness = scaffold_harness_root();
    let r1 = tool_use_stream("toolu_01", "bash", "cmd", "ls");
    let r2 = tool_use_stream("toolu_02", "bash", "cmd", "pwd");
    let r3 = final_stream();
    let adapter = StubAdapter::scripted([
        StubAdapter::reply_ok(&version_line()),
        StubAdapter::reply_ok(&r1),
        StubAdapter::reply_ok(&r2),
        StubAdapter::reply_ok(&r3),
    ]);
    let git = StubGit::ok();
    let (clock, id) = (FixedClock::default(), FixedIdGen);
    let (sleeper, tool_executor) = (StubSleeper::default(), StubToolExecutor::ok());

    run(
        repo.path(),
        "go",
        None,
        None,
        None,
        crate::prompt::PinnedDocs::none(),
        &valid_deps(
            &adapter,
            &sleeper,
            &git,
            &clock,
            &id,
            &tool_executor,
            harness.path(),
        ),
    )
    .unwrap();

    let step3_resp = repo.path().join("steps/ct-1-deadbeef/003/response.json");
    assert!(step3_resp.exists());
    assert!(!repo.path().join("steps/ct-1-deadbeef/004").exists());

    let invocations = tool_executor.invocations.borrow().clone();
    assert_eq!(invocations.len(), 2);
    assert!(invocations[0].0.ends_with("steps/ct-1-deadbeef/001"));
    assert!(invocations[1].0.ends_with("steps/ct-1-deadbeef/002"));
    assert_eq!(invocations[0].1, "toolu_01");
    assert_eq!(invocations[1].1, "toolu_02");
}

#[test]
fn loop_runs_each_tool_use_block_in_one_step_in_emission_order() {
    let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
    let harness = scaffold_harness_root();
    let two_tool_use = stream_of(
        FinishReason::ToolUse,
        &[
            Block::ToolUse {
                id: "toolu_a",
                name: "bash",
                input: json!({"cmd": "ls"}),
            },
            Block::ToolUse {
                id: "toolu_b",
                name: "read_file",
                input: json!({"path": "/x"}),
            },
        ],
    );
    let adapter = StubAdapter::scripted([
        StubAdapter::reply_ok(&version_line()),
        StubAdapter::reply_ok(&two_tool_use),
        StubAdapter::reply_ok(&final_stream()),
    ]);
    let git = StubGit::ok();
    let (clock, id) = (FixedClock::default(), FixedIdGen);
    let (sleeper, tool_executor) = (StubSleeper::default(), StubToolExecutor::ok());

    run(
        repo.path(),
        "do two things",
        None,
        None,
        None,
        crate::prompt::PinnedDocs::none(),
        &valid_deps(
            &adapter,
            &sleeper,
            &git,
            &clock,
            &id,
            &tool_executor,
            harness.path(),
        ),
    )
    .unwrap();

    let invocations = tool_executor.invocations.borrow().clone();
    assert_eq!(invocations.len(), 2);
    let pair = |c: &(_, String, String, _)| (c.1.clone(), c.2.clone());
    assert_eq!(pair(&invocations[0]), ("toolu_a".into(), "bash".into()));
    assert_eq!(
        pair(&invocations[1]),
        ("toolu_b".into(), "read_file".into())
    );

    let req2: Value = serde_json::from_slice(
        &std::fs::read(repo.path().join("steps/ct-1-deadbeef/002/request.json")).unwrap(),
    )
    .unwrap();
    let user_blocks = req2["messages"][2]["content"].as_array().unwrap();
    assert_eq!(user_blocks.len(), 2);
    assert_eq!(user_blocks[0]["tool_use_id"], "toolu_a");
    assert_eq!(user_blocks[1]["tool_use_id"], "toolu_b");
}

// Loop-termination cases (tool-executor failure) live in
// [`super::multi_step_terminal`].