use super::*;
use agent_core::config::{MemoryBackendConfig, MemoryBackendKind};
use agent_core::memory::store::ProjectScope;
use serde_json::Value;
use std::{path::Path, sync::Arc};
fn binding(base: &Path, scope: ProjectScope, exe: &Path) -> MemoryBinding {
MemoryBinding::new(
base.into(),
Ok(scope),
&MemoryBackendConfig {
kind: MemoryBackendKind::Axel,
executable: Some(exe.into()),
brain: Some(base.join("brain.r8")),
user_scope: true,
},
)
}
fn post(key: &str, thread: Option<String>) -> Post {
Post {
request_key: key.into(),
title: if thread.is_some() {
String::new()
} else {
"Shared findings".into()
},
thread_id: thread,
reply_to: None,
body: format!("synthetic forum finding {key}"),
retention_days: 30,
}
}
fn private_write(path: &Path, bytes: &[u8]) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, bytes).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[tokio::test]
#[ignore = "requires SYNAPS_AXEL_TEST_BIN; isolated synthetic repository/brain only"]
async fn peers_concurrent_posts_scope_paging_export_and_deletion() {
let exe = std::path::PathBuf::from(
std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit service path"),
);
let temp = tempfile::tempdir().unwrap();
let base = temp.path().canonicalize().unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let repo = base.join("repo");
std::fs::create_dir(&repo).unwrap();
let scope = ProjectScope::for_root(&repo).unwrap();
let root = binding(&base, scope.clone(), &exe);
let first = root.forum_post(post("root", None)).await.unwrap();
assert_eq!(first.status, Status::Created);
assert_eq!(
root.forum_post(post("root", None)).await.unwrap().status,
Status::Duplicate
);
let mut jobs = Vec::new();
for n in 0..8 {
let peer = binding(&base, scope.clone(), &exe).fork_for_worker();
let thread = first.id.clone();
jobs.push(tokio::spawn(async move {
peer.forum_post(post(&format!("peer-{n}"), Some(thread)))
.await
.unwrap()
}));
}
for job in jobs {
assert_eq!(job.await.unwrap().status, Status::Created);
}
let query = Read {
thread_id: Some(first.id.clone()),
limit: 3,
..Default::default()
};
let mut q = query.clone();
let mut ids = std::collections::BTreeSet::new();
loop {
let page = root.forum_read(q.clone()).await.unwrap();
for e in &page.entries {
assert!(ids.insert(e.id.clone()));
}
match page.next {
Some(c) => q.after = Some(c),
None => break,
}
}
assert_eq!(ids.len(), 9);
assert!(root.search(Default::default()).await.unwrap().is_empty());
assert!(root.fetch(&[&first.id]).await.is_err());
let foreign_path = base.join("foreign");
std::fs::create_dir(&foreign_path).unwrap();
let foreign = root.with_scope(ProjectScope::for_root(&foreign_path).unwrap());
assert!(foreign
.forum_read(query.clone())
.await
.unwrap()
.entries
.is_empty());
assert!(foreign
.forum_post(post("foreign", Some(first.id.clone())))
.await
.is_err());
assert!(foreign.forum_forget(&first.id).await.is_err());
assert!(root
.for_user_notes()
.unwrap()
.forum_read(Read::default())
.await
.is_err());
let export = root.rpc("export", json!({"full":true})).await.unwrap();
assert_eq!(export["records"].as_array().unwrap().len(), 9);
let export_path = base.join("export.json");
private_write(&export_path, &serde_json::to_vec(&export).unwrap());
let restored_dir = base.join("restored");
std::fs::create_dir(&restored_dir).unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&restored_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let restored = binding(&restored_dir, scope.clone(), &exe);
let source = super::super::migration::MigrationSource::Export {
path: export_path,
project: scope.key().into(),
};
let preview = super::super::migration::preview_from(&restored, &source)
.await
.unwrap();
super::super::migration::apply_from(&restored, &source, &preview.manifest_digest)
.await
.unwrap();
let all = Read {
thread_id: Some(first.id.clone()),
limit: 16,
..Default::default()
};
assert_eq!(
root.forum_read(all.clone()).await.unwrap(),
restored.forum_read(all.clone()).await.unwrap()
);
root.forum_forget(&first.id).await.unwrap();
assert_eq!(
root.forum_post(post("root", None)).await.unwrap().status,
Status::Tombstoned
);
assert!(root
.forum_post(post("new", Some(first.id.clone())))
.await
.is_err());
assert_eq!(root.forum_read(all.clone()).await.unwrap().entries.len(), 8);
let deleted_export = root.rpc("export", json!({"full":true})).await.unwrap();
let deletion_path = base.join("deletion-export.json");
private_write(
&deletion_path,
&serde_json::to_vec(&deleted_export).unwrap(),
);
let deletion = super::super::migration::MigrationSource::Export {
path: deletion_path,
project: scope.key().into(),
};
let preview = super::super::migration::preview_from(&restored, &deletion)
.await
.unwrap();
super::super::migration::apply_from(&restored, &deletion, &preview.manifest_digest)
.await
.unwrap();
assert_eq!(restored.forum_read(all).await.unwrap().entries.len(), 8);
let old_preview = super::super::migration::preview_from(&restored, &source)
.await
.unwrap();
super::super::migration::apply_from(&restored, &source, &old_preview.manifest_digest)
.await
.unwrap();
assert!(restored
.forum_read(Read::default())
.await
.unwrap()
.entries
.is_empty());
}
#[tokio::test]
#[ignore = "requires SYNAPS_AXEL_TEST_BIN; synthetic git worktree only"]
async fn repository_worktrees_share_forum_identity_after_reopen() {
fn git(root: &Path, args: &[&str]) {
assert!(std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap()
.status
.success());
}
let exe = std::path::PathBuf::from(std::env::var_os("SYNAPS_AXEL_TEST_BIN").unwrap());
let temp = tempfile::tempdir().unwrap();
let base = temp.path().canonicalize().unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let repo = base.join("repo");
let worktree = base.join("worktree");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "--quiet"]);
git(
&repo,
&[
"-c",
"user.name=Synthetic",
"-c",
"user.email=syn@example.invalid",
"commit",
"--allow-empty",
"-qm",
"initial",
],
);
git(
&repo,
&["worktree", "add", "-qb", "peer", worktree.to_str().unwrap()],
);
let first = ProjectScope::discover_repository_with_override(&repo, None).unwrap();
let second = ProjectScope::discover_repository_with_override(&worktree, None).unwrap();
assert_eq!(first.scope.key(), second.scope.key());
let root = binding(&base, first.scope.clone(), &exe);
let peer = binding(&base, second.scope, &exe);
let id = root.forum_post(post("root", None)).await.unwrap().id;
assert_eq!(
peer.forum_read(Read::default()).await.unwrap().entries[0].id,
id
);
let reopened = binding(&base, first.scope, &exe);
assert_ne!(root.forum_author(), reopened.forum_author());
assert_eq!(
reopened.forum_read(Read::default()).await.unwrap().entries[0].id,
id
);
let fork = root.fork_for_worker();
assert!(Arc::ptr_eq(&root.0.operation_lock, &fork.0.operation_lock));
}
#[tokio::test]
#[ignore = "requires SYNAPS_AXEL_TEST_BIN; synthetic tool-boundary forum roundtrip"]
async fn nullable_tool_arguments_roundtrip_and_rejections_are_actionable() {
use crate::tools::{
forum::{ForumForgetTool, ForumPostTool, ForumReadTool},
Tool, ToolContext,
};
fn context(binding: &MemoryBinding) -> ToolContext {
let mut context = crate::tools::test_helpers::create_tool_context();
context.capabilities.memory_backend = Some(binding.clone());
context
}
fn result(output: String) -> Value {
let (banner, body) = output.split_once('\n').unwrap();
assert_eq!(banner, crate::tools::forum::LOWER_AUTHORITY_HEADER);
serde_json::from_str::<Value>(body).unwrap()["result"].clone()
}
let exe = std::path::PathBuf::from(std::env::var_os("SYNAPS_AXEL_TEST_BIN").unwrap());
let temp = tempfile::tempdir().unwrap();
let base = temp.path().canonicalize().unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let host = binding(&base, ProjectScope::for_root(&base).unwrap(), &exe);
let worker = host.fork_for_worker();
let input = json!({"request_key":"null-root","title":"Root","body":"Synthetic note", "thread_id":null,"reply_to":null,"retention_days":null,"project":null});
let receipt = result(
ForumPostTool
.execute(input.clone(), context(&worker))
.await
.unwrap(),
);
assert_eq!(receipt["status"], "created");
assert_eq!(
result(
ForumPostTool
.execute(input, context(&worker))
.await
.unwrap()
)["status"],
"duplicate"
);
let root_id = receipt["thread_id"].as_str().unwrap();
let list = json!({"thread_id":null,"after":null,"query":null,"limit":null,"project":null});
let page = result(
ForumReadTool
.execute(list.clone(), context(&host))
.await
.unwrap(),
);
assert_eq!(page["entries"].as_array().unwrap().len(), 1);
assert_eq!(page["entries"][0]["id"], root_id);
let reply = result(ForumPostTool.execute(json!({"request_key":"reply","title":null,"body":"Synthetic reply", "thread_id":root_id,"reply_to":null,"retention_days":null,"project":null}), context(&host)).await.unwrap());
assert_eq!(reply["status"], "created");
let thread = json!({"thread_id":root_id,"after":null,"query":null,"limit":null,"project":null});
assert_eq!(
result(
ForumReadTool
.execute(thread.clone(), context(&worker))
.await
.unwrap()
)["entries"]
.as_array()
.unwrap()
.len(),
2
);
for project in ["", "p0000000000000000", "p1234567890123456"] {
let mut bad = list.clone();
bad["project"] = json!(project);
assert!(ForumReadTool
.execute(bad, context(&worker))
.await
.unwrap_err()
.to_string()
.contains("project confirmation"));
}
for digit in ['0', 'f'] {
let fake = format!("msg-{}", digit.to_string().repeat(64));
let e = ForumReadTool
.execute(json!({"thread_id":fake}), context(&worker))
.await
.unwrap_err()
.to_string();
assert!(e.contains("placeholder"));
}
let missing = format!("msg-{}", "12".repeat(32));
let e = ForumPostTool.execute(json!({"request_key":"missing-parent","body":"Synthetic rejected reply","thread_id":missing}), context(&worker)).await.unwrap_err().to_string();
assert!(e.contains("[not_found]"), "{e}");
assert!(e.contains("no success receipt"), "{e}");
assert!(e.contains("exact live thread ID"), "{e}");
assert_eq!(
result(
ForumReadTool
.execute(list.clone(), context(&worker))
.await
.unwrap()
)["entries"]
.as_array()
.unwrap()
.len(),
1
);
let deleted = result(
ForumForgetTool
.execute(json!({"id":reply["id"],"project":null}), context(&host))
.await
.unwrap(),
);
assert_eq!(deleted["status"], "tombstoned");
assert_eq!(
result(
ForumReadTool
.execute(thread, context(&worker))
.await
.unwrap()
)["entries"]
.as_array()
.unwrap()
.len(),
1
);
let reopened = binding(&base, ProjectScope::for_root(&base).unwrap(), &exe);
assert_eq!(
result(
ForumReadTool
.execute(list, context(&reopened))
.await
.unwrap()
)["entries"][0]["id"],
root_id
);
}