~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
3228 lines · 135.5 KB Raw History
//! The MCP server against a reference PDS, driven by an MCP client.

#[path = "../../knotless/tests/common/mod.rs"]
mod common;
use common::*;

use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use serde_json::{Value, json};

struct Client {
    rt: tokio::runtime::Runtime,
    peer: rmcp::service::RunningService<rmcp::RoleClient, ()>,
}

impl Client {
    fn new(s: &Sandbox) -> Client {
        Client::knowing(s, &Default::default())
    }

    /// A client of a server told what `known` says of versions.
    fn knowing(s: &Sandbox, known: &knotless::update::Known) -> Client {
        Client::of(knotless_mcp::Server::new(ctx(s)).knowing(known))
    }

    /// A client of `server`.
    fn of(server: knotless_mcp::Server) -> Client {
        let rt = tokio::runtime::Runtime::new().unwrap();
        let peer = rt.block_on(async {
            let (server_io, client_io) = tokio::io::duplex(1 << 20);
            tokio::spawn(async move {
                let running = server.serve(server_io).await.unwrap();
                let _ = running.waiting().await;
            });
            ().serve(client_io).await.unwrap()
        });
        Client { rt, peer }
    }

    /// The tool's text, and whether it was an error.
    fn call(&self, tool: &str, args: Value) -> (String, bool) {
        let params = CallToolRequestParams::new(tool.to_string())
            .with_arguments(args.as_object().unwrap().clone());
        let res = self.rt.block_on(self.peer.call_tool(params)).unwrap();
        let text = res.content[0].as_text().unwrap().text.clone();
        (text, res.is_error == Some(true))
    }

    /// Each of the tool's texts, when it answers more than one.
    fn texts(&self, tool: &str, args: Value) -> Vec<String> {
        let params = CallToolRequestParams::new(tool.to_string())
            .with_arguments(args.as_object().unwrap().clone());
        let res = self.rt.block_on(self.peer.call_tool(params)).unwrap();
        assert_ne!(res.is_error, Some(true), "{tool}: {:?}", res.content);
        let texts = res
            .content
            .iter()
            .map(|c| c.as_text().unwrap().text.clone());
        texts.collect()
    }

    fn ok(&self, tool: &str, args: Value) -> String {
        let (text, err) = self.call(tool, args);
        assert!(!err, "{tool}: {text}");
        text
    }

    fn json(&self, tool: &str, args: Value) -> Value {
        serde_json::from_str(&self.ok(tool, args)).unwrap()
    }

    /// The resource at `uri`, its one content as JSON (`mimeType`, and
    /// `text` or `blob`), or the error it is answered with.
    fn read(&self, uri: &str) -> Result<Value, rmcp::model::ErrorData> {
        let params = rmcp::model::ReadResourceRequestParams::new(uri);
        match self.rt.block_on(self.peer.read_resource(params)) {
            Ok(read) => {
                assert_eq!(read.contents.len(), 1, "{read:?}");
                Ok(serde_json::to_value(&read.contents[0]).unwrap())
            }
            Err(rmcp::ServiceError::McpError(err)) => Err(err),
            Err(err) => panic!("{uri}: {err}"),
        }
    }
}

/// Logged in as its account, as `knotless login` leaves a machine.
fn logged_in() -> Sandbox {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    let password = s.account.password.clone();
    let handle = s.account.handle.clone();
    assert_eq!(
        s.cli(&["login", &handle, "--password-stdin"], password.as_bytes())
            .0,
        0
    );
    s
}

#[test]
fn an_agent_works_on_a_repository() {
    let s = logged_in();
    let c = Client::new(&s);
    let tools = c.rt.block_on(c.peer.list_all_tools()).unwrap();
    let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
    for tool in [
        "repo_read",
        "repo_commit",
        "repo_search",
        "repo_create",
        "repo_share",
    ] {
        assert!(names.contains(&tool), "{names:?}");
    }

    let created = c.json(
        "repo_create",
        json!({"name": "agentic", "description": "made by a tool"}),
    );
    let repo = created["repo"].as_str().unwrap().to_string();
    assert_eq!(repo, format!("{}/agentic", s.account.handle));
    let (text, err) = c.call("repo_create", json!({"name": "agentic"}));
    assert!(err && text.contains("already exists"), "{text}");

    let first = c.json(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "start", "changes": [
            {"path": "README.md", "content": "# agentic\n\nhello world\n"},
            {"path": "src/main.rs", "content": "fn main() {}\n"},
            {"path": "bin/run", "content": "#!/bin/sh\n", "executable": true},
            {"path": "logo.bin", "base64": "AAEC/w=="},
        ]}),
    );
    assert_eq!(first["changed"], true);
    let second = c.json(
        "repo_commit",
        json!({"repo": format!("knotless://{repo}"), "branch": "main", "message": "tidy", "changes": [
            {"path": "bin/run", "delete": true},
            {"path": "README.md", "content": "# agentic\n\nhello there\n"},
        ], "base": first["sha"]}),
    );
    let (text, err) = c.call(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "content": "x", "delete": true}]}),
    );
    assert!(err && text.contains("exactly one"), "{text}");
    let (text, err) = c.call(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "base64": "!!"}]}),
    );
    assert!(err, "{text}");

    let refs = c.json("repo_refs", json!({"repo": repo}));
    assert_eq!(refs["refs"][0]["sha"], second["sha"]);
    assert_eq!(refs["private"], false);
    // Two commits, two packs, both small enough to ride inline.
    let storage = c.json("repo_storage", json!({"repo": repo}));
    assert_eq!(storage["packs"], 2);
    assert!(storage["bytes"].as_u64().unwrap() > 0, "{storage}");
    assert_eq!(storage["blobBytes"], 0);
    let tree = c.json("repo_tree", json!({"repo": repo}));
    let names: Vec<&str> = tree
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["name"].as_str().unwrap())
        .collect();
    assert_eq!(names, ["README.md", "logo.bin", "src"]);
    let sub = c.json(
        "repo_tree",
        json!({"repo": repo, "path": "src", "rev": first["sha"]}),
    );
    assert_eq!(sub[0]["name"], "main.rs");
    assert_eq!(
        c.ok("repo_read", json!({"repo": repo, "path": "README.md"})),
        "# agentic\n\nhello there\n"
    );
    assert_eq!(
        c.json("repo_read", json!({"repo": repo, "path": "logo.bin"}))["base64"],
        "AAEC/w=="
    );
    let hits = c.json("repo_search", json!({"repo": repo, "pattern": "HELLO"}));
    assert_eq!(hits[0]["path"], "README.md");
    assert_eq!(hits[0]["line"], 3);
    // Who last changed each run of lines: the line tidied since, after two
    // from the start.
    let blamed = c.json("repo_blame", json!({"repo": repo, "path": "README.md"}));
    assert_eq!(
        (&blamed[0]["sha"], &blamed[0]["count"]),
        (&first["sha"], &json!(2))
    );
    assert_eq!(
        (&blamed[1]["start"], &blamed[1]["summary"]),
        (&json!(3), &json!("tidy"))
    );
    assert_eq!(blamed[1]["previous"]["sha"], first["sha"]);
    let log = c.json("repo_log", json!({"repo": repo, "limit": 5}));
    assert_eq!(log.as_array().unwrap().len(), 2);
    assert_eq!(log[0]["message"], "tidy");
    let touched = c.json("repo_log", json!({"repo": repo, "path": "src"}));
    assert_eq!(touched.as_array().unwrap().len(), 1);
    let diff = c.ok(
        "repo_diff",
        json!({"repo": repo, "base": first["sha"], "head": "main"}),
    );
    assert!(
        diff.contains("-hello world") && diff.contains("+hello there"),
        "{diff}"
    );
    // Nothing changed says so, rather than nothing at all.
    let same = c.ok(
        "repo_diff",
        json!({"repo": repo, "base": "main", "head": "main"}),
    );
    assert_eq!(same, "No differences: main changes nothing since main.");

    // A new branch starts from the default branch, or from `base`, and
    // its commits are the committer's.
    let feature = c.json("repo_commit", json!({"repo": repo, "branch": "feature", "message": "f", "changes": [{"path": "f", "content": "f\n"}]}));
    assert_eq!(feature["parent"], second["sha"]);
    let fix = c.json("repo_commit", json!({"repo": repo, "branch": "fix", "message": "x", "changes": [{"path": "x", "content": "x\n"}], "base": first["sha"]}));
    assert_eq!(fix["parent"], first["sha"]);
    let log = c.json(
        "repo_log",
        json!({"repo": repo, "rev": "feature", "limit": 1}),
    );
    assert_eq!(log[0]["author"]["name"], s.account.handle.as_str());

    // Branches are made and deleted without a commit.
    let develop = c.json("branch_create", json!({"repo": repo, "name": "develop"}));
    assert_eq!(develop["sha"], second["sha"]);
    let old = c.json(
        "branch_create",
        json!({"repo": repo, "name": "old", "from": first["sha"]}),
    );
    assert_eq!(old["sha"], first["sha"]);
    c.json("branch_delete", json!({"repo": repo, "name": "old"}));
    c.json(
        "branch_create",
        json!({"repo": repo, "name": "gone", "from": "main"}),
    );
    c.json("branch_delete", json!({"repo": repo, "name": "gone"}));
    // A deleted branch can be made again.
    c.json("branch_create", json!({"repo": repo, "name": "gone"}));
    c.json("branch_delete", json!({"repo": repo, "name": "gone"}));
    let refs_now = c.json("repo_refs", json!({"repo": repo}))["refs"].to_string();
    assert!(
        refs_now.contains("develop") && !refs_now.contains("refs/heads/old"),
        "{refs_now}"
    );
    for (tool, args, why) in [
        (
            "branch_create",
            json!({"repo": repo, "name": "develop"}),
            "already has",
        ),
        (
            "branch_create",
            json!({"repo": repo, "name": "y", "from": "nope"}),
            "no commit or branch",
        ),
        (
            "branch_delete",
            json!({"repo": repo, "name": "old"}),
            "has no branch",
        ),
        (
            "branch_delete",
            json!({"repo": repo, "name": "main"}),
            "default branch",
        ),
    ] {
        let (text, err) = c.call(tool, args);
        assert!(err && text.contains(why), "{tool}: {text}");
    }

    // Tags are made and deleted as branches are, and never twice.
    let v1 = c.json("tag_create", json!({"repo": repo, "name": "v1"}));
    assert_eq!(v1["sha"], second["sha"]);
    let v0 = c.json(
        "tag_create",
        json!({"repo": repo, "name": "v0", "from": first["sha"]}),
    );
    assert_eq!(v0["sha"], first["sha"]);
    let refs_now = c.json("repo_refs", json!({"repo": repo}))["refs"].to_string();
    assert!(refs_now.contains("refs/tags/v1"), "{refs_now}");
    c.json("tag_delete", json!({"repo": repo, "name": "v0"}));
    for (tool, args, why) in [
        (
            "tag_create",
            json!({"repo": repo, "name": "v1"}),
            "already has a tag",
        ),
        (
            "tag_delete",
            json!({"repo": repo, "name": "v0"}),
            "has no tag",
        ),
    ] {
        let (text, err) = c.call(tool, args);
        assert!(err && text.contains(why), "{tool}: {text}");
    }

    let repos = c.json("repo_list", json!({"handle": s.account.handle}));
    assert_eq!(repos[0]["name"], "agentic");
    assert_eq!(repos[0]["description"], "made by a tool");
    let members = c.json("repo_members", json!({"repo": repo}));
    assert_eq!(members[0]["access"], "owner");
    let protect = |extra: Value| {
        let mut args = json!({"repo": repo, "branch": "main"});
        args.as_object_mut()
            .unwrap()
            .extend(extra.as_object().unwrap().clone());
        c.json("branch_protect", args)
    };
    let rule = |history| json!([{"branch": "main", "history": history}]);
    assert_eq!(protect(json!({})), rule("linear"));
    assert_eq!(protect(json!({"merges": true})), rule("merges"));
    assert_eq!(protect(json!({"undo": true})), json!([]));
    // What its runners report: here the owner runs its own. Naming none,
    // it has nothing to show or to ask.
    for (tool, args) in [
        ("run_list", json!({"repo": repo})),
        ("run_rerun", json!({"repo": repo, "ref": "refs/heads/main"})),
    ] {
        let (text, err) = c.call(tool, args);
        assert!(err && text.contains("names no runners"), "{tool}: {text}");
    }
    assert_eq!(c.json("runner_list", json!({"repo": repo})), json!([]));
    let handle = s.account.handle.clone();
    let named = c.json("runner_add", json!({"repo": repo, "handle": handle}));
    assert_eq!(named, json!([{"did": s.account.did, "handle": handle}]));
    let named = c.json("runner_remove", json!({"repo": repo, "handle": handle}));
    assert_eq!(named, json!([]));
    // Agents likewise.
    assert_eq!(c.json("agent_list", json!({"repo": repo})), json!([]));
    let named = c.json("agent_add", json!({"repo": repo, "handle": handle}));
    assert_eq!(named, json!([{"did": s.account.did, "handle": handle}]));
    let named = c.json("agent_remove", json!({"repo": repo, "handle": handle}));
    assert_eq!(named, json!([]));
    c.json("runner_add", json!({"repo": repo, "handle": handle}));
    // Its minutes: none counted, then what the runner filed for its owner.
    let counted = c.json("runner_minutes", json!({"repo": repo}));
    let none = json!([{"runner": s.account.did, "handle": handle, "counted": null}]);
    assert_eq!(counted, none);
    let minutes = json!({"$type": "dev.knotless.minutes", "minutes": 12, "allowance": 2000, "updatedAt": "2026-10-25T00:00:00Z"});
    s.put_record("dev.knotless.minutes", &s.account.did, minutes);
    let counted = c.json("runner_minutes", json!({"repo": repo}));
    assert_eq!(counted[0]["counted"]["minutes"], 12, "{counted}");
    assert_eq!(counted[0]["counted"]["allowance"], 2000, "{counted}");
    let key = s.record("dev.knotless.repo", "agentic")["key"]
        .as_str()
        .unwrap()
        .to_string();
    let commit = "a".repeat(40);
    let rkey =
        knotless::keys::check_rkey(&s.account.did, &key, "refs/heads/main", &commit, None, "ci");
    let picture = s.upload_blob(b"GIF89a", "image/gif");
    s.put_record("dev.knotless.check", &rkey, json!({
        "$type": "dev.knotless.check", "repo": {"uri": format!("at://{}/dev.knotless.repo/agentic", s.account.did), "key": key},
        "ref": "refs/heads/main", "commit": "a".repeat(40), "workflow": "ci", "status": "success",
        "outputs": [
            {"$type": "dev.knotless.check#image", "image": picture, "alt": "home 2 after"},
            {"$type": "dev.knotless.check#trace", "name": "trace.zip"},
        ],
        "startedAt": "2026-10-21T00:00:00.000Z",
    }));
    let checks = c.json(
        "run_list",
        json!({"repo": repo, "ref": "refs/heads/main", "log": true}),
    );
    assert_eq!(checks[0]["status"], "success");
    // It has no log to give.
    assert!(checks[0].get("logText").is_none(), "{checks}");
    assert_eq!(checks[0]["runner"], s.account.did.as_str());
    // With its pictures, each named, as images to look at; an output of a
    // kind it does not know left out.
    let params = CallToolRequestParams::new("run_list".to_string()).with_arguments(
        json!({"repo": repo, "images": true})
            .as_object()
            .unwrap()
            .clone(),
    );
    let res = c.rt.block_on(c.peer.call_tool(params)).unwrap();
    assert_eq!(res.content.len(), 3);
    assert_eq!(res.content[1].as_text().unwrap().text, "ci: home 2 after");
    let image = res.content[2].as_image().unwrap();
    assert_eq!(
        (image.data.as_str(), image.mime_type.as_str()),
        ("R0lGODlh", "image/gif")
    );
    let none = "b".repeat(40);
    let (text, err) = c.call("run_list", json!({"repo": repo, "commit": none, "wait": 1}));
    assert!(err, "{text}");
    assert!(
        text.contains(&format!("no checks of commit {none} after 1s")),
        "{text}"
    );
    let (text, err) = c.call("run_list", json!({"repo": repo, "commit": "main"}));
    assert!(err && text.contains("a commit is its hex ID"), "{text}");
    let asked = c.json("run_rerun", json!({"repo": repo, "ref": "refs/heads/main"}));
    let asked = asked["asked"].as_str().unwrap();
    assert!(asked.contains("/dev.knotless.rerun/"), "{asked}");
    // Or one workflow at a ref, with variables.
    let one = json!({"repo": repo, "ref": "refs/heads/main", "workflow": "scenarios", "env": {"TRACKS": "team"}});
    let asked = c.json("run_rerun", one);
    let rkey = asked["asked"].as_str().unwrap().rsplit('/').next().unwrap();
    let rerun = s.record("dev.knotless.rerun", rkey);
    assert_eq!(rerun["workflow"], "scenarios");
    assert_eq!(rerun["env"], json!([{"name": "TRACKS", "value": "team"}]));
    // Or to cancel what a ref or a pull has going, one workflow's or all.
    let off = json!({"repo": repo, "ref": "main", "workflow": "deploy"});
    let asked = c.json("run_cancel", off);
    let rkey = asked["asked"].as_str().unwrap().rsplit('/').next().unwrap();
    let cancel = s.record("dev.knotless.cancel", rkey);
    assert_eq!(
        (&cancel["ref"], &cancel["workflow"]),
        (&json!("refs/heads/main"), &json!("deploy"))
    );
    let (text, err) = c.call("run_cancel", json!({"repo": repo}));
    assert!(err && text.contains("name one ref or one pull"), "{text}");

    // The workflows at a ref, and what runs each there: it has none.
    let listed = c.json("workflow_list", json!({"repo": repo}));
    assert_eq!(listed, json!({"ref": "refs/heads/main", "workflows": []}));

    // Renamed, it answers to both names.
    let refs = c.json("repo_refs", json!({"repo": repo}));
    let renamed = c.json("repo_rename", json!({"repo": repo, "name": "agentic2"}));
    let moved = format!("knotless://{}/agentic2", s.account.handle);
    assert_eq!(renamed["url"], moved);
    assert_eq!(
        c.json("repo_refs", json!({"repo": repo}))["refs"],
        refs["refs"]
    );
    assert_eq!(
        c.json("repo_refs", json!({"repo": moved}))["refs"],
        refs["refs"]
    );
    // Its description dropped and its default branch named, as `knotless
    // describe` does, and its packs folded.
    let links = json!([{"url": "https://agentic.example", "title": "Site"}]);
    let topics = json!([{"name": "agents"}]);
    let described = json!({"repo": moved, "description": "", "links": links, "topics": ["agents"], "default_branch": "main"});
    let described = c.json("repo_edit", described);
    assert_eq!(
        described,
        json!({"description": null, "links": links, "topics": topics, "defaultBranch": "main"})
    );
    assert!(
        s.record("dev.knotless.repo", "agentic2")
            .get("description")
            .is_none()
    );
    let compacted = c.json("repo_compact", json!({ "repo": moved }));
    assert!(compacted["before"].as_u64().unwrap() >= 2, "{compacted}");
    assert!(compacted["removed"].is_u64() && compacted["claims"].is_u64());

    // Errors come back as tool errors, for the agent to read.
    for (tool, args) in [
        ("repo_read", json!({"repo": repo, "path": "nope"})),
        (
            "repo_read",
            json!({"repo": repo, "path": "README.md", "rev": "--all"}),
        ),
        ("repo_tree", json!({"repo": "not a repo"})),
        (
            "repo_tree",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        (
            "repo_refs",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        (
            "repo_storage",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        ("repo_search", json!({"repo": repo, "pattern": ""})),
        ("repo_log", json!({"repo": repo, "rev": "-x"})),
        (
            "repo_blame",
            json!({"repo": repo, "path": "README.md", "rev": "-x"}),
        ),
        (
            "repo_diff",
            json!({"repo": repo, "base": "-x", "head": "main"}),
        ),
        ("repo_list", json!({"handle": "no-such-handle.test"})),
        (
            "repo_members",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        (
            "repo_share",
            json!({"repo": repo, "handle": s.account.handle}),
        ),
        (
            "branch_protect",
            json!({"repo": repo, "branch": "main", "undo": true}),
        ),
        (
            "branch_protect",
            json!({"repo": format!("{}/missing", s.account.handle), "branch": "main"}),
        ),
        ("repo_rename", json!({"repo": "not a repo", "name": "x"})),
        ("repo_edit", json!({ "repo": moved })),
        ("repo_compact", json!({"repo": "not a repo"})),
        ("repo_delete", json!({"repo": moved, "confirm": "agentic"})),
        // With no appview, there is no gateway to assume.
        ("account_delegate", json!({})),
    ] {
        let (text, err) = c.call(tool, args.clone());
        assert!(err, "{tool} {args}: {text}");
    }
    // Deleted, once confirmed by its name.
    let deleted = c.json("repo_delete", json!({"repo": moved, "confirm": "agentic2"}));
    assert_eq!(deleted["deleted"], moved);
    assert!(s.record("dev.knotless.repo", "agentic2").is_null());
}

/// An agent deletes its account's knotless data: told what there is
/// first, then, confirmed by its handle, all of it. With no appview, there
/// is none to forget it.
/// What one call cost: the bytes the agent sent and was sent back, which
/// it pays for as tokens, and how long it took.
#[derive(Default, Clone, Copy)]
struct Cost {
    sent: usize,
    got: usize,
    took: std::time::Duration,
}

impl std::ops::Add for Cost {
    type Output = Cost;
    fn add(self, o: Cost) -> Cost {
        Cost {
            sent: self.sent + o.sent,
            got: self.got + o.got,
            took: self.took + o.took,
        }
    }
}

impl Client {
    /// The tool's text, and what the call cost.
    fn costed(&self, tool: &str, args: Value) -> (String, Cost) {
        let start = std::time::Instant::now();
        let text = self.ok(tool, args.clone());
        let cost = Cost {
            sent: args.to_string().len(),
            got: text.len(),
            took: start.elapsed(),
        };
        (text, cost)
    }
}

/// The bench for changing one line of a long file, kept as a test so it
/// cannot regress: the way an agent had to (read it all, send it all
/// back) against the way it can now (search with context, commit an
/// edit). Both land the same file; the second costs a small part of the
/// first, whatever the file's length. Its table is in docs/reference/mcp.md.
#[test]
fn an_agent_changes_a_line_of_a_long_file() {
    let s = logged_in();
    let c = Client::new(&s);
    let repo = format!("{}/long", s.account.handle);
    // About 130 KB, as the longest files in this repository are.
    let long: String = (0..1600)
        .map(|n| format!("    pub async fn item_{n:04}(&self) -> Result<u64> {{ self.get(\"item-{n:04}\", {n}).await }}\n"))
        .collect();
    let base = c.json(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "long", "changes": [
            {"path": "src/long.rs", "content": long},
            {"path": "README.md", "content": "item_0800 is here too\n"},
        ]}),
    );
    let old = "self.get(\"item-0800\", 800)";
    let new = "self.get(\"item-0800\", 801)";

    // Before: the whole file read, and the whole file sent back.
    let at = |branch: &str| json!({"repo": repo, "path": "src/long.rs", "rev": branch});
    let (read, r1) = c.costed("repo_read", at("main"));
    let content = read.replace(old, new);
    let (_, r2) = c.costed(
        "repo_commit",
        json!({"repo": repo, "branch": "whole", "message": "m",
        "base": base["sha"], "changes": [{"path": "src/long.rs", "content": content}]}),
    );
    let before = r1 + r2;

    // Now: the line found with what is around it, and only it sent back.
    let (found, n1) = c.costed(
        "repo_search",
        json!({"repo": repo, "pattern": "item_0800(", "path": "src", "context": 2}),
    );
    let runs: Value = serde_json::from_str(&found).unwrap();
    assert_eq!(
        (runs.as_array().unwrap().len(), &runs[0]["matches"]),
        (1, &json!([801]))
    );
    assert!(runs[0]["text"].as_str().unwrap().contains(old), "{runs}");
    let (_, n2) = c.costed("repo_commit", json!({"repo": repo, "branch": "edited", "message": "m",
        "base": base["sha"], "changes": [{"path": "src/long.rs", "edits": [{"old": old, "new": new}]}]}));
    let now = n1 + n2;

    assert_eq!(
        c.ok("repo_read", at("whole")),
        c.ok("repo_read", at("edited"))
    );
    let tokens = |c: Cost| (c.sent + c.got) / 4;
    println!(
        "changing a line of a {} byte file, two calls each:",
        long.len()
    );
    for (way, cost) in [("before", before), ("now", now)] {
        let (sent, got, ms) = (cost.sent, cost.got, cost.took.as_millis());
        println!(
            "  {way:6} sent {sent:>7} B  got {got:>7} B  ~{:>6} tokens  {ms:>5} ms",
            tokens(cost)
        );
    }
    let times = tokens(before) / tokens(now).max(1);
    println!("  {times}x fewer tokens");
    assert!(
        times >= 50,
        "edits cost {}, a whole file {}",
        tokens(now),
        tokens(before)
    );

    // A part of a file, and who last changed it.
    let part = c.json(
        "repo_read",
        json!({"repo": repo, "path": "src/long.rs", "start": 801, "end": 802}),
    );
    assert_eq!(
        (&part["start"], &part["end"], &part["lines"]),
        (&json!(801), &json!(802), &json!(1600))
    );
    assert!(
        part["text"]
            .as_str()
            .unwrap()
            .starts_with("    pub async fn item_0800("),
        "{part}"
    );
    let head = c.json(
        "repo_read",
        json!({"repo": repo, "path": "README.md", "end": 1}),
    );
    assert_eq!(head["text"], "item_0800 is here too\n");
    let blamed = c.json(
        "repo_blame",
        json!({"repo": repo, "path": "src/long.rs", "rev": "edited", "start": 801}),
    );
    assert_eq!(
        (&blamed[0]["start"], &blamed[0]["count"]),
        (&json!(801), &json!(1))
    );
    let everywhere = c.json(
        "repo_search",
        json!({"repo": repo, "pattern": "item_0800", "limit": 5}),
    );
    assert_eq!(everywhere.as_array().unwrap().len(), 2);

    // What an agent gets wrong comes back for it to put right.
    let edit = |old: &str| {
        json!({"repo": repo, "branch": "edited", "message": "m",
        "changes": [{"path": "src/long.rs", "edits": [{"old": old, "new": "x"}]}]})
    };
    for (args, says) in [
        (
            edit("item-0800\", 800)"),
            "edit 1 of src/long.rs: old is not in the file",
        ),
        (edit("pub async fn"), "old is in the file 1600 times"),
        (
            json!({"repo": repo, "branch": "edited", "message": "m",
            "changes": [{"path": "a", "content": "x", "edits": []}]}),
            "exactly one",
        ),
        (
            json!({"repo": repo, "path": "src/long.rs", "start": 0}),
            "no lines 0 to 1600",
        ),
    ] {
        let tool = if args.get("changes").is_some() {
            "repo_commit"
        } else {
            "repo_read"
        };
        let (text, err) = c.call(tool, args);
        assert!(err && text.contains(says), "{text}");
    }
}

#[test]
fn an_agent_deletes_its_knotless_data() {
    let s = logged_in();
    let c = Client::new(&s);
    c.json("repo_create", json!({"name": "gone", "runners": []}));
    let held = c.json("account_delete_data", json!({}));
    let repos = json!([{"name": "gone", "private": false, "collaborators": 0}]);
    assert_eq!(held["repos"], repos, "{held}");
    let (why, err) = c.call("account_delete_data", json!({"confirm": "nope"}));
    assert!(err && why.contains(&s.account.handle), "{why}");
    assert!(!s.record("dev.knotless.repo", "gone").is_null());
    let confirm = json!({"confirm": s.account.handle});
    let done = c.json("account_delete_data", confirm);
    assert!(done["deleted"].as_u64().unwrap() >= 1, "{done}");
    assert!(s.record("dev.knotless.repo", "gone").is_null());
    assert!(s.records("dev.knotless.repo").is_empty());
}

#[test]
fn an_agent_sets_its_profile() {
    let s = logged_in();
    let c = Client::new(&s);
    assert_eq!(c.json("account_profile", json!({})), json!({}));
    let set = c.json(
        "account_profile",
        json!({"bio": "Writes tools.", "pronouns": "they/them", "avatar": "iVBORw0KGgo=",
            "links": [{"url": "https://agent.example", "title": "Home"}]}),
    );
    assert_eq!(set["description"], "Writes tools.");
    assert_eq!(set["avatar"]["mimeType"], "image/png");
    let record = s.record("dev.knotless.profile", "self");
    assert_eq!(record["pronouns"], "they/them");
    assert_eq!(record["links"][0]["title"], "Home");
    let (text, err) = c.call("account_profile", json!({"avatar": "not base64!"}));
    assert!(err, "{text}");
    let left = c.json("account_profile", json!({"name": "Agent"}));
    assert_eq!(
        (&left["displayName"], &left["pronouns"]),
        (&json!("Agent"), &json!("they/them"))
    );
}

#[test]
fn a_template_asks_for_a_body() {
    let s = logged_in();
    let c = Client::new(&s);
    let repo = c_repo(&c.json("repo_create", json!({"name": "templated"})));
    // A new repository's default branch can be named, as with `knotless create`.
    let trunk = json!({"name": "trunked", "default_branch": "trunk"});
    let trunked = c_repo(&c.json("repo_create", trunk));
    let refs = c.json("repo_refs", json!({ "repo": trunked }));
    assert_eq!(refs["defaultBranch"], "trunk");
    let pull_md =
        "<!-- Title: what it does, under 70 characters. -->\n\n## What changes\n\n## Why\n";
    c.json(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "templates", "changes": [
            {"path": ".knotless/pull.md", "content": pull_md},
            {"path": ".knotless/issue.md", "content": "## What happened\n\n## What you expected\n"},
        ]}),
    );
    c.json("branch_create", json!({"repo": repo, "name": "change"}));
    let pull_template = |args: Value| c.json("pull_template", args)["template"].clone();
    assert_eq!(pull_template(json!({"repo": repo})), pull_md);
    assert_eq!(
        pull_template(json!({"repo": repo, "base": "change"})),
        pull_md
    );
    assert_eq!(
        pull_template(json!({"repo": repo, "base": "gone"})),
        Value::Null
    );
    let issue_template = c.json("issue_template", json!({"repo": repo}))["template"].clone();
    assert!(
        issue_template
            .as_str()
            .unwrap()
            .contains("What you expected")
    );

    let open = |body: Option<&str>| {
        let mut args = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "change", "title": "A change"});
        if let Some(b) = body {
            args["body"] = json!(b);
        }
        c.call("pull_create", args)
    };
    // No body, a blank one, or only the template's notes: the template
    // comes back to write the title and body from.
    let notes = "<!-- Title: what it does, under 70 characters. -->\n";
    for body in [None, Some("  \n"), Some(notes)] {
        let (text, err) = open(body);
        assert!(
            err && text.contains("title and body") && text.contains(pull_md),
            "{text}"
        );
    }
    // The notes copied in are left out, as forge's tests show.
    let (text, err) = open(Some(&format!("{notes}\n## What changes\n\nA line.\n")));
    assert!(!err, "{text}");
    // A body with none of the template's sections is refused, naming them;
    // one with any of them, at any level, goes in.
    let (text, err) = open(Some("Just prose, as before."));
    assert!(
        err && text.contains("none of its sections (what changes, why)"),
        "{text}"
    );
    let (text, err) = c.call("issue_create", json!({"repo": repo, "title": "Broken"}));
    assert!(
        err && text.contains("has no body") && text.contains("## What you expected"),
        "{text}"
    );
    c.json(
        "issue_create",
        json!({"repo": repo, "title": "Broken", "body": "### what happened\n\nIt <!-- broke -->."}),
    );
}

#[test]
fn sharing_from_an_agent() {
    if !net().spaces {
        eprintln!("skipped: the PDS is not on the spaces alpha");
        return;
    }
    let s = logged_in();
    let c = Client::new(&s);
    let created = c.json("repo_create", json!({"name": "secret", "private": true}));
    let repo = created["repo"].as_str().unwrap().to_string();
    c.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "a"}]}));
    assert_eq!(c.json("repo_refs", json!({"repo": repo}))["private"], true);
    // Its owner sees it listed, once, under the name it has now.
    let mine = |c: &Client| -> Vec<Value> {
        c.json("repo_list", json!({"handle": s.account.handle}))
            .as_array()
            .unwrap()
            .iter()
            .filter(|r| r["private"] == true)
            .cloned()
            .collect()
    };
    assert_eq!(mine(&c)[0]["name"], "secret");
    c.json("repo_rename", json!({"repo": repo, "name": "hidden"}));
    let listed = mine(&c);
    assert_eq!(listed.len(), 1, "{listed:?}");
    assert_eq!(listed[0]["name"], "hidden");
    c.json(
        "repo_rename",
        json!({"repo": format!("{}/hidden", s.account.handle), "name": "secret"}),
    );
    let reader = logged_in();
    let other = reader.account.clone();
    let members = c.json("repo_share", json!({"repo": repo, "handle": other.handle}));
    assert_eq!(
        members[1],
        json!({"did": other.did, "handle": other.handle, "access": "read"})
    );
    // A reader may not list the others.
    let (text, err) = Client::new(&reader).call("repo_members", json!({"repo": repo}));
    assert!(err && text.contains("owner can list"), "{text}");
    // Read-only, logged in as the reader, it reads the repository all the
    // same: an agent that must change nothing reads what its account may.
    let read_only = Client::of(knotless_mcp::Server::new(ctx(&reader)).read_only());
    assert_eq!(
        read_only.ok("repo_read", json!({"repo": repo, "path": "a"})),
        "a"
    );
    let members = c.json(
        "repo_share",
        json!({"repo": repo, "handle": other.handle, "undo": true}),
    );
    assert_eq!(members.as_array().unwrap().len(), 1);
}

#[test]
fn nobody_logged_in() {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    let c = Client::new(&s);
    let (text, err) = c.call("repo_create", json!({"name": "x"}));
    assert!(err && text.contains("not logged in"), "{text}");

    // An identifier and password in the environment act as that account.
    let mut s = Sandbox::new();
    let handle = s.account.handle.clone();
    s.set("KNOTLESS_IDENTIFIER", &handle);
    let c = Client::new(&s);
    let created = c.json("repo_create", json!({"name": "from-env"}));
    assert_eq!(created["repo"], format!("{handle}/from-env"));
}

#[test]
fn an_old_version_is_refused_and_a_new_one_told_of() {
    let mut s = Sandbox::new();
    let behind = knotless::update::Known {
        checked: 0,
        latest: Some("999.0.0".into()),
        minimum: Some("999.0.0".into()),
        reason: Some("Compaction loses objects.".into()),
    };
    let mine = json!({"handle": s.account.handle});
    let (said, err) = Client::knowing(&s, &behind).call("repo_list", mine.clone());
    assert!(err && said.contains("Compaction loses objects."), "{said}");
    // Its resources too, however well formed the URI.
    let file = format!("knotless://{}/x/file/main/a", s.account.handle);
    let refused = Client::knowing(&s, &behind).read(&file).unwrap_err();
    assert!(
        refused.message.contains("Compaction loses objects."),
        "{refused:?}"
    );

    // Told to go on anyway, it does, and says once that 999.0.0 is out.
    s.set("KNOTLESS_IGNORE_MINIMUM", "1");
    let c = Client::knowing(&s, &behind);
    let first = c.texts("repo_list", mine.clone());
    assert!(first.last().unwrap().starts_with("knotless 999.0.0 is out"));
    let again = c.texts("repo_list", mine);
    assert_eq!(again.len(), first.len() - 1);
}

/// A read-only server lists only the tools that read, each one the full
/// server has, and runs none of the others.
#[test]
fn a_read_only_server_only_reads() {
    let s = Sandbox::new();
    let names = |c: &Client| -> Vec<String> {
        let tools = c.rt.block_on(c.peer.list_all_tools()).unwrap();
        tools.into_iter().map(|t| t.name.to_string()).collect()
    };
    let all = names(&Client::new(&s));
    let reader = Client::of(knotless_mcp::Server::new(ctx(&s)).read_only());
    let mut read = names(&reader);
    read.sort();
    let mut reads: Vec<String> = knotless_mcp::READS.iter().map(|t| t.to_string()).collect();
    reads.sort();
    assert_eq!(read, reads);
    assert!(reads.iter().all(|t| all.contains(t)), "{reads:?}");
    assert!(all.contains(&"repo_commit".to_string()));
    let mine = json!({"repo": format!("{}/nothing", s.account.handle), "branch": "main", "message": "m", "changes": []});
    let params =
        CallToolRequestParams::new("repo_commit").with_arguments(mine.as_object().unwrap().clone());
    let refused = reader.rt.block_on(reader.peer.call_tool(params));
    assert!(refused.is_err(), "{refused:?}");
    reader.ok("repo_list", json!({"handle": s.account.handle}));
}

#[test]
fn the_binary_speaks_mcp_on_stdio() {
    use std::io::{BufRead, Write};
    let s = Sandbox::new();
    let mut child = s
        .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    let mut stdin = child.stdin.take().unwrap();
    writeln!(
        stdin,
        "{}",
        json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
            "protocolVersion": "2025-06-18", "capabilities": {},
            "clientInfo": {"name": "t", "version": "0"}}})
    )
    .unwrap();
    let mut line = String::new();
    std::io::BufReader::new(child.stdout.take().unwrap())
        .read_line(&mut line)
        .unwrap();
    let res: Value = serde_json::from_str(&line).unwrap();
    assert_eq!(res["result"]["serverInfo"]["name"], "knotless");
    drop(stdin);
    assert!(child.wait().unwrap().success());

    // Read-only, it says hello the same; it takes no other flag.
    let mut child = s
        .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
        .arg("--read-only")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    let mut stdin = child.stdin.take().unwrap();
    writeln!(
        stdin,
        "{}",
        json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
            "protocolVersion": "2025-06-18", "capabilities": {},
            "clientInfo": {"name": "t", "version": "0"}}})
    )
    .unwrap();
    let mut line = String::new();
    std::io::BufReader::new(child.stdout.take().unwrap())
        .read_line(&mut line)
        .unwrap();
    assert!(line.contains("\"knotless\""), "{line}");
    drop(stdin);
    assert!(child.wait().unwrap().success());
    let out = s
        .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
        .arg("--bogus")
        .output()
        .unwrap();
    assert_eq!(out.status.code(), Some(2));

    // A client that leaves before it says hello is an error.
    let out = s
        .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
        .stdin(std::process::Stdio::null())
        .output()
        .unwrap();
    assert!(!out.status.success());
}

/// An appview following the PDS, for listing public repositories' pulls
/// and issues.
/// It takes reports too, from the accounts `operators` names.
/// Its OAuth callback is on an https proxy in front of it, which the
/// returned `TlsProxy` keeps up.
fn appview(rt: &tokio::runtime::Runtime, operators: &str) -> (String, Sandbox, TlsProxy) {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_ADMIN_DIDS", operators);
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let base = format!("http://localhost:{}", listener.local_addr().unwrap().port());
    let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap();
    let proxy = tls_proxy(listener.local_addr().unwrap().port());
    let mut gateway = knotless_serve::gateway::Gateway::new(&base).unwrap();
    gateway.oauth_url = proxy.url.clone();
    let mut app = knotless_serve::App::build(
        ctx(&s),
        std::time::Duration::ZERO,
        Some(gateway),
        Some(index),
    );
    // It keeps its logs, for its operators to read.
    let logs = knotless_serve::logs::Logs::open(&s.path("logs.db"), knotless_serve::logs::KEEP);
    std::sync::Arc::get_mut(&mut app).unwrap().logs = Some(std::sync::Arc::new(logs.unwrap()));
    let router = knotless_serve::router(app.clone());
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    rt.spawn(knotless_serve::firehose::watch(
        app,
        net().pds.replace("http://", "ws://"),
    ));
    (base, s, proxy)
}

#[test]
fn agents_fork_review_and_merge() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    // Delegating is approved in the browser.
    let browser = browser(&owner.account, "");
    owner.set("KNOTLESS_BROWSER", &browser);
    // The appview is a gateway too, at a did:web served over http.
    owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let mut contrib = logged_in();
    contrib.set("KNOTLESS_APPVIEW", &base);
    contrib.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let (o, c) = (Client::new(&owner), Client::new(&contrib));

    let repo = c_repo(&o.json("repo_create", json!({"name": "reviewed"})));
    // Its owner gives its runner a secret, sealed to the key it publishes,
    // and lists it, never its value, then takes it back.
    let dir = tempfile::tempdir().unwrap();
    let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap();
    let nsid = knotless::sealed::KEY_NSID;
    let key = json!({"$type": nsid, "key": keys.public().did_key(), "createdAt": "2026-10-29T00:00:00.000Z"});
    contrib.put_record(nsid, knotless::sealed::KEY_RKEY, key);
    o.json(
        "runner_add",
        json!({"repo": repo, "handle": contrib.account.handle}),
    );
    let set = json!({"repo": repo, "name": "TOKEN", "value": "v4lue", "refs": ["refs/heads/main"]});
    let listed = o.json("secret_set", set);
    assert_eq!(listed[0]["name"], "TOKEN", "{listed}");
    assert_eq!(o.json("secret_list", json!({"repo": repo})), listed);
    assert_eq!(listed[0]["refs"], json!([{"pattern": "refs/heads/main"}]));
    assert!(listed[0].get("sealed").is_none(), "{listed}");
    let gone = o.json("secret_remove", json!({"repo": repo, "name": "TOKEN"}));
    assert_eq!(gone, json!([]));
    o.json(
        "runner_remove",
        json!({"repo": repo, "handle": contrib.account.handle}),
    );
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let fork = c_repo(&c.json("repo_fork", json!({"repo": repo})));
    assert_eq!(fork, format!("{}/reviewed", contrib.account.handle));
    let change = c.json("repo_commit", json!({"repo": fork, "branch": "main", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));

    // A collaborator pushes through a gateway, the owner's writes
    // delegated to it: sharing for writing first delegates them to the
    // appview, and says so after who may write.
    let (text, err) = c.call("branch_create", json!({"repo": repo, "name": "theirs"}));
    assert!(err && text.contains("only its owner can"), "{text}");
    let shared = o.texts(
        "repo_share",
        json!({"repo": repo, "handle": contrib.account.handle, "write": true}),
    );
    let members: Value = serde_json::from_str(&shared[0]).unwrap();
    assert_eq!(members[1]["access"], "write", "{members}");
    assert_eq!(shared.len(), 2, "{shared:?}");
    let first = "Delegated your writes to the gateway did:web:localhost%3A";
    assert!(shared[1].starts_with(first), "{shared:?}");
    // Delegated to already, it shares at once; another gateway may be
    // named.
    let shared = o.texts(
        "repo_share",
        json!({"repo": repo, "handle": contrib.account.handle, "write": true}),
    );
    assert_eq!(shared.len(), 1, "{shared:?}");
    let gateway = o.json("account_delegate", json!({}))["delegated"].clone();
    assert!(
        gateway
            .as_str()
            .unwrap()
            .starts_with("did:web:localhost%3A"),
        "{gateway}"
    );
    o.json("account_delegate", json!({"gateway": gateway}));
    c.json("branch_create", json!({"repo": repo, "name": "theirs"}));
    c.json("branch_delete", json!({"repo": repo, "name": "theirs"}));
    o.json(
        "repo_share",
        json!({"repo": repo, "handle": contrib.account.handle, "undo": true}),
    );

    let pull = c.json("pull_create", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "main", "title": "Add b", "body": "small"}));
    let pull = pull["uri"].as_str().unwrap().to_string();
    let issue = c.json("issue_create", json!({"repo": repo, "title": "Missing c"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let listed = |state: &str| -> Vec<Value> {
        o.json(
            "pull_list",
            json!({"repo": repo, "query": format!("state:{state}")}),
        )
        .as_array()
        .unwrap()
        .clone()
    };
    wait_until(|| listed("open").len() == 1);
    // Authors come with their handles.
    assert_eq!(listed("open")[0]["handle"], contrib.account.handle.as_str());
    wait_until(|| {
        let pulls = o.json("pull_list", json!({"repo": repo}));
        let issues = o.json("issue_list", json!({"repo": repo}));
        pulls.as_array().unwrap().len() + issues.as_array().unwrap().len() == 2
    });
    // A query filters them, as GitHub's search does.
    let missing = o.json(
        "issue_list",
        json!({"repo": repo, "query": "state:open missing"}),
    );
    assert_eq!(missing.as_array().unwrap().len(), 1, "{missing}");
    assert_eq!(missing[0]["uri"], issue.as_str());

    o.json(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "good", "approve": true}),
    );
    let (text, err) = o.call(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "?", "approve": true, "changes": true}),
    );
    assert!(err, "{text}");
    // Its author comments on it, and reviews nothing of their own.
    let mine = json!({"repo": repo, "uri": pull, "body": "mine", "approve": true});
    let (text, err) = c.call("comment_add", mine);
    assert!(err && text.contains("your own pull"), "{text}");
    wait_until(|| {
        o.json("pull_show", json!({"repo": repo, "uri": pull}))["comments"]
            .as_array()
            .unwrap()
            .len()
            == 1
    });
    let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
    assert_eq!(shown["item"]["handle"], contrib.account.handle.as_str());
    assert_eq!(shown["item"]["reviews"].as_array().unwrap().len(), 1);

    // The pull's author points it at the head branch it already names.
    assert_eq!(
        c.json(
            "pull_retarget",
            json!({"repo": repo, "uri": pull, "head": "main"})
        )["retargeted"],
        pull
    );
    let (text, err) = o.call(
        "pull_retarget",
        json!({"repo": repo, "uri": pull, "base": "nope"}),
    );
    assert!(err, "{text}");

    // Its author changes what a pull or a comment says, and takes a
    // comment back.
    let edited = c.json(
        "pull_edit",
        json!({"repo": repo, "uri": pull, "title": "Add b, please", "body": format!("smaller, for {issue}")}),
    );
    assert_eq!(edited["edited"], pull);
    // Made a draft and ready again, but not both at once.
    let (text, err) = c.call(
        "pull_edit",
        json!({"repo": repo, "uri": pull, "draft": true, "ready": true}),
    );
    assert!(err && text.contains("not both"), "{text}");
    c.json(
        "pull_edit",
        json!({"repo": repo, "uri": pull, "draft": true}),
    );
    c.json(
        "pull_edit",
        json!({"repo": repo, "uri": pull, "ready": true}),
    );
    let retitled = json!({"repo": repo, "uri": issue, "title": "Missing c, please"});
    assert_eq!(c.json("issue_edit", retitled)["edited"], issue);
    let said = c.json(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "one more"}),
    )["uri"]
        .as_str()
        .unwrap()
        .to_string();
    // A picture shown after what it says, attached while it shows it.
    let png = {
        use base64::Engine;
        base64::engine::general_purpose::STANDARD.encode(b"\x89PNG\r\n\x1a\nshot")
    };
    let shot = json!([{"base64": png, "alt": "the page"}]);
    c.json(
        "comment_edit",
        json!({"repo": repo, "uri": said, "body": "see", "images": shot}),
    );
    let rkey = said.rsplit('/').next().unwrap();
    let record = contrib.record("dev.knotless.comment", rkey);
    assert!(
        record["body"]
            .as_str()
            .unwrap()
            .starts_with("see\n\n![the page](http"),
        "{record}"
    );
    assert_eq!(
        record["attachments"].as_array().unwrap().len(),
        1,
        "{record}"
    );
    let (text, err) = c.call(
        "pull_edit",
        json!({"repo": repo, "uri": pull, "images": shot}),
    );
    assert!(err && text.contains("go with a body"), "{text}");
    let broken = json!([{"base64": "not base64!", "alt": "x"}]);
    let (text, err) = c.call(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "x", "images": broken}),
    );
    assert!(err, "{text}");
    c.json(
        "comment_edit",
        json!({"repo": repo, "uri": said, "body": "one more thing"}),
    );
    wait_until(|| {
        let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
        let comments = shown["comments"].as_array().unwrap().clone();
        shown["item"]["title"] == "Add b, please"
            && shown["item"]["body"] == format!("smaller, for {issue}")
            && comments
                .iter()
                .any(|x| x["body"] == "one more thing" && x["updatedAt"].is_string())
    });
    // The issue it names says so.
    let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
    assert_eq!(shown["mentionedBy"], json!([{"pull": pull}]), "{shown}");
    assert_eq!(
        c.json("comment_delete", json!({"repo": repo, "uri": said}))["deleted"],
        said
    );
    wait_until(|| {
        o.json("pull_show", json!({"repo": repo, "uri": pull}))["comments"]
            .as_array()
            .unwrap()
            .len()
            == 1
    });

    // Before merging: what it brings in, that it merges, and the review.
    let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
    assert_eq!(shown["compare"]["mergeable"], "fast-forward");
    assert_eq!(shown["compare"]["head"], change["sha"]);
    assert_eq!(shown["compare"]["commits"].as_array().unwrap().len(), 1);
    assert_eq!(shown["comments"][0]["body"], "good");
    assert_eq!(shown["comments"][0]["verdict"], "approve");
    let review = &shown["item"]["reviews"][0];
    assert_eq!(
        (&review["by"], &review["verdict"]),
        (&json!(owner.account.did), &json!("approve"))
    );
    assert_eq!(review["commit"], change["sha"]);
    assert_eq!(
        shown["comments"][0]["handle"],
        owner.account.handle.as_str()
    );
    // A comment on its lines, answered in its thread, which its author
    // resolves.
    let lines = json!([{"path": "b", "line": 1, "side": "new", "body": "Why b?"}]);
    let wrote = o.json(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "", "lines": lines}),
    );
    let on = wrote["uri"].as_str().unwrap().to_string();
    assert_eq!(wrote["written"], json!([on]));
    // A review sends at most a hundred.
    let many: Vec<Value> = (1..=101)
        .map(|n| json!({"path": "b", "line": n, "body": "And this"}))
        .collect();
    let (text, err) = o.call(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "", "lines": many}),
    );
    assert!(err && text.contains("at most 100"), "{text}");
    c.json(
        "comment_add",
        json!({"repo": repo, "uri": on, "body": "Because"}),
    );
    c.json("comment_resolve", json!({"repo": repo, "uri": on}));
    let thread = || o.json("pull_show", json!({"repo": repo, "uri": pull}))["comments"].clone();
    let said = |c: &Value| c["reply"] == on.as_str();
    wait_until(|| thread().as_array().unwrap().iter().any(said));
    let comments = thread();
    let started = comments
        .as_array()
        .unwrap()
        .iter()
        .find(|c| c["uri"] == on.as_str());
    let started = started.unwrap();
    assert_eq!(
        (&started["line"]["path"], &started["resolved"]),
        (&json!("b"), &json!(true))
    );
    // Its answers stand as the thread does.
    let answer = comments.as_array().unwrap().iter().find(|c| said(c));
    assert_eq!(answer.unwrap()["resolved"], true);

    let diff = o.ok("pull_diff", json!({"repo": repo, "uri": pull}));
    // Each line numbered as the comment on it above took it: b's line 1,
    // on the new side, and none on the old.
    assert!(diff.contains("@@ -0,0 +1 @@\n  1 | +b\n"), "{diff}");
    // Asked for by path, the file's part of it is the same.
    let part = o.ok(
        "pull_diff",
        json!({"repo": repo, "uri": pull, "paths": ["b"]}),
    );
    assert!(part.contains("@@ -0,0 +1 @@\n  1 | +b\n"), "{part}");

    // Merged, it says how, and why its branch stays: it is in the fork.
    let merged = o.json("pull_merge", json!({"repo": repo, "uri": pull}));
    assert_ne!(merged["sha"], change["sha"]);
    assert_eq!(merged["method"], "merge");
    let kept = merged["kept"].as_str().unwrap();
    assert!(kept.starts_with("main is in another repository"), "{kept}");
    wait_until(|| listed("merged").len() == 1);
    let (text, err) = o.call("pull_merge", json!({"repo": repo, "uri": pull}));
    assert!(err, "{text}");
    // Merged, it compares as its merge brought it in, though its branch
    // moves on.
    c.json("repo_commit", json!({"repo": fork, "branch": "main", "message": "later", "changes": [{"path": "z", "content": "z\n"}]}));
    let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
    let compared = &shown["compare"];
    assert_eq!(compared["mergeable"], "merged");
    // The merge commit, and the commit it brought in.
    assert_eq!(compared["head"], merged["sha"]);
    assert_eq!(compared["commits"].as_array().unwrap().len(), 2);
    // Merged, it reverts in a pull of its own, on a branch of its own.
    let reverted = o.json(
        "pull_revert",
        json!({"repo": repo, "uri": pull, "reason": "not yet"}),
    );
    let rkey = pull.rsplit('/').next().unwrap();
    assert_eq!(reverted["branch"], format!("revert/{rkey}"));
    assert!(
        reverted["pull"]
            .as_str()
            .unwrap()
            .contains("dev.knotless.pull")
    );
    let (text, err) = o.call("pull_revert", json!({"repo": repo, "uri": pull}));
    assert!(err && text.contains("has a branch"), "{text}");
    // The revert names what it undoes, and once it merges, that names it.
    let undo = reverted["pull"].as_str().unwrap();
    wait_until(|| listed("open").iter().any(|i| i["uri"] == undo));
    let shown = o.json("pull_show", json!({"repo": repo, "uri": undo}));
    assert_eq!(shown["item"]["reverts"], pull.as_str());
    assert!(shown["item"].get("revertedBy").is_none(), "{shown}");
    o.json("pull_merge", json!({"repo": repo, "uri": undo}));
    wait_until(|| {
        let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
        shown["item"]["revertedBy"] == undo
    });

    // A pull that conflicts says where.
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "mine", "changes": [{"path": "a", "content": "owner\n"}]}));
    c.json("repo_commit", json!({"repo": fork, "branch": "clash", "message": "theirs", "changes": [{"path": "a", "content": "contrib\n"}]}));
    let clash = c.json("pull_create", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "clash", "title": "Clash"}));
    let clash = clash["uri"].as_str().unwrap().to_string();
    let compared = &o.json("pull_show", json!({"repo": repo, "uri": clash}))["compare"];
    assert_eq!(compared["mergeable"], "conflict");
    assert_eq!(compared["conflicts"], json!(["a"]));
    let (text, err) = o.call("pull_merge", json!({"repo": repo, "uri": clash}));
    assert!(err && text.contains("conflicts in a"), "{text}");
    // Its branch is in the fork: the fork's owner brings it up to date.
    let (text, err) = o.call("pull_update_branch", json!({"repo": repo, "uri": clash}));
    assert!(err && text.contains("in another repository"), "{text}");
    // Merged, a pull's branch is not brought up to date.
    let (text, err) = o.call("pull_update_branch", json!({"repo": repo, "uri": pull}));
    assert!(err && text.contains("only an open pull"), "{text}");
    // One here is, the lines both sides added at one place kept, main's first.
    o.json("branch_create", json!({"repo": repo, "name": "notes"}));
    o.json("repo_commit", json!({"repo": repo, "branch": "notes", "message": "n", "changes": [{"path": "a", "content": "owner\nnotes\n"}]}));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "owner\nmain\n"}]}));
    let notes = o.json("pull_create", json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "notes", "title": "Notes"}));
    let notes = notes["uri"].as_str().unwrap().to_string();
    let updated = o.json("pull_update_branch", json!({"repo": repo, "uri": notes}));
    assert_eq!(updated["branch"], "notes");
    assert_eq!(updated["added"], json!(["a"]));
    let read = o.ok(
        "repo_read",
        json!({"repo": repo, "path": "a", "rev": "notes"}),
    );
    assert_eq!(read, "owner\nmain\nnotes\n");
    // One that touches other files merges cleanly though main moved on.
    c.json("repo_commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]}));
    let aside = c.json("pull_create", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "aside", "title": "Aside"}));
    let aside = aside["uri"].as_str().unwrap().to_string();
    let compared = &o.json("pull_show", json!({"repo": repo, "uri": aside}))["compare"];
    assert_eq!(compared["mergeable"], "clean");
    // One whose head the base holds already brings in nothing.
    o.json("branch_create", json!({"repo": repo, "name": "level"}));
    let level = o.json("pull_create", json!({"repo": repo, "base": "level", "head_repo": repo, "head_branch": "main", "title": "Level"}));
    let level = level["uri"].as_str().unwrap().to_string();
    let compared = &o.json("pull_show", json!({"repo": repo, "uri": level}))["compare"];
    assert_eq!(compared["mergeable"], "merged");
    assert_eq!(compared["commits"], json!([]));
    // Once main moves on, it merges, and main, the default branch, stays.
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "l", "changes": [{"path": "l", "content": "l\n"}]}));
    let merged = o.json("pull_merge", json!({"repo": repo, "uri": level}));
    let kept = merged["kept"].as_str().unwrap();
    assert!(kept.contains("the branch it merges into"), "{kept}");

    // Renaming a branch takes the pulls on it along.
    o.json("branch_create", json!({"repo": repo, "name": "develop"}));
    let onto = c.json("pull_create", json!({"repo": repo, "base": "develop", "head_repo": fork, "head_branch": "clash", "title": "Onto develop"}));
    let onto = onto["uri"].as_str().unwrap().to_string();
    wait_until(|| listed("open").iter().any(|i| i["uri"] == onto.as_str()));
    let renamed = o.json(
        "branch_rename",
        json!({"repo": repo, "name": "develop", "new_name": "next"}),
    );
    assert_eq!(renamed["retargeted"], json!([onto]));
    wait_until(|| {
        o.json("pull_show", json!({"repo": repo, "uri": onto}))["item"]["base"] == "next"
    });
    let (text, err) = o.call(
        "branch_rename",
        json!({"repo": repo, "name": "main", "new_name": "trunk"}),
    );
    assert!(err && text.contains("default branch"), "{text}");

    // A pull whose head is gone still shows, and says so.
    c.json("branch_delete", json!({"repo": fork, "name": "clash"}));
    let compared = &o.json("pull_show", json!({"repo": repo, "uri": clash}))["compare"];
    assert!(
        compared["error"]
            .as_str()
            .unwrap()
            .contains("has no branch clash"),
        "{compared}"
    );

    // An issue has nothing to compare.
    let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
    assert!(shown.get("compare").is_none(), "{shown}");
    // The owner defines labels and puts them on; the issue's author may not.
    let bug = json!({"repo": repo, "name": "kind/bug", "color": "#d73a4a"});
    o.json("label_define", bug);
    let docs = json!({"repo": repo, "name": "kind/docs", "exclusive": true});
    let defined = o.json("label_define", docs);
    assert_eq!(defined[1]["exclusive"], true, "{defined}");
    assert_eq!(o.json("label_list", json!({"repo": repo})), defined);
    let both = json!({"repo": repo, "uri": issue, "labels": ["kind/bug", "kind/docs"]});
    let (text, err) = c.call("issue_label", both.clone());
    assert!(err, "{text}");
    assert_eq!(
        o.json("issue_label", both)["labels"],
        json!(["kind/bug", "kind/docs"])
    );
    let off = json!({"repo": repo, "uri": issue, "labels": ["kind/bug"], "undo": true});
    assert_eq!(o.json("issue_label", off)["labels"], json!(["kind/docs"]));
    // A pull takes labels the same way.
    let on_pull = json!({"repo": repo, "uri": clash, "labels": ["kind/bug"]});
    assert_eq!(o.json("pull_label", on_pull)["labels"], json!(["kind/bug"]));
    // The owner assigns it, and asks for a review of a pull besides its
    // author; the shown item names them.
    let them = contrib.account.handle.clone();
    let assigning = json!({"repo": repo, "uri": issue, "handles": [them]});
    let (text, err) = c.call("issue_assign", assigning.clone());
    assert!(err, "{text}");
    let assigned = o.json("issue_assign", assigning);
    assert_eq!(assigned["assignees"], json!([contrib.account.did]));
    // The shown issue names them once the appview hears of the assignment.
    let shown = || o.json("issue_show", json!({"repo": repo, "uri": issue}));
    wait_until(|| shown()["item"]["assignees"][0]["handle"] == them.as_str());
    let me = owner.account.handle.clone();
    let asking = json!({"repo": repo, "uri": clash, "handles": [me]});
    assert_eq!(
        o.json("pull_ask_review", asking.clone())["reviewers"],
        json!([owner.account.did])
    );
    let mut stop = asking.clone();
    stop["undo"] = json!(true);
    assert_eq!(o.json("pull_ask_review", stop)["reviewers"], json!([]));
    o.json("pull_ask_review", asking);
    let off = json!({"repo": repo, "uri": issue, "handles": [them], "undo": true});
    assert_eq!(o.json("issue_assign", off)["assignees"], json!([]));
    // A pull is assigned the same way.
    let to_pull = json!({"repo": repo, "uri": clash, "handles": [them]});
    assert_eq!(
        o.json("pull_assign", to_pull)["assignees"],
        json!([contrib.account.did])
    );
    // The owner asks for changes to it, then dismisses that review: the
    // shown pull says so, and its author may not.
    let review = json!({"repo": repo, "uri": clash, "body": "no", "changes": true});
    o.json("comment_add", review);
    let dismissing = json!({"repo": repo, "uri": clash, "reviewer": me, "reason": "moot"});
    let (text, err) = c.call("pull_dismiss_review", dismissing.clone());
    assert!(err, "{text}");
    let dismissal = o.json("pull_dismiss_review", dismissing)["uri"].clone();
    // The shown pull says so once the appview hears of the dismissal.
    let shown = || o.json("pull_show", json!({"repo": repo, "uri": clash}));
    wait_until(|| shown()["item"]["reviews"][0]["dismissed"] == dismissal);
    let shown = shown();
    let mine = &shown["item"]["reviews"][0];
    assert_eq!(
        (&mine["by"], &mine["dismissed"]),
        (&json!(owner.account.did), &dismissal)
    );
    let said = shown["comments"].as_array().unwrap();
    assert!(
        said.iter().any(|c| c["dismisses"] == mine["uri"]),
        "{shown}"
    );
    let gone = o.json("label_delete", json!({"repo": repo, "name": "kind/bug"}));
    assert_eq!(gone.as_array().unwrap().len(), 1, "{gone}");
    let bot = contrib.account.handle.clone();
    let named = o.json(
        "triager_add",
        json!({"repo": repo, "handle": bot, "scopes": ["kind"]}),
    );
    assert_eq!(named[0]["scopes"][0]["name"], "kind", "{named}");
    assert_eq!(o.json("triager_list", json!({"repo": repo})), named);
    let unnamed = o.json("triager_remove", json!({"repo": repo, "handle": bot}));
    assert_eq!(unnamed, json!([]));
    let closing = json!({"repo": repo, "uri": issue});
    assert_eq!(o.json("issue_close", closing.clone())["state"], "closed");
    assert_eq!(o.json("issue_reopen", closing)["state"], "open");
    let closing = json!({"repo": repo, "uri": clash});
    assert_eq!(o.json("pull_close", closing.clone())["state"], "closed");
    assert_eq!(o.json("pull_reopen", closing)["state"], "open");
    let (text, err) = o.call("comment_resolve", json!({"repo": repo, "uri": issue}));
    assert!(err, "{text}");
    // Queued, a pull waits for a runner to land it, and leaves when asked.
    let (text, err) = o.call(
        "pull_merge",
        json!({"repo": repo, "uri": aside, "queue": true}),
    );
    assert!(err && text.contains("names no runners"), "{text}");
    o.json(
        "runner_add",
        json!({"repo": repo, "handle": owner.account.handle}),
    );
    // Nor is there a queue until a rule of the branch turns it on.
    let (text, err) = o.call(
        "pull_merge",
        json!({"repo": repo, "uri": aside, "queue": true}),
    );
    assert!(
        err && text.contains("does not use the merge queue"),
        "{text}"
    );
    // Set to merge when its checks pass it says so, until cancelled.
    let auto = json!({"repo": repo, "uri": aside, "auto": true});
    let set = o.json("pull_merge", auto.clone());
    assert_eq!(set["autoMerge"], "set", "{set}");
    // Once the index has the record.
    wait_until(|| {
        let shown = o.json("pull_show", json!({"repo": repo, "uri": aside}));
        let said = shown["autoMerge"].as_str().unwrap_or_default();
        said.starts_with("merge when checks pass: set by")
    });
    let undo = json!({"repo": repo, "uri": aside, "auto": true, "undo": true});
    assert_eq!(o.json("pull_merge", undo)["autoMerge"], "cancelled");
    let on = json!({"repo": repo, "branch": "main", "merges": true, "queue": true});
    o.json("branch_protect", on);
    // Asked first, it would merge: it has no workflows to wait for. Its
    // branch is in the fork, so nothing goes with it.
    let checked = o.json(
        "pull_merge",
        json!({"repo": repo, "uri": aside, "check": true}),
    );
    assert_eq!(checked["state"], "clean", "{checked}");
    assert_eq!(checked["checks"], "none", "{checked}");
    assert_eq!(checked["method"], "merge", "{checked}");
    assert_eq!(checked.get("deletes"), None, "{checked}");
    let may = json!({"merge": true, "force": true, "queue": true, "autoMerge": false, "base": true, "head": false, "rerun": true});
    assert_eq!(checked["may"], may);
    let queued = o.json(
        "pull_merge",
        json!({"repo": repo, "uri": aside, "queue": true}),
    );
    assert_eq!(
        queued["queued"],
        json!({"base": "main", "position": 1, "of": 1})
    );
    wait_until(|| {
        o.json("queue_list", json!({"repo": repo}))
            .as_array()
            .unwrap()
            .len()
            == 1
    });
    let line = o.json("queue_list", json!({"repo": repo}));
    assert_eq!(line[0]["uri"], aside.as_str());
    assert_eq!(line[0]["queue"]["state"], "queued");
    let batches = o.json("run_list", json!({"repo": repo, "queue": "main"}));
    assert_eq!(batches, json!([]));
    let shown = o.json("pull_show", json!({"repo": repo, "uri": aside}));
    assert_eq!(shown["standing"]["said"], "Queued, 1st of 1", "{shown}");
    assert_eq!(shown["standing"]["place"]["position"], 1);
    assert!(shown["standing"]["batch"].is_null());
    assert_eq!(
        o.json("queue_leave", json!({"repo": repo, "uri": aside}))["left"],
        aside.as_str()
    );
    wait_until(|| o.json("queue_list", json!({"repo": repo})) == json!([]));
    let (text, err) = o.call("queue_leave", json!({"repo": repo, "uri": aside}));
    assert!(err && text.contains("not in the merge queue"), "{text}");
    // Into a branch that takes pulls only through the queue, a merge
    // queues it instead, saying why.
    let main = json!({"repo": repo, "branch": "main", "queue": true});
    let held = json!([{"branch": "main", "history": "linear", "queue": true}]);
    assert_eq!(o.json("branch_protect", main), held);
    assert_eq!(o.json("branch_rules", json!({"repo": repo})), held);
    let checked = o.json(
        "pull_merge",
        json!({"repo": repo, "uri": aside, "check": true}),
    );
    assert_eq!(checked["queueOnly"], true, "{checked}");
    let queued = o.json("pull_merge", json!({"repo": repo, "uri": aside}));
    assert_eq!(
        queued,
        json!({"queued": {"base": "main", "position": 1, "of": 1}, "why": "main takes pulls only through the merge queue"})
    );
    wait_until(|| o.json("queue_list", json!({"repo": repo})) != json!([]));
    o.json("queue_leave", json!({"repo": repo, "uri": aside}));
    wait_until(|| o.json("queue_list", json!({"repo": repo})) == json!([]));
    let unheld = json!({"repo": repo, "branch": "main", "undo": true});
    assert_eq!(o.json("branch_protect", unheld.clone()), json!([]));
    // A gate named by its handle is kept by its DID.
    let gate = json!({"repo": repo, "branch": "main", "gates": [contrib.account.handle]});
    let rules = o.json("branch_protect", gate);
    let gates = &rules[0]["reviews"]["gates"];
    assert_eq!(gates[0]["did"], contrib.account.did.as_str(), "{rules}");
    assert_eq!(o.json("branch_protect", unheld.clone()), json!([]));

    // Into a branch that asks for a review, an approval from someone who
    // may not write counts for nothing, and each answer says why.
    o.json("repo_commit", json!({"repo": repo, "branch": "mine", "message": "d", "changes": [{"path": "d", "content": "d\n"}]}));
    let them = contrib.account.handle.clone();
    let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "mine", "title": "Add d", "reviewers": [them]});
    let mine = o.json("pull_create", opened)["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let shown = || o.json("pull_show", json!({"repo": repo, "uri": mine}));
    // Asked to review it, they are waited on until they do.
    wait_until(|| shown()["item"]["reviewers"][0]["pending"] == true);
    let body = format!("Fine, @{}", owner.account.handle);
    let lines = json!([{"path": "d", "line": 1, "body": "Why d?"}]);
    let approval =
        json!({"repo": repo, "uri": mine, "body": body, "approve": true, "lines": lines});
    let asks = json!({"repo": repo, "branch": "main", "reviews": 1});
    o.json("branch_protect", asks);
    let approved = c.json("comment_add", approval);
    let note = approved["note"].as_str().unwrap();
    assert!(note.contains("does not count"), "{approved}");
    let review = approved["uri"].as_str().unwrap().to_string();
    // Asked at once, the check reads the review just written, not
    // waiting for the appview to hear of it.
    let at_once = c.json(
        "pull_merge",
        json!({"repo": repo, "uri": mine, "check": true}),
    );
    let counted = at_once["reviewsUncounted"].as_array();
    assert_eq!(counted.map(Vec::len), Some(1), "{at_once}");
    wait_until(|| shown()["comments"].as_array().unwrap().len() == 2);
    let s = shown();
    assert_eq!(s["item"]["draft"], false, "{s}");
    let asked = &s["item"]["reviewers"][0];
    assert_eq!(
        (&asked["did"], &asked["handle"], &asked["pending"]),
        (&json!(contrib.account.did), &json!(them), &json!(false))
    );
    // Its line comment says on which side and lines, as written or not,
    // and stands as its thread does; the review's own comment starts no
    // thread, so is not resolved or not.
    let (own, on_line) = (&s["comments"][0], &s["comments"][1]);
    assert_eq!(own.get("resolved"), None, "{own}");
    let at = json!({"path": "d", "side": "new", "line": 1, "startLine": 1});
    assert_eq!(
        (&on_line["line"], &on_line["resolved"]),
        (&at, &json!(false))
    );
    // A pull, draft or not, says so where pulls are listed too.
    let listed = o.json("pull_list", json!({"repo": repo, "query": "state:open"}));
    let listed = listed
        .as_array()
        .unwrap()
        .iter()
        .find(|i| i["uri"] == mine.as_str());
    assert_eq!(listed.unwrap()["draft"], false);
    let theirs = &s["item"]["reviews"][0];
    assert_eq!(theirs["handle"], them.as_str());
    assert_eq!(
        (&theirs["maintainer"], &theirs["outdated"]),
        (&json!(false), &json!(false))
    );
    let not = "only the owner's and collaborators' approvals count";
    assert_eq!(theirs["uncounted"], not);
    // The review's own comment names itself, as its line comment names
    // it; and says whom it mentions.
    for said in s["comments"].as_array().unwrap() {
        assert_eq!(said["review"], review.as_str(), "{said}");
    }
    let mention = &s["comments"][0]["facets"][0]["features"][0];
    assert_eq!(mention["did"], owner.account.did.as_str(), "{s}");
    // Asked first, it is blocked: the owner may merge it, but only by
    // force, and is told how, in tools.
    let checked = o.json(
        "pull_merge",
        json!({"repo": repo, "uri": mine, "check": true}),
    );
    assert_eq!(
        (
            &checked["state"],
            &checked["reviews"],
            &checked["may"]["merge"]
        ),
        (&json!("blocked"), &json!("blocked"), &json!(true)),
        "{checked}"
    );
    let uncounted = format!("@{them}'s approval does not count: {not}");
    assert_eq!(checked["reviewsUncounted"], json!([{"text": uncounted}]));
    let why = checked["reason"].as_str().unwrap();
    assert!(why.contains(&uncounted), "{why}");
    assert!(
        why.ends_with(
            "the owner, its author or not, merges past it with the pull_merge tool and force"
        ),
        "{why}"
    );
    let (text, err) = o.call("pull_merge", json!({"repo": repo, "uri": mine}));
    assert!(
        err && text.contains("the comment_add tool and approve"),
        "{text}"
    );
    // Someone who may not merge it is told so.
    let theirs = c.json(
        "pull_merge",
        json!({"repo": repo, "uri": mine, "check": true}),
    );
    assert_eq!(theirs["may"]["merge"], false);
    let why = theirs["reason"].as_str().unwrap();
    assert!(why.contains("; and you may not merge it"), "{why}");
    // The pull moves on: the approval is of an earlier head.
    o.json("repo_commit", json!({"repo": repo, "branch": "mine", "message": "e", "changes": [{"path": "e", "content": "e\n"}]}));
    wait_until(|| shown()["item"]["reviews"][0]["outdated"] == true);
    // Forced, it merges, saying how, and its branch goes with it.
    let merged = o.json(
        "pull_merge",
        json!({"repo": repo, "uri": mine, "force": true}),
    );
    assert_eq!(
        (&merged["method"], &merged["deleted"], &merged["kept"]),
        (&json!("squash"), &json!("mine"), &Value::Null),
        "{merged}"
    );
    // Its branch gone, its diff is what the merge brought in, numbered
    // as ever.
    wait_until(|| shown()["item"]["state"] == "merged");
    let diff = o.ok("pull_diff", json!({"repo": repo, "uri": mine}));
    assert!(diff.contains("+++ b/d\n"), "{diff}");
    assert!(diff.contains("@@ -0,0 +1 @@\n  1 | +e\n"), "{diff}");
    // Reviewed again, the review before stands for nothing now, and says
    // so; merged, no review of it is waited on.
    let again = json!({"repo": repo, "uri": mine, "body": "Still fine", "approve": true});
    c.json("comment_add", again);
    wait_until(|| shown()["comments"].as_array().unwrap().len() == 3);
    let s = shown();
    assert_eq!(s["comments"][0]["superseded"], true, "{s}");
    assert_eq!(s["comments"][2].get("superseded"), None, "{s}");
    assert_eq!(s["item"]["reviewers"][0]["pending"], false, "{s}");
    assert_eq!(o.json("branch_protect", unheld), json!([]));
    let (text, err) = o.call("queue_list", json!({"repo": "nobody.test/x"}));
    assert!(err, "{text}");

    // Archived, it is read only, to anyone; unarchived, it takes issues
    // again.
    let archived = o.json("repo_archive", json!({"repo": repo}));
    assert!(archived["archivedAt"].is_string(), "{archived}");
    let (text, err) = c.call("issue_create", json!({"repo": repo, "title": "Late"}));
    assert!(err && text.contains("is archived"), "{text}");
    let (text, err) = o.call("repo_archive", json!({"repo": repo}));
    assert!(err && text.contains("already archived"), "{text}");
    let back = o.json("repo_archive", json!({"repo": repo, "undo": true}));
    assert_eq!(back["archivedAt"], json!(null));

    // Offered to the contributor, withdrawn, offered again, and taken up.
    let them = contrib.account.handle.clone();
    o.json("repo_transfer", json!({"repo": repo, "to": them}));
    o.json("repo_transfer", json!({"repo": repo, "undo": true}));
    let offered = o.json(
        "repo_transfer",
        json!({"repo": repo, "to": format!("{them}/kept")}),
    );
    assert_eq!(offered["offeredTo"], format!("knotless://{them}/kept"));
    let moved = c.json("repo_accept", json!({"repo": repo}));
    assert_eq!(
        moved["url"],
        format!("knotless://{}/kept", contrib.account.did)
    );
    for (tool, args) in [
        ("repo_transfer", json!({"repo": "nobody.test/x", "to": "x"})),
        ("repo_transfer", json!({"repo": "nobody.test/x"})),
        (
            "repo_transfer",
            json!({"repo": "nobody.test/x", "to": "x", "undo": true}),
        ),
        ("repo_archive", json!({"repo": "nobody.test/x"})),
        ("repo_accept", json!({"repo": "nobody.test/x"})),
        ("repo_fork", json!({"repo": "nobody.test/x"})),
        (
            "pull_create",
            json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "none", "title": "t"}),
        ),
        (
            "issue_create",
            json!({"repo": "nobody.test/x", "title": "t"}),
        ),
        ("pull_list", json!({"repo": "nobody.test/x"})),
        ("issue_list", json!({"repo": "nobody.test/x"})),
        ("label_list", json!({"repo": "nobody.test/x"})),
        ("pull_diff", json!({"repo": repo, "uri": issue})),
        (
            "pull_show",
            json!({"repo": repo, "uri": "at://did:plc:nobody/dev.knotless.issue/x"}),
        ),
        // Only its author changes what a pull says, and a pull is closed,
        // not deleted.
        (
            "pull_edit",
            json!({"repo": repo, "uri": pull, "title": "x"}),
        ),
        ("comment_delete", json!({"repo": repo, "uri": issue})),
        (
            "pull_dismiss_review",
            json!({"repo": repo, "uri": clash, "reviewer": format!("at://{}/dev.knotless.comment/x", owner.account.did), "reason": "r"}),
        ),
    ] {
        let (text, err) = o.call(tool, args.clone());
        assert!(err, "{tool} {args}: {text}");
    }
}

#[test]
fn agents_moderate() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let mut operator = logged_in();
    let (base, _appview, _proxy) = appview(&rt, &operator.account.did);
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    // Delegating is approved in the browser.
    let browser = browser(&owner.account, "");
    owner.set("KNOTLESS_BROWSER", &browser);
    operator.set("KNOTLESS_APPVIEW", &base);
    let (o, c) = (Client::new(&owner), Client::new(&operator));
    let repo = c_repo(&o.json("repo_create", json!({"name": "moderated"})));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let issue = c.json("issue_create", json!({"repo": repo, "title": "Spam"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let items = || {
        o.json("issue_list", json!({"repo": repo}))
            .as_array()
            .unwrap()
            .len()
    };
    wait_until(|| items() == 1);

    let hidden = o.json("mod_hide", json!({"repo": repo, "uri": issue}));
    assert_eq!(hidden["hidden"], true);
    wait_until(|| items() == 0);
    o.json(
        "mod_hide",
        json!({"repo": repo, "uri": issue, "undo": true}),
    );
    wait_until(|| items() == 1);
    let them = operator.account.handle.clone();
    assert_eq!(
        o.json("mod_block", json!({"handle": them}))["blocked"],
        true
    );
    wait_until(|| items() == 0);
    o.json("mod_block", json!({"handle": them, "undo": true}));
    wait_until(|| items() == 1);
    o.json("mod_block", json!({"handle": them, "repo": repo}));
    wait_until(|| items() == 0);
    o.json(
        "mod_block",
        json!({"handle": them, "repo": repo, "undo": true}),
    );
    wait_until(|| items() == 1);

    let report = c.json(
        "mod_report",
        json!({"subject": repo, "reason": "spam", "text": "all of it"}),
    );
    assert_eq!(report["id"], 1);
    assert_eq!(
        report["subject"]["uri"],
        format!("at://{}/dev.knotless.repo/moderated", owner.account.did)
    );
    c.json(
        "mod_report",
        json!({"subject": owner.account.handle, "reason": "rude"}),
    );
    let reports = c.json("admin_reports", json!({}));
    assert_eq!(reports["reports"].as_array().unwrap().len(), 2);
    let stats = c.json("admin_stats", json!({"days": 1}));
    assert_eq!(stats["days"].as_array().unwrap().len(), 1, "{stats}");
    // Its operator reads its logs.
    let logs = c.json(
        "admin_logs",
        json!({"level": "warn", "min_ms": 1, "limit": 5}),
    );
    assert!(logs["lines"].is_array(), "{logs}");
    let down = c.json("admin_takedown", json!({"subject": issue}));
    assert_eq!(down["takedown"]["applied"], true);
    wait_until(|| items() == 0);
    let status = c.json("admin_status", json!({"subject": issue}));
    assert_eq!(status["takedown"]["applied"], true);
    c.json("admin_takedown", json!({"subject": issue, "undo": true}));
    wait_until(|| items() == 1);

    for (who, tool, args) in [
        (&c, "mod_hide", json!({"repo": repo, "uri": issue})),
        (&o, "mod_block", json!({"handle": "nobody.test"})),
        (
            &c,
            "mod_report",
            json!({"subject": repo, "reason": "boring"}),
        ),
        (&o, "admin_takedown", json!({"subject": issue})),
        (&o, "admin_status", json!({"subject": issue})),
        (&o, "admin_reports", json!({})),
        (&o, "admin_stats", json!({})),
        (&o, "admin_logs", json!({"trace": "t"})),
    ] {
        let (text, err) = who.call(tool, args.clone());
        assert!(err, "{tool} {args}: {text}");
    }
}

fn c_repo(v: &Value) -> String {
    v["repo"].as_str().unwrap().to_string()
}

#[test]
fn agents_star() {
    let owner = logged_in();
    let o = Client::new(&owner);
    let repo = c_repo(&o.json("repo_create", json!({"name": "shiny"})));
    let fan = logged_in();
    let f = Client::new(&fan);
    let starred = f.json("repo_star", json!({"repo": repo}));
    assert_eq!(starred["starred"], true);
    let uri = starred["uri"].as_str().unwrap().to_string();
    assert_eq!(
        f.json("repo_star", json!({"repo": repo}))["uri"],
        uri.as_str()
    );
    let mine = f.json("repo_stars", json!({}));
    assert_eq!(mine["stars"][0]["uri"], uri.as_str());
    let shown = format!("knotless://{}/shiny", owner.account.handle);
    assert_eq!(mine["stars"][0]["repo"], shown);
    let theirs = o.json("repo_stars", json!({"handle": fan.account.handle}));
    assert_eq!(theirs, mine);
    let unstarred = f.json("repo_star", json!({"repo": repo, "undo": true}));
    assert_eq!(unstarred, json!({"starred": false, "uri": null}));
    assert_eq!(f.json("repo_stars", json!({})), json!({"stars": []}));
}

/// An agent follows an account, sees whom it follows, and unfollows it;
/// who follows an account takes an appview.
#[test]
fn agents_follow() {
    let owner = logged_in();
    let fan = logged_in();
    let f = Client::new(&fan);
    let account = owner.account.handle.clone();
    let followed = f.json("account_follow", json!({"handle": account}));
    assert_eq!(followed["following"], true);
    let uri = followed["uri"].as_str().unwrap().to_string();
    assert_eq!(
        f.json("account_follow", json!({"handle": account}))["uri"],
        uri.as_str()
    );
    let mine = f.json("account_follows", json!({}));
    assert_eq!(mine["follows"][0]["uri"], uri.as_str());
    assert_eq!(mine["follows"][0]["handle"], account.as_str());
    let theirs = f.json("account_follows", json!({"handle": fan.account.handle}));
    assert_eq!(theirs, mine);
    let (why, err) = f.call("account_followers", json!({}));
    assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}");
    let unfollowed = f.json("account_follow", json!({"handle": account, "undo": true}));
    assert_eq!(unfollowed, json!({"following": false, "uri": null}));
    assert_eq!(f.json("account_follows", json!({})), json!({"follows": []}));
}

/// An owner hears, from the appview, of what others did that concerns it:
/// a star, a follow, an issue on its repository and a comment on its
/// issue; new until it lists them, over MCP or the command line.
#[test]
fn agents_hear_what_others_did() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let mut fan = logged_in();
    fan.set("KNOTLESS_APPVIEW", &base);
    fan.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let (o, f) = (Client::new(&owner), Client::new(&fan));
    let repo = c_repo(&o.json("repo_create", json!({"name": "heard"})));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let mine = o.json("issue_create", json!({"repo": repo, "title": "Mine"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    f.json("repo_star", json!({"repo": repo}));
    f.json("account_follow", json!({"handle": owner.account.handle}));
    f.json("issue_create", json!({"repo": repo, "title": "Theirs"}));
    f.json(
        "comment_add",
        json!({"repo": repo, "uri": mine, "body": "me too"}),
    );

    // Listed without being marked seen, until all four are there.
    let peek = || o.json("notification_list", json!({"peek": true}))["notifications"].clone();
    wait_until(|| peek().as_array().unwrap().len() == 4);
    let heard = peek();
    let mut reasons: Vec<&str> = heard
        .as_array()
        .unwrap()
        .iter()
        .map(|n| n["reason"].as_str().unwrap())
        .collect();
    reasons.sort();
    assert_eq!(reasons, ["comment", "follow", "issue", "star"]);
    // Who follows the owner, as the appview counts them.
    let followers = o.json("account_followers", json!({}));
    assert_eq!(
        followers["followers"][0]["handle"],
        fan.account.handle.as_str()
    );
    assert!(heard.as_array().unwrap().iter().all(|n| n["new"] == true));
    let comment = heard
        .as_array()
        .unwrap()
        .iter()
        .find(|n| n["reason"] == "comment");
    let comment = comment.unwrap();
    assert_eq!(comment["subject"], mine.as_str());
    assert_eq!(comment["author"], fan.account.handle.as_str());
    let about = format!("knotless://{}/heard \"Mine\"", owner.account.handle);
    assert_eq!(comment["about"], about);
    // Each names its repository, for pull_show, and what it is about: the
    // issue itself, the repository starred, and for a follow, nothing.
    let named = format!("{}/heard", owner.account.handle);
    let of = |reason: &str| {
        let mut all = heard.as_array().unwrap().iter();
        all.find(|n| n["reason"] == reason).unwrap().clone()
    };
    let issue = of("issue");
    assert_eq!(
        (&issue["subject"], &issue["repo"]),
        (&issue["uri"], &json!(named))
    );
    let starred = format!("at://{}/dev.knotless.repo/heard", owner.account.did);
    assert_eq!(of("star")["subject"], starred);
    assert_eq!(
        (&of("follow")["subject"], &of("follow")["repo"]),
        (&Value::Null, &Value::Null)
    );

    // Of one kind, on the command line and here, marking none seen.
    let (code, lines) = owner.cli(
        &["notification", "list", "--kind", "stars-and-follows"],
        b"",
    );
    assert_eq!((code, lines.lines().count()), (0, 2), "{lines}");
    let comments = o.json("notification_list", json!({"kind": "comments"}));
    assert_eq!(comments["notifications"][0]["reason"], "comment");
    assert_eq!(comments["notifications"].as_array().unwrap().len(), 1);
    assert_eq!(comments["markedSeen"], false);
    assert_eq!(owner.cli(&["notification", "list", "--kind"], b"").0, 1);

    // The command line peeks as well; listing marks them seen.
    let (code, lines) = owner.cli(&["notification", "list", "--peek"], b"");
    assert_eq!(code, 0, "{lines}");
    assert_eq!(lines.lines().filter(|l| l.starts_with("new\t")).count(), 4);
    assert_eq!(owner.cli(&["notification", "list", "--bogus"], b"").0, 1);
    assert_eq!(owner.cli(&["notification", "list"], b"").0, 0);
    let listed = o.json("notification_list", json!({}));
    assert_eq!(listed["markedSeen"], true);
    let listed = listed["notifications"].clone();
    assert!(listed.as_array().unwrap().iter().all(|n| n["new"] == false));
    let peeked = o.json("notification_list", json!({"peek": true}));
    assert_eq!(peeked["markedSeen"], false);
    // Having commented, the fan hears of what is said after in the thread.
    o.json(
        "comment_add",
        json!({"repo": repo, "uri": mine, "body": "thanks"}),
    );
    let theirs = || f.json("notification_list", json!({"peek": true}))["notifications"].clone();
    wait_until(|| theirs().as_array().unwrap().len() == 1);
    let reply = &theirs()[0];
    assert_eq!(
        (&reply["reason"], &reply["subject"]),
        (&json!("comment"), &json!(mine))
    );
    assert_eq!(reply["author"], owner.account.handle.as_str());
    // A review of the owner's pull that names them, on its lines too, is
    // heard of once: as a review, mentioning them, its line comments
    // counted.
    let side = json!([{"path": "b", "content": "b\n"}]);
    o.json(
        "repo_commit",
        json!({"repo": repo, "branch": "side", "message": "b", "changes": side}),
    );
    let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "side", "title": "Add b"});
    let pull = o.json("pull_create", opened)["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let body = format!("Not yet, @{}", owner.account.handle);
    let lines = json!([{"path": "b", "line": 1, "body": "Why b?"}, {"path": "b", "line": 1, "body": "And here"}]);
    let review = json!({"repo": repo, "uri": pull, "body": body, "changes": true, "lines": lines});
    f.json("comment_add", review);
    let on = |n: &&Value| n["subject"] == pull.as_str();
    wait_until(|| peek().as_array().unwrap().iter().any(|n| on(&n)));
    let heard = peek();
    let of_it: Vec<&Value> = heard.as_array().unwrap().iter().filter(on).collect();
    assert_eq!(of_it.len(), 1, "{heard}");
    let said = (
        &of_it[0]["reason"],
        &of_it[0]["verdict"],
        &of_it[0]["mentioned"],
    );
    assert_eq!(said, (&json!("comment"), &json!("changes"), &json!(true)));
    assert_eq!(of_it[0]["lineComments"], 2);
    // Without an appview there is no one to ask.
    let mut alone = logged_in();
    alone.set("KNOTLESS_APPVIEW", "");
    let (why, err) = Client::new(&alone).call("notification_list", json!({}));
    assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}");
    // Whether the appview is well, asked of it, or of one named.
    let status = o.json("status", json!({"repo": repo}));
    assert!(status["ok"].is_boolean(), "{status}");
    let named = o.json("status", json!({"server": base}));
    assert!(named["ok"].is_boolean(), "{named}");
    let (why, err) = Client::new(&alone).call("status", json!({}));
    assert!(err && why.contains("give a server"), "{why}");
}

/// An agent watches a repository it has no part in and hears of an
/// issue opened on it; subscribes to the issue and hears of its comments;
/// mutes it and hears nothing of it; and lists what it chose, over MCP or
/// the command line.
#[test]
fn agents_watch_subscribe_and_mute() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let [mut owner, mut fan, mut watcher] = [logged_in(), logged_in(), logged_in()];
    for s in [&mut owner, &mut fan, &mut watcher] {
        s.set("KNOTLESS_APPVIEW", &base);
        s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    }
    let (o, f, w) = (
        Client::new(&owner),
        Client::new(&fan),
        Client::new(&watcher),
    );
    let repo = c_repo(&o.json("repo_create", json!({"name": "watched"})));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let record = format!("at://{}/dev.knotless.repo/watched", owner.account.did);
    let watching = w.json("notification_watch", json!({"repo": repo}));
    assert_eq!(
        (&watching["subject"], &watching["level"]),
        (&json!(record), &json!("all"))
    );
    let issue = f.json("issue_create", json!({"repo": repo, "title": "Theirs"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let peek = || w.json("notification_list", json!({"peek": true}))["notifications"].clone();
    let heard = |reason: &str| {
        let all = peek();
        let all = all.as_array().unwrap().iter();
        all.filter(|n| n["reason"] == reason).count()
    };
    wait_until(|| heard("watching") == 1);
    assert_eq!(peek()[0]["subject"], issue.as_str());

    // Subscribed, its comments are heard of as such, not as watching.
    let subscribed = w.json("notification_subscribe", json!({"uri": issue}));
    assert_eq!(subscribed["level"], "subscribed");
    f.json(
        "comment_add",
        json!({"repo": repo, "uri": issue, "body": "more"}),
    );
    wait_until(|| heard("subscribed") == 1);
    assert_eq!(heard("watching"), 1);

    // Muted on the command line, nothing of it is heard.
    let (code, said) = watcher.cli(&["notification", "mute", &issue], b"");
    assert_eq!((code, said.trim()), (0, format!("muted\t{issue}").as_str()));
    assert!(peek().as_array().unwrap().is_empty());
    let url = format!("knotless://{}/watched", owner.account.handle);
    let (code, said) = watcher.cli(&["notification", "watch", &url, "releases"], b"");
    assert_eq!(
        (code, said.trim()),
        (0, format!("releases\t{record}").as_str())
    );
    let (code, listed) = watcher.cli(&["notification", "subscriptions"], b"");
    assert_eq!((code, listed.lines().count()), (0, 2), "{listed}");
    assert!(listed.starts_with("releases\t"), "{listed}");
    let all = w.json("notification_subscriptions", json!({}));
    assert_eq!(all["subscriptions"][1]["subject"], issue.as_str());
    assert!(all["subscriptions"][1]["createdAt"].is_string());
    // Each forgotten: the issue heard of again as watching does.
    let (code, said) = watcher.cli(&["notification", "unsubscribe", &issue], b"");
    assert_eq!(
        (code, said.trim()),
        (0, format!("participating\t{issue}").as_str())
    );
    let (code, said) = watcher.cli(&["notification", "subscribe", &issue], b"");
    assert_eq!((code, said.split('\t').next()), (0, Some("subscribed")));
    let (code, _) = watcher.cli(&["notification", "watch", &url], b"");
    assert_eq!(code, 0);
    let forgot = w.json(
        "notification_watch",
        json!({"repo": repo, "level": "participating"}),
    );
    assert_eq!(forgot.get("createdAt"), None);
    let muted = w.json("notification_mute", json!({"uri": issue}));
    assert_eq!(muted["level"], "muted");
    // Refused for what is no repository, pull or issue, or a level wrong
    // for it.
    let (why, err) = w.call("notification_mute", json!({"uri": record}));
    assert!(err && why.contains("no level"), "{why}");
    let undone = w.json("notification_unsubscribe", json!({"uri": issue}));
    assert_eq!(undone["level"], "participating");
    let (code, _) = watcher.cli(&["notification", "mute"], b"");
    assert_eq!(code, 1);
}

/// What only the command line did before, as tools: an owner's
/// house-keeping, from making a repository to deleting it.
#[test]
fn agents_keep_house() {
    let s = logged_in();
    let c = Client::new(&s);
    let made = c.json("repo_create", json!({"name": "kept", "runners": []}));
    assert_eq!(made["runners"], json!([]));
    let repo = c_repo(&made);
    for (n, path) in ["a", "b"].iter().enumerate() {
        let change = json!([{"path": path, "content": format!("{n}\n")}]);
        c.json(
            "repo_commit",
            json!({"repo": repo, "branch": "trunk", "message": path, "changes": change}),
        );
    }
    assert_eq!(c.json("branch_rules", json!({"repo": repo})), json!([]));
    c.json("branch_protect", json!({"repo": repo, "branch": "trunk"}));
    let rules = c.json("branch_rules", json!({"repo": repo}));
    assert_eq!(rules[0]["branch"], "trunk");

    // With no grace period here, both packs fold into one.
    let compacted = c.json("repo_compact", json!({"repo": repo}));
    let counted = (&compacted["before"], &compacted["removed"]);
    assert_eq!(counted, (&json!(2), &json!(2)), "{compacted}");
    assert_eq!(c.json("repo_storage", json!({"repo": repo}))["packs"], 1);

    // Asking for code owners where the owner alone owns a path warns
    // that their own pull of it merges only by force.
    let handle = s.account.handle.clone();
    let owners = json!([{"path": "CODEOWNERS", "content": format!("*  @{handle}\n")}]);
    c.json(
        "repo_commit",
        json!({"repo": repo, "branch": "trunk", "message": "owners", "changes": owners}),
    );
    let asks = json!({"repo": repo, "branch": "trunk", "reviews": 1, "code_owners": true});
    let rules = c.json("branch_protect", asks);
    let warned = rules[0]["warnings"][0].as_str().unwrap();
    assert!(
        warned.starts_with(&format!("@{handle} alone owns *, ")),
        "{rules}"
    );
    assert_eq!(c.json("branch_rules", json!({"repo": repo})), rules);

    let (why, err) = c.call("repo_delete", json!({"repo": repo, "confirm": "nope"}));
    assert!(err && why.contains("kept"), "{why}");
    let deleted = c.json("repo_delete", json!({"repo": repo, "confirm": "kept"}));
    let shown = format!("knotless://{}/kept", s.account.handle);
    assert_eq!(deleted["deleted"], shown);
    let (why, err) = c.call("repo_refs", json!({"repo": repo}));
    assert!(err, "{why}");
}

/// What takes an appview, over the tools and the command line alike:
/// finding an account by its handle, a pull's diff, renaming the branch it
/// targets, and how a triager labels.
#[test]
fn agents_and_the_command_line_find_and_rename() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let o = Client::new(&owner);
    let repo = c_repo(&o.json("repo_create", json!({"name": "found"})));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    o.json("repo_commit", json!({"repo": repo, "branch": "topic", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));
    o.json("branch_create", json!({"repo": repo, "name": "side"}));

    let handle = owner.account.handle.clone();
    let find = || o.json("account_search", json!({"text": handle, "limit": 50}))["owners"].clone();
    let mine = |v: &Value| v["owner"]["handle"] == handle.as_str();
    wait_until(|| find().as_array().unwrap().iter().any(mine));
    let found = find();
    let me = found.as_array().unwrap().iter().find(|v| mine(v));
    assert_eq!(me.unwrap()["repoCount"], 1);
    let (code, out) = owner.cli(&["account", "search", &handle, "--limit", "50"], b"");
    assert_eq!(code, 0, "{out}");
    assert!(out.lines().any(|l| l == format!("{handle}\t1")), "{out}");
    assert_eq!(
        owner.cli(&["account", "search", &handle, "--bogus"], b"").0,
        1
    );
    assert_eq!(owner.cli(&["account", "search", &handle], b"").0, 0);
    let mut alone = logged_in();
    alone.set("KNOTLESS_APPVIEW", "");
    let (why, err) = Client::new(&alone).call("account_search", json!({"text": handle}));
    assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}");

    let url = format!("knotless://{repo}");
    let (code, pull) = owner.cli(
        &["pull", "create", &url, "side", &url, "topic", "Topic"],
        b"",
    );
    assert_eq!(code, 0, "{pull}");
    let pulls = || o.json("pull_list", json!({"repo": repo}));
    wait_until(|| pulls().as_array().unwrap().len() == 1);
    let (code, diff) = owner.cli(&["pull", "diff", &url, &pull], b"");
    assert!(code == 0 && diff.contains("+b"), "{diff}");
    let (code, out) = owner.cli(&["branch", "rename", &url, "side", "next"], b"");
    assert_eq!(code, 0, "{out}");
    assert_eq!(out, format!("renamed side to next\nretargeted {pull}"));
    let (code, sha) = owner.cli(&["tag", "create", &url, "v1", "next"], b"");
    assert!(code == 0 && sha.len() == 40, "{sha}");
    let (code, out) = owner.cli(&["tag", "delete", &url, "v1"], b"");
    assert_eq!((code, out.as_str()), (0, "deleted v1"));

    let scores = o.json("triager_eval", json!({"repo": repo, "handle": handle}));
    assert!(scores.is_array(), "{scores}");
}

/// An agent claims the addresses its commits show as its by, and lists
/// whom it blocks, as the command line does.
#[test]
fn agents_keep_their_settings() {
    let s = logged_in();
    let them = logged_in();
    let c = Client::new(&s);
    assert_eq!(c.json("account_email", json!({})), json!({"claimed": 0}));
    let add = json!({"address": "Agent@Example.com"});
    let added = c.json("account_email_add", add.clone());
    assert_eq!(added, json!({"changed": true, "claimed": 1}));
    assert_eq!(c.json("account_email_add", add)["changed"], false);
    let (code, out) = s.cli(&["account", "email"], b"");
    assert_eq!((code, out.as_str()), (0, "1 address claimed"));
    let gone = c.json(
        "account_email_remove",
        json!({"address": "agent@example.com"}),
    );
    assert_eq!(gone, json!({"changed": true, "claimed": 0}));
    let (why, err) = c.call("account_email_add", json!({"address": "nowhere"}));
    assert!(err && why.contains("no email address"), "{why}");

    assert_eq!(c.json("mod_blocks", json!({})), json!({"blocks": []}));
    let handle = them.account.handle.clone();
    c.json("mod_block", json!({"handle": handle}));
    let repo = c_repo(&c.json("repo_create", json!({"name": "kept"})));
    c.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    c.json("mod_block", json!({"handle": handle, "repo": repo}));
    let listed = c.json("mod_blocks", json!({}));
    let blocks = listed["blocks"].as_array().unwrap();
    assert_eq!(blocks.len(), 2, "{listed}");
    assert!(blocks.iter().all(|b| b["did"] == them.account.did.as_str()));
    assert!(blocks.iter().all(|b| b["handle"] == handle.as_str()));
    let named = format!("at://{}/dev.knotless.repo/kept", s.account.did);
    assert_eq!(blocks.iter().filter(|b| b["repo"] == named).count(), 1);
    assert_eq!(blocks.iter().filter(|b| b["repo"].is_null()).count(), 1);
    // Anyone's, by handle, as their repo has them.
    let mine = json!({"handle": s.account.handle});
    assert_eq!(Client::new(&them).json("mod_blocks", mine), listed);
    let (code, out) = s.cli(&["mod", "blocks"], b"");
    assert_eq!((code, out.lines().count()), (0, 2), "{out}");
}

/// What an account hears of is its to choose, kept by the appview: a
/// reason turned off, or anyone it does not follow, is not heard of, over
/// MCP and the command line alike.
#[test]
fn agents_choose_what_they_hear() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let mut fan = logged_in();
    fan.set("KNOTLESS_APPVIEW", &base);
    fan.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let (o, f) = (Client::new(&owner), Client::new(&fan));
    let all = o.json("notification_settings", json!({}));
    assert_eq!(all["followed_only"], false);
    let listed = all["reasons"].as_array().unwrap();
    let reasons: Vec<&str> = listed
        .iter()
        .map(|r| r["reason"].as_str().unwrap())
        .collect();
    assert_eq!(reasons[..5], ["star", "follow", "pull", "issue", "comment"]);
    assert!(listed.iter().all(|r| r["enabled"] == true));
    let off = o.json("notification_settings", json!({"off": ["star"]}));
    assert_eq!(
        off["reasons"][0],
        json!({"reason": "star", "enabled": false})
    );
    assert_eq!(off["reasons"][1]["enabled"], true);
    let both = json!({"followed_only": true, "anyone": true});
    let (why, err) = o.call("notification_settings", both);
    assert!(err && why.contains("not both"), "{why}");
    let only = o.json("notification_settings", json!({"followed_only": true}));
    assert_eq!(only["followed_only"], true);
    let anyone = o.json("notification_settings", json!({"anyone": true}));
    assert_eq!(anyone["followed_only"], false);
    let (why, err) = o.call("notification_settings", json!({"off": ["stars"]}));
    assert!(err && why.contains("is no reason"), "{why}");

    // A star, unheard of; the follow after it, heard.
    let repo = c_repo(&o.json("repo_create", json!({"name": "quiet"})));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    f.json("repo_star", json!({"repo": repo}));
    f.json("account_follow", json!({"handle": owner.account.handle}));
    let peek = || o.json("notification_list", json!({"peek": true}))["notifications"].clone();
    wait_until(|| !peek().as_array().unwrap().is_empty());
    let heard = peek();
    assert_eq!(heard.as_array().unwrap().len(), 1, "{heard}");
    assert_eq!(heard[0]["reason"], "follow");

    // The command line, the same: only from accounts followed, which the
    // fan is not, then from anyone, with stars again.
    let settings = |args: &[&str]| owner.cli(&[&["notification", "settings"], args].concat(), b"");
    let (code, out) = settings(&["--followed-only", "--off", "issue"]);
    assert_eq!(code, 0, "{out}");
    assert!(out.contains("issue\toff"), "{out}");
    assert!(out.ends_with("from\taccounts you follow"), "{out}");
    assert!(peek().as_array().unwrap().is_empty());
    let (code, out) = settings(&["--anyone", "--on", "star"]);
    assert_eq!(code, 0, "{out}");
    assert!(out.starts_with("star\ton\nfollow\ton\n"), "{out}");
    assert!(out.ends_with("from\tanyone"), "{out}");
    assert_eq!(peek().as_array().unwrap().len(), 2);
    assert_eq!(settings(&[]).1, out);
    assert_eq!(settings(&["--bogus"]).0, 1);
    // Each account's are its own.
    let theirs = f.json("notification_settings", json!({}));
    assert_eq!(theirs["reasons"], all["reasons"]);
}

/// An agent makes a theme for the pages: a palette checked for contrast
/// as the pages check their own, published for anyone to choose, chosen,
/// found, and deleted; the command line does the same.
#[test]
fn agents_make_themes() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut maker = logged_in();
    maker.set("KNOTLESS_APPVIEW", &base);
    maker.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let mut other = logged_in();
    other.set("KNOTLESS_APPVIEW", &base);
    other.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let (m, o) = (Client::new(&maker), Client::new(&other));
    let paper = json!({"colorScheme": "light", "background": "#ffffff", "text": "#1f2328",
        "primary": "#1f6feb", "accent": "#0969da"});
    let mut pale = paper.clone();
    pale["textMuted"] = json!("#eeeeee");
    let mine = json!({"handle": maker.account.handle});
    assert_eq!(m.json("theme_list", mine.clone()), json!({"themes": []}));

    // Checked alone: what falls short, named as the palette names it.
    let fine = m.json("theme_check", json!({"palette": paper}));
    assert_eq!(fine, json!({"readable": true, "shortfalls": []}));
    let short = m.json("theme_check", json!({"palette": pale}));
    assert_eq!(short["readable"], false);
    assert_eq!(short["shortfalls"][0]["text"], "textMuted");
    assert_eq!(short["shortfalls"][0]["on"], "background");
    let refused = |c: &Client, tool: &str, args: Value, why: &str| {
        let (said, err) = c.call(tool, args);
        assert!(err && said.contains(why), "{said}");
    };
    refused(
        &m,
        "theme_check",
        json!({"palette": {"colorScheme": "dark"}}),
        "background",
    );
    refused(&m, "theme_use", json!({"theme": "palette"}), "none is");
    refused(&m, "theme_use", json!({"theme": "sepia"}), "is no theme");
    let hard = json!({"name": "Pale", "palette": pale});
    refused(&m, "theme_publish", hard, "hard to read");
    let blank = json!({"name": " ", "palette": paper});
    refused(&m, "theme_publish", blank, "a theme's name");
    assert!(maker.records("dev.knotless.theme").is_empty());

    // Published and used at once; found by anyone, as the appview lists it.
    let made = m.json(
        "theme_publish",
        json!({"name": "Quokka paper", "palette": paper, "use": true}),
    );
    assert_eq!(made["using"], "Quokka paper");
    let uri = made["uri"].as_str().unwrap().to_string();
    assert!(
        uri.starts_with(&format!("at://{}/dev.knotless.theme/", maker.account.did)),
        "{uri}"
    );
    let found = || o.json("theme_list", json!({"find": "quokka"}))["themes"].clone();
    wait_until(|| !found().as_array().unwrap().is_empty());
    assert_eq!(found()[0]["uri"], uri.as_str());
    assert_eq!(found()[0]["creator"], maker.account.handle.as_str());
    assert_eq!(found()[0]["palette"], paper);
    let theirs = o.json("theme_list", mine);
    assert_eq!(theirs["themes"][0]["name"], "Quokka paper");
    // Chosen by another, as published; or one of the pages', or the system's.
    for (choice, drawn) in [
        (uri.as_str(), "Quokka paper"),
        ("mocha", "mocha"),
        ("system", "the system's light or dark"),
    ] {
        assert_eq!(
            o.json("theme_use", json!({"theme": choice})),
            json!({"using": drawn})
        );
    }
    let own = o.json("theme_use", json!({"theme": "palette", "palette": paper}));
    assert_eq!(own, json!({"using": "your own"}));
    let gone = format!(
        "at://{}/dev.knotless.theme/3zzzzzzzzzzzz",
        maker.account.did
    );
    refused(&o, "theme_use", json!({"theme": gone}), "no such theme");
    refused(
        &o,
        "theme_use",
        json!({"theme": "at://nowhere"}),
        "is no theme",
    );
    // A record no palette is, as any client could write.
    maker.put_record("dev.knotless.theme", "3broken", json!({"$type": "dev.knotless.theme", "name": "Broken", "palette": {"colorScheme": "sepia"}, "createdAt": "2026-01-01T00:00:00Z"}));
    let broken = format!("at://{}/dev.knotless.theme/3broken", maker.account.did);
    refused(&o, "theme_use", json!({"theme": broken}), "is no palette");
    // Only its maker deletes it.
    refused(&o, "theme_delete", json!({"uri": uri}), "no theme of yours");
    assert_eq!(
        m.json("theme_delete", json!({"uri": uri})),
        json!({"deleted": uri})
    );
    assert_eq!(maker.records("dev.knotless.theme").len(), 1);
    let plain = m.json(
        "theme_publish",
        json!({"name": "Plain paper", "palette": paper}),
    );
    assert!(plain.get("using").is_none(), "{plain}");
    let plain = plain["uri"].as_str().unwrap().to_string();
    m.json("theme_delete", json!({"uri": plain}));

    // The command line, the same.
    let file = maker.path("night.json");
    let night = json!({"colorScheme": "dark", "background": "#0d1117", "text": "#e6edf3",
        "primary": "#2f81f7", "accent": "#58a6ff"});
    std::fs::write(&file, night.to_string()).unwrap();
    let file = file.to_str().unwrap();
    let cli = |args: &[&str], stdin: &[u8]| maker.cli(args, stdin);
    assert_eq!(
        cli(&["theme", "check", file], b""),
        (0, "every text reads at 4.5 to 1 or more".into())
    );
    let record = json!({"name": "Pale", "palette": pale}).to_string();
    // Refused as an error, on stderr, as the binary says it.
    let (ok, _, why) = maker.knotless(&["theme", "check", "-"], record.as_bytes());
    assert!(!ok && why.contains("textMuted on background"), "{why}");
    let (code, uri) = cli(
        &["theme", "publish", "Wombat night", "-", "--use"],
        night.to_string().as_bytes(),
    );
    assert_eq!(code, 0, "{uri}");
    assert!(uri.contains("/dev.knotless.theme/"), "{uri}");
    // Published alone, not used; and not with what it does not know.
    let (code, alone) = cli(
        &["theme", "publish", "Wombat dusk", "-"],
        night.to_string().as_bytes(),
    );
    assert_eq!(code, 0, "{alone}");
    assert_eq!(
        cli(&["theme", "delete", &alone], b""),
        (0, "deleted".into())
    );
    let bogus = cli(
        &["theme", "publish", "Wombat", "-", "--bogus"],
        night.to_string().as_bytes(),
    );
    assert_eq!(bogus.0, 1);
    assert_eq!(
        cli(&["theme", "use", "dracula"], b""),
        (0, "drawn in dracula".into())
    );
    assert_eq!(
        cli(&["theme", "use", file], b""),
        (0, "drawn in your own".into())
    );
    assert_eq!(
        cli(&["theme", "use", "-"], night.to_string().as_bytes()),
        (0, "drawn in your own".into())
    );
    let named = || cli(&["theme", "list", "--find", "wombat"], b"");
    wait_until(|| named().1.starts_with("Wombat night\t"));
    assert_eq!(
        named().1,
        format!("Wombat night\t{}\t{uri}", maker.account.handle)
    );
    let (code, all) = cli(&["theme", "list"], b"");
    assert!(code == 0 && all.contains(&uri), "{all}");
    let (code, mine) = cli(&["theme", "list", &maker.account.handle], b"");
    assert!(code == 0 && mine.contains(&uri), "{mine}");
    assert_eq!(cli(&["theme", "delete", &uri], b""), (0, "deleted".into()));
    let wrong: [&[&str]; 8] = [
        &["themes", "--bogus"],
        &["themes", "a.test", "b.test"],
        &["theme", "frob"],
        &["theme", "publish", "Night", file, "--bogus"],
        &["theme", "check", "/nowhere.json"],
        &["theme", "check", "-"],
        &["theme", "use", "sepia"],
        &["theme"],
    ];
    for args in wrong {
        assert_eq!(cli(args, b"not json").0, 1, "{args:?}");
    }
}

/// An agent reacts to an issue and a comment, sees the counts the appview
/// keeps in issue_show, and takes a reaction back; the command line does
/// the same.
#[test]
fn agents_react() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    let mut fan = logged_in();
    fan.set("KNOTLESS_APPVIEW", &base);
    let (o, f) = (Client::new(&owner), Client::new(&fan));
    let repo = c_repo(&o.json("repo_create", json!({"name": "reacted"})));
    o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let issue = o.json("issue_create", json!({"repo": repo, "title": "Shiny"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let said = o.json(
        "comment_add",
        json!({"repo": repo, "uri": issue, "body": "look"}),
    )["uri"]
        .as_str()
        .unwrap()
        .to_string();

    let rocket = json!({"repo": repo, "uri": issue, "content": "rocket"});
    let reacted = f.json("issue_react", rocket.clone());
    assert_eq!(reacted["reacted"], true);
    let uri = reacted["uri"].as_str().unwrap().to_string();
    assert!(uri.starts_with(&format!("at://{}/dev.knotless.reaction/", fan.account.did)));
    assert_eq!(f.json("issue_react", rocket)["uri"], uri.as_str());
    assert_eq!(fan.records("dev.knotless.reaction").len(), 1);
    let hooray = json!({"repo": repo, "uri": said, "content": "hooray"});
    let on_comment = f.json("comment_react", hooray);
    assert_eq!(on_comment["reacted"], true);
    // The command line reacts too.
    let url = format!("knotless://{repo}");
    let (code, out) = fan.cli(&["comment", "react", &url, &said, "hooray"], b"");
    assert_eq!(code, 0, "{out}");
    assert!(out.starts_with("at://"), "{out}");
    let (code, out) = owner.cli(&["comment", "react", &url, &said, "hooray"], b"");
    assert_eq!(code, 0, "{out}");

    // Both show what the appview counts, once it has them.
    wait_until(|| {
        let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
        shown["reactions"] == json!({"rocket": 1})
            && shown["comments"][0]["reactions"] == json!({"hooray": 2})
    });
    let (code, shown) = owner.cli(&["pull", "show", &url, &issue], b"");
    assert_eq!(code, 0, "{shown}");
    assert!(shown.contains("reactions: rocket 1"), "{shown}");
    assert!(shown.contains("reactions: hooray 2"), "{shown}");

    // Taken back, over MCP and on the command line.
    let undo = json!({"repo": repo, "uri": issue, "content": "rocket", "undo": true});
    assert_eq!(
        f.json("issue_react", undo),
        json!({"reacted": false, "uri": null})
    );
    let undo = [
        "comment",
        "react",
        url.as_str(),
        said.as_str(),
        "hooray",
        "--undo",
    ];
    assert_eq!(fan.cli(&undo, b""), (0, "taken back".to_string()));
    assert!(fan.records("dev.knotless.reaction").is_empty());
    wait_until(|| {
        let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
        shown["reactions"].is_null() && shown["comments"][0]["reactions"] == json!({"hooray": 1})
    });

    // One of the eight, to a pull, an issue or a comment on the repository.
    let tada = json!({"repo": repo, "uri": issue, "content": "tada"});
    let (why, err) = f.call("issue_react", tada);
    assert!(err && why.contains("+1 -1 laugh"), "{why}");
    let record = format!("at://{}/dev.knotless.repo/reacted", owner.account.did);
    let (why, err) = f.call(
        "pull_react",
        json!({"repo": repo, "uri": record, "content": "heart"}),
    );
    assert!(
        err && why.contains("is not a pull, an issue or a comment"),
        "{why}"
    );
    let missing = format!("at://{}/dev.knotless.issue/3zzzzzzzzzzzz", fan.account.did);
    let (why, err) = f.call(
        "comment_react",
        json!({"repo": repo, "uri": missing, "content": "heart"}),
    );
    assert!(err && why.contains("no pull, issue or comment"), "{why}");
    let bogus = [
        "comment",
        "react",
        url.as_str(),
        said.as_str(),
        "heart",
        "--bogus",
    ];
    assert_eq!(fan.cli(&bogus, b"").0, 1);
    if net().spaces {
        // On a private repository, a reaction is in its space, with what
        // it is about, and counted from there: none in the account's own.
        o.json("repo_create", json!({"name": "hush", "private": true}));
        let hush = format!("{}/hush", owner.account.handle);
        let quiet = o.json("issue_create", json!({"repo": hush, "title": "Quiet"}))["uri"]
            .as_str()
            .unwrap()
            .to_string();
        let public = owner.records("dev.knotless.reaction").len();
        let heart = json!({"repo": hush, "uri": quiet, "content": "heart"});
        let reacted = o.json("issue_react", heart.clone());
        assert_eq!(reacted["reacted"], true);
        assert_eq!(o.json("issue_react", heart)["uri"], reacted["uri"]);
        assert_eq!(owner.records("dev.knotless.reaction").len(), public);
        let shown = o.json("pull_show", json!({"repo": hush, "uri": quiet}));
        assert_eq!(shown["reactions"], json!({"heart": 1}));
        let hushed = format!("knotless://{hush}");
        let undo = [
            "issue",
            "react",
            hushed.as_str(),
            quiet.as_str(),
            "heart",
            "--undo",
        ];
        assert_eq!(owner.cli(&undo, b""), (0, "taken back".to_string()));
        let shown = o.json("pull_show", json!({"repo": hush, "uri": quiet}));
        assert!(shown["reactions"].is_null(), "{shown}");
    }
}

#[test]
fn agents_publish_releases() {
    let s = logged_in();
    let c = Client::new(&s);
    let repo = format!("{}/shipped", s.account.handle);
    c.ok(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a"}]}),
    );
    let big = vec![b'x'; (1 << 20) + 1];
    use base64::Engine;
    let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
    let published = c.json(
        "release_publish",
        json!({"repo": repo, "package": "tool", "version": "1.0.0", "notes": "First.", "commit": "c".repeat(40), "files": [
            {"name": "README", "content": "read me\n"},
            {"name": "tool.bin", "base64": b64(&[0, 1, 2, 255])},
            {"name": "big.txt", "base64": b64(&big)},
        ]}),
    );
    assert!(
        published["uri"]
            .as_str()
            .unwrap()
            .contains("/dev.knotless.release/")
    );
    c.ok(
        "release_publish",
        json!({"repo": repo, "package": "tool", "version": "1.1.0-rc.1", "files": [{"name": "README", "content": "next\n"}]}),
    );
    c.ok(
        "release_publish",
        json!({"repo": repo, "package": "other", "version": "0.1.0", "files": [{"name": "x", "content": "x"}]}),
    );
    for (files, says) in [
        (
            json!([{"name": "a", "content": "x", "base64": "eA=="}]),
            "exactly one",
        ),
        (json!([{"name": "a"}]), "exactly one"),
        (json!([{"name": "a", "base64": "!!"}]), "Invalid"),
    ] {
        let (text, err) = c.call(
            "release_publish",
            json!({"repo": repo, "package": "tool", "version": "2.0.0", "files": files}),
        );
        assert!(err && text.contains(says), "{text}");
    }
    let (text, err) = c.call(
        "release_publish",
        json!({"repo": repo, "package": "tool", "version": "1.0.0", "files": [{"name": "a", "content": "x"}]}),
    );
    assert!(err && text.contains("published already"), "{text}");

    // A yanked version is marked and is not the latest, until undone.
    let tool = json!({"repo": repo, "package": "tool", "version": "1.0.0"});
    let uri = c.json("release_yank", tool.clone())["uri"].clone();
    assert!(uri.as_str().unwrap().contains("/dev.knotless.release/"));
    let yanked = c.json("release_list", json!({"repo": repo, "package": "tool"}));
    let stable = yanked
        .as_array()
        .unwrap()
        .iter()
        .find(|r| r["version"] == "1.0.0")
        .unwrap();
    assert_eq!(
        (&stable["yanked"], &stable["latest"]),
        (&json!(true), &json!(false))
    );
    let mut undone = tool;
    undone["undo"] = json!(true);
    assert_eq!(c.json("release_yank", undone)["uri"], uri);

    // By package, highest first; the pre-release is never the latest.
    let all = c.json("release_list", json!({"repo": repo}));
    let listed: Vec<(&str, &str, bool)> = all
        .as_array()
        .unwrap()
        .iter()
        .map(|r| {
            let (p, v) = (
                r["package"].as_str().unwrap(),
                r["version"].as_str().unwrap(),
            );
            (p, v, r["latest"].as_bool().unwrap())
        })
        .collect();
    assert_eq!(
        listed,
        [
            ("other", "0.1.0", true),
            ("tool", "1.1.0-rc.1", false),
            ("tool", "1.0.0", true)
        ]
    );
    let tool = c.json("release_list", json!({"repo": repo, "package": "tool"}));
    assert_eq!(tool.as_array().unwrap().len(), 2);
    let first = &tool[1];
    assert_eq!(
        (first["notes"].as_str(), first["commit"].as_str()),
        (Some("First."), Some("c".repeat(40).as_str()))
    );
    assert_eq!(
        first["files"][1],
        json!({"name": "tool.bin", "size": 4, "sha256": knotless::release::sha256(&[0, 1, 2, 255])})
    );

    // A file of the latest, or of a version named, as text or base64.
    let read = |args: Value| c.json("release_read", args);
    let latest = read(json!({"repo": repo, "package": "tool", "file": "README"}));
    assert_eq!(latest, json!({"version": "1.0.0", "content": "read me\n"}));
    let rc =
        read(json!({"repo": repo, "package": "tool", "version": "1.1.0-rc.1", "file": "README"}));
    assert_eq!(rc["content"], "next\n");
    let bin = read(json!({"repo": repo, "package": "tool", "file": "tool.bin"}));
    assert_eq!(bin["base64"], b64(&[0, 1, 2, 255]));
    for (args, says) in [
        (
            json!({"package": "tool", "version": "9.9.9", "file": "README"}),
            "has published no tool 9.9.9",
        ),
        (
            json!({"package": "none", "file": "README"}),
            "has published no none latest",
        ),
        (
            json!({"package": "tool", "file": "missing"}),
            "has no file missing",
        ),
        (
            json!({"package": "tool", "file": "big.txt"}),
            "knotless release download",
        ),
    ] {
        let mut args = args;
        args["repo"] = json!(repo);
        let (text, err) = c.call("release_read", args);
        assert!(err && text.contains(says), "{text}");
    }
}

/// An account that invited no one is no organization; inviting takes a
/// gateway to act through, as the command line's does, and leaving takes
/// back the join record whatever the gateway does.
#[test]
fn organizations_over_mcp() {
    // Who is in an organization is read from the appview.
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut s = logged_in();
    s.set("KNOTLESS_APPVIEW", &base);
    let other = account();
    let c = Client::new(&s);
    let me = s.account.handle.clone();
    let none = |did: &str, name: &str, inside: bool, changed: Value| json!({"org": did, "name": name, "organization": false, "inside": inside, "entries": [], "changed": changed});
    let mine = |changed| none(&s.account.did, &me, true, changed);
    assert_eq!(c.json("org_members", json!({})), mine(Value::Null));
    let removed = json!({"org": me, "who": other.handle, "organization": false});
    assert_eq!(
        c.json("org_remove", json!({"handle": other.handle})),
        mine(removed)
    );
    let deleted = json!({"removed": []});
    assert_eq!(c.json("org_delete", json!({})), mine(deleted.clone()));
    assert_eq!(c.json("org_delete", json!({"yes": true})), mine(deleted));
    let theirs = none(&other.did, &other.handle, false, Value::Null);
    assert_eq!(c.json("org_members", json!({"org": other.handle})), theirs);
    let public = c.json("org_members", json!({"org": other.handle, "public": true}));
    assert_eq!(public, theirs);
    let (text, err) = c.call("org_add", json!({"handle": other.handle, "admin": true}));
    assert!(
        err && text.ends_with("delegates its writes with the account_delegate tool"),
        "{text}"
    );
    let (text, err) = c.call("org_join", json!({"org": other.handle}));
    assert!(
        err && text.contains("exactly one of public or hidden"),
        "{text}"
    );
    let (text, err) = c.call("org_join", json!({"org": other.handle, "public": true}));
    assert!(err && text.contains("is not an organization"), "{text}");
    let (text, err) = c.call("org_join", json!({"org": other.handle, "hidden": true}));
    assert!(err, "{text}");
    let (text, err) = c.call("org_leave", json!({"org": other.handle}));
    assert!(err && text.contains("you are not a member of"), "{text}");
    // Memberships in public: none yet.
    assert_eq!(c.json("org_list", json!({})), json!({"organizations": []}));
    // An account that keeps an organization record is one, so ending it
    // asks first.
    s.put_record(
        "dev.knotless.org",
        "self",
        json!({"$type": "dev.knotless.org", "createdAt": "2026-10-28T00:00:00Z"}),
    );
    wait_until(|| c.json("org_members", json!({}))["organization"] == true);
    let (text, err) = c.call("org_delete", json!({}));
    assert!(err && text.contains("call again with yes"), "{text}");
    let ended = c.json("org_delete", json!({"yes": true}));
    assert_eq!(ended["changed"], json!({"removed": []}), "{ended}");
    // A join record left behind goes, though no gateway takes the rest.
    let joined = json!({"$type": "dev.knotless.orgAcceptance", "subject": other.did, "createdAt": "2026-10-28T00:00:00Z"});
    s.put_record("dev.knotless.orgAcceptance", &other.did, joined);
    let left = c.json("org_leave", json!({"org": other.handle}));
    assert_eq!(left["changed"]["left"], json!(other.handle));
    let warning = left["changed"]["warning"].as_str().unwrap_or_default();
    assert!(warning.contains("acts through no gateway"), "{left}");
    assert!(s.records("dev.knotless.orgAcceptance").is_empty());
}

/// Pulls, issues, a pull's diff and files are resources as well as tools,
/// by URIs that mirror their pages, each read as its tool answers.
#[test]
fn agents_read_resources() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt, "");
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    let c = Client::new(&owner);

    let info = c.peer.peer_info().unwrap();
    assert!(info.capabilities.resources.is_some(), "{info:?}");
    assert!(info.capabilities.tools.is_some(), "{info:?}");
    let templates = c.rt.block_on(c.peer.list_all_resource_templates()).unwrap();
    let listed: Vec<(&str, &str, Option<&str>)> = templates
        .iter()
        .map(|t| {
            assert!(t.title.is_some() && t.description.is_some(), "{t:?}");
            (
                t.name.as_str(),
                t.uri_template.as_str(),
                t.mime_type.as_deref(),
            )
        })
        .collect();
    let (json, text) = (Some("application/json"), Some("text/plain"));
    assert_eq!(
        listed,
        [
            ("pulls", "knotless://{owner}/{repo}/pulls", json),
            ("issues", "knotless://{owner}/{repo}/issues", json),
            ("pull", "knotless://{owner}/{repo}/pull/{did}/{rkey}", json),
            (
                "pull_diff",
                "knotless://{owner}/{repo}/pull/{did}/{rkey}/diff",
                text
            ),
            (
                "issue",
                "knotless://{owner}/{repo}/issue/{did}/{rkey}",
                json
            ),
            ("file", "knotless://{owner}/{repo}/file/{rev}/{+path}", text),
        ]
    );
    // Only templates: there are too many pulls and files to list.
    assert!(
        c.rt.block_on(c.peer.list_all_resources())
            .unwrap()
            .is_empty()
    );

    let repo = c_repo(&c.json("repo_create", json!({"name": "resourced"})));
    c.json(
        "repo_commit",
        json!({"repo": repo, "branch": "main", "message": "start", "changes": [
            {"path": "README.md", "content": "hello\n"},
            {"path": "logo.bin", "base64": "AAEC/w=="},
        ]}),
    );
    c.json("branch_create", json!({"repo": repo, "name": "topic/more"}));
    c.json(
        "repo_commit",
        json!({"repo": repo, "branch": "topic/more", "message": "more", "changes": [
            {"path": "docs/a b.md", "content": "on the branch\n"},
        ]}),
    );
    let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "topic/more", "title": "More", "body": "more"});
    let pull = c.json("pull_create", opened)["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let issue = c.json("issue_create", json!({"repo": repo, "title": "Less"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    c.json(
        "comment_add",
        json!({"repo": repo, "uri": pull, "body": "looks right"}),
    );
    // The lists from the appview, once it has the pull, its comment and
    // the issue, so a tool and its resource read the same.
    let all = |tool: &str| c.json(tool, json!({"repo": repo}));
    wait_until(|| {
        let shown = c.json("pull_show", json!({"repo": repo, "uri": pull}));
        let pulls = all("pull_list");
        pulls[0]["comments"] == 1
            && all("issue_list").as_array().unwrap().len() == 1
            && shown["comments"]
                .as_array()
                .is_some_and(|said| said.len() == 1)
    });

    // Each as its tool answers, JSON or text.
    let page = |uri: &str| {
        let parts: Vec<&str> = uri.strip_prefix("at://").unwrap().split('/').collect();
        format!("{}/{}", parts[0], parts[2])
    };
    let url = format!("knotless://{repo}");
    let as_tool = |uri: String, tool: &str, args: Value| {
        let read = c.read(&uri).unwrap();
        assert_eq!(read["uri"], uri.as_str());
        let said = c.ok(tool, args);
        match read["mimeType"].as_str().unwrap() {
            "application/json" => {
                let got: Value = serde_json::from_str(read["text"].as_str().unwrap()).unwrap();
                assert_eq!(got, serde_json::from_str::<Value>(&said).unwrap(), "{uri}");
            }
            mime => {
                assert_eq!(mime, "text/plain", "{uri}");
                assert_eq!(read["text"], said.as_str(), "{uri}");
            }
        }
        read
    };
    let subject = json!({"repo": repo, "uri": pull});
    as_tool(format!("{url}/pulls"), "pull_list", json!({"repo": repo}));
    as_tool(format!("{url}/issues"), "issue_list", json!({"repo": repo}));
    let shown = as_tool(
        format!("{url}/pull/{}", page(&pull)),
        "pull_show",
        subject.clone(),
    );
    assert!(shown["text"].as_str().unwrap().contains("looks right"));
    let diff = as_tool(
        format!("{url}/pull/{}/diff", page(&pull)),
        "pull_diff",
        subject,
    );
    assert!(
        diff["text"]
            .as_str()
            .unwrap()
            .contains("  1 | +on the branch")
    );
    as_tool(
        format!("{url}/issue/{}", page(&issue)),
        "issue_show",
        json!({"repo": repo, "uri": issue}),
    );
    // A branch's `/` in the revision, and a space in the path, escaped as a
    // template's expansion escapes them; the owner by DID, its `:` too.
    let did = owner.account.did.replace(':', "%3A");
    let read = as_tool(
        format!("knotless://{did}/resourced/file/topic%2Fmore/docs/a%20b.md"),
        "repo_read",
        json!({"repo": repo, "rev": "topic/more", "path": "docs/a b.md"}),
    );
    assert_eq!(read["text"], "on the branch\n");
    // A binary file as a blob.
    let logo = c.read(&format!("{url}/file/main/logo.bin")).unwrap();
    assert_eq!(logo["blob"], "AAEC/w==");
    assert_eq!(logo["mimeType"], "application/octet-stream");

    // A read that fails says what the tool would.
    let gone = c.read(&format!("{url}/file/main/gone.md")).unwrap_err();
    let (said, err) = c.call("repo_read", json!({"repo": repo, "path": "gone.md"}));
    assert!(err);
    assert_eq!(gone.message, said);
    // A URI no template makes names those that are.
    for bad in [
        format!("https://knotless.dev/{repo}/pulls"),
        url.clone(),
        format!("{url}/pulls/"),
        format!("{url}/pull/{}/patch", page(&pull)),
        format!("{url}/issue/{}/diff", page(&issue)),
        format!("{url}/file/main"),
        format!("{url}/file/main//a"),
        format!("{url}/file/%FF/README.md"),
    ] {
        let err = c.read(&bad).unwrap_err();
        assert_eq!(
            err.code,
            rmcp::model::ErrorCode::RESOURCE_NOT_FOUND,
            "{bad}"
        );
        assert!(
            err.message.contains(&bad)
                && err
                    .message
                    .contains("knotless://{owner}/{repo}/file/{rev}/{+path}"),
            "{err:?}"
        );
    }
}