use assert_cmd::Command;
use std::fs;
use std::path::Path;
const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"
[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]
[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"
[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
"#;
const CONFORMING: &str = "import { UserSchema } from \"../../generated/user.zod.gen\";\n\
export async function POST(req: Request) {\n \
const body = UserSchema.parse(await req.json());\n \
return Response.json({ name: body.name });\n}\n";
fn repo() -> Option<tempfile::TempDir> {
let dir = tempfile::tempdir().ok()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
Some(dir)
}
fn board(dir: &Path, run: &str, agent: &str, args: &[&str]) -> Option<std::process::Output> {
let mut all = vec!["board"];
all.extend_from_slice(args);
all.extend_from_slice(&["--run", run, "--agent", agent]);
Command::cargo_bin("pushkin")
.ok()?
.args(all)
.current_dir(dir)
.output()
.ok()
}
fn stdout_of(output: &std::process::Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn hook_write(dir: &Path, run: &str, agent: &str, file_path: &str) -> Option<String> {
let payload = serde_json::json!({
"session_id": agent,
"tool_name": "Write",
"tool_input": { "file_path": file_path, "content": CONFORMING }
})
.to_string();
let output = Command::cargo_bin("pushkin")
.ok()?
.args(["hook", "claude"])
.env("PUSHKIN_RUN_ID", run)
.env("PUSHKIN_AGENT_ID", agent)
.current_dir(dir)
.write_stdin(payload)
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.trim().is_empty() {
return None;
}
let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
Some(
json["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()?
.to_owned(),
)
}
#[test]
fn register_then_peers_lists_agent_scoped_by_run() {
let dir = repo().unwrap();
let out = board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
assert!(out.status.success(), "register must succeed");
board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
board(dir.path(), "run-2", "agent-c", &["register"]).unwrap();
let peers = stdout_of(&board(dir.path(), "run-1", "agent-a", &["peers"]).unwrap());
assert!(peers.contains("agent-b"), "same-run peer listed: {peers}");
assert!(
!peers.contains("agent-c"),
"other-run agent must not appear: {peers}"
);
}
#[test]
fn broadcast_read_advances_cursor() {
let dir = repo().unwrap();
board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
board(
dir.path(),
"run-1",
"agent-a",
&["broadcast", "--message", "contracts frozen for release"],
)
.unwrap();
let first = stdout_of(&board(dir.path(), "run-1", "agent-b", &["read"]).unwrap());
assert!(
first.contains("contracts frozen for release"),
"first read must deliver the broadcast: {first}"
);
let second = stdout_of(&board(dir.path(), "run-1", "agent-b", &["read"]).unwrap());
assert!(
!second.contains("contracts frozen for release"),
"auto-cursor: second read must not redeliver: {second}"
);
}
#[test]
fn send_is_directed_and_read_by_recipient_only() {
let dir = repo().unwrap();
for agent in ["agent-a", "agent-b", "agent-c"] {
board(dir.path(), "run-1", agent, &["register"]).unwrap();
}
board(
dir.path(),
"run-1",
"agent-a",
&[
"send",
"--to",
"agent-b",
"--message",
"take the users route",
],
)
.unwrap();
let b_read = stdout_of(&board(dir.path(), "run-1", "agent-b", &["read"]).unwrap());
assert!(
b_read.contains("take the users route"),
"recipient must receive the direct message: {b_read}"
);
let c_read = stdout_of(&board(dir.path(), "run-1", "agent-c", &["read"]).unwrap());
assert!(
!c_read.contains("take the users route"),
"non-recipient must not see a directed message: {c_read}"
);
}
#[test]
fn status_verb_records_and_peers_shows_it() {
let dir = repo().unwrap();
board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
board(
dir.path(),
"run-1",
"agent-a",
&["status", "--message", "working app/api/users"],
)
.unwrap();
board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
let peers = stdout_of(&board(dir.path(), "run-1", "agent-b", &["peers"]).unwrap());
assert!(
peers.contains("working app/api/users"),
"peers must surface the last status: {peers}"
);
}
#[test]
fn claim_blocks_other_agents_write() {
let dir = repo().unwrap();
board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
let out = board(
dir.path(),
"run-1",
"agent-a",
&["claim", "--path", "app/api/users/**"],
)
.unwrap();
assert!(out.status.success(), "claim must succeed");
assert!(
hook_write(dir.path(), "run-1", "agent-a", "app/api/users/route.ts").is_none(),
"the claim holder's own write must pass"
);
let reason = hook_write(dir.path(), "run-1", "agent-b", "app/api/users/route.ts")
.expect("write to a claimed path by another agent must deny");
assert!(
reason.contains("agent-a"),
"denial must name the claim holder: {reason}"
);
}
#[test]
fn release_unblocks_claimed_path() {
let dir = repo().unwrap();
board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
board(
dir.path(),
"run-1",
"agent-a",
&["claim", "--path", "app/api/users/**"],
)
.unwrap();
let out = board(
dir.path(),
"run-1",
"agent-a",
&["release", "--path", "app/api/users/**"],
)
.unwrap();
assert!(out.status.success(), "release must succeed");
assert!(
hook_write(dir.path(), "run-1", "agent-b", "app/api/users/route.ts").is_none(),
"after release the other agent's write must pass"
);
}
#[test]
fn claims_do_not_cross_run_ids() {
let dir = repo().unwrap();
board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
board(
dir.path(),
"run-1",
"agent-a",
&["claim", "--path", "app/api/users/**"],
)
.unwrap();
assert!(
hook_write(dir.path(), "run-2", "agent-z", "app/api/users/route.ts").is_none(),
"a claim in run-1 must not gate run-2"
);
}
#[test]
fn board_survives_process_restart() {
let dir = repo().unwrap();
board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
board(
dir.path(),
"run-1",
"agent-a",
&["claim", "--path", "app/api/users/**"],
)
.unwrap();
let claims = stdout_of(&board(dir.path(), "run-1", "agent-a", &["claims"]).unwrap());
assert!(
claims.contains("app/api/users/**"),
"claims must persist on disk across processes: {claims}"
);
assert!(
Path::new(&dir.path().join(".pushkin/board.db")).exists(),
"board lives in .pushkin/board.db (spec §10: embedded SQLite only)"
);
}