Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: C++ tests

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Check out source
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake g++ libcurl4-openssl-dev nlohmann-json3-dev

- name: Configure
run: cmake -S . -B build -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release

- name: Build
run: cmake --build build --parallel 2

- name: Test
run: ctest --test-dir build --output-on-failure --no-tests=error
38 changes: 37 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,40 @@ target_compile_options(agentforge PRIVATE
target_link_libraries(agentforge PRIVATE
CURL::libcurl
nlohmann_json::nlohmann_json
)
)

include(CTest)

if(BUILD_TESTING)
add_executable(tool_request_parser_tests
tests/tool_request_parser_tests.cpp
src/tool_request_parser.cpp
)

target_include_directories(tool_request_parser_tests PRIVATE src)
target_compile_features(tool_request_parser_tests PRIVATE cxx_std_20)
set_target_properties(tool_request_parser_tests PROPERTIES
CXX_EXTENSIONS OFF
)

add_test(
NAME tool_request_parser
COMMAND tool_request_parser_tests
)

add_executable(file_tool_tests
tests/file_tool_tests.cpp
src/file_tool.cpp
)

target_include_directories(file_tool_tests PRIVATE src)
target_compile_features(file_tool_tests PRIVATE cxx_std_20)
set_target_properties(file_tool_tests PROPERTIES
CXX_EXTENSIONS OFF
)

add_test(
NAME workspace_file_tool
COMMAND file_tool_tests
)
endif()
140 changes: 140 additions & 0 deletions tests/file_tool_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#include "file_tool.hpp"

#include <exception>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <random>
#include <stdexcept>
#include <string>
#include <system_error>

namespace {

namespace fs = std::filesystem;

// Owns the test directory and restores the original working directory.
class TestWorkspace {
public:
TestWorkspace() : original_directory_(fs::current_path()) {
const fs::path base = fs::canonical(fs::temp_directory_path());
std::random_device random;

for (int attempt = 0; attempt < 100; ++attempt) {
root_ = base / (
"agentforge-tests-" + std::to_string(random()) +
"-" + std::to_string(random())
);

if (fs::create_directory(root_)) {
return;
}
}

throw std::runtime_error("Could not create a test directory.");
}

TestWorkspace(const TestWorkspace&) = delete;
TestWorkspace& operator=(const TestWorkspace&) = delete;

~TestWorkspace() {
std::error_code error;
fs::current_path(original_directory_, error);
fs::remove_all(root_, error);
}

const fs::path& root() const {
return root_;
}

private:
fs::path original_directory_;
fs::path root_;
};

void write_text(const fs::path& path, const std::string& text) {
std::ofstream output;
output.exceptions(std::ios::failbit | std::ios::badbit);
output.open(path);
output << text;
output.close();
}

void expect(bool passed, const char* description) {
if (!passed) {
throw std::runtime_error(description);
}
}

void expect_rejected(
const FileTool& tool,
const std::string& path,
const char* expected_error) {
try {
tool.read(path);
} catch (const std::runtime_error& error) {
if (std::string(error.what()).find(expected_error) !=
std::string::npos) {
return;
}

throw std::runtime_error(
"Unexpected error for " + path + ": " + error.what());
}

throw std::runtime_error("Expected rejection for: " + path);
}

} // namespace

int main() {
try {
TestWorkspace temporary;
const fs::path workspace = temporary.root() / "workspace";
const std::string contents = "AgentForge file test\n";

fs::create_directories(workspace / "nested");

write_text(workspace / "inside.txt", contents);
write_text(temporary.root() / "outside.txt", "Outside file\n");

fs::create_symlink(
workspace / "inside.txt",
workspace / "inside-link.txt");

fs::create_symlink(
temporary.root() / "outside.txt",
workspace / "outside-link.txt");

fs::current_path(workspace);
const FileTool tool;

expect(tool.read("inside.txt") == contents,
"A workspace file should return its exact contents.");

expect(tool.read("nested/../inside.txt") == contents,
"A normalized path inside the workspace should work.");

expect(tool.read("inside-link.txt") == contents,
"A symlink staying inside the workspace should work.");

expect_rejected(
tool, "missing.txt", "Could not open file");

expect_rejected(
tool, (workspace / "inside.txt").string(),
"must be relative");

expect_rejected(
tool, "../outside.txt", "outside the workspace");

expect_rejected(
tool, "outside-link.txt", "outside the workspace");

std::cout << "All file-tool checks passed.\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "File-tool test failed: " << error.what() << '\n';
return 1;
}
}
72 changes: 72 additions & 0 deletions tests/tool_request_parser_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#include "tool_request_parser.hpp"

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

namespace {

void expect(bool passed, const char* description) {
if (!passed) {
throw std::runtime_error(description);
}
}

} // namespace

int main() {
try {
ToolRequestParser parser;

expect(!parser.parse("Hello").has_value(),
"Ordinary text should not become a command.");

expect(!parser.parse("").has_value(),
"Empty input should not become a command.");

expect(!parser.parse("/").has_value(),
"A slash alone should not become a command.");

const auto read = parser.parse("/read README.md");
expect(read &&
read->name == "read" &&
read->arguments ==
std::vector<std::string>{"README.md"},
"/read should preserve the file path.");

const auto ask = parser.parse(
"/askfile\tREADME.md What is this? ");
expect(ask &&
ask->name == "askfile" &&
ask->arguments == std::vector<std::string>{
"README.md", "What", "is", "this?"},
"/askfile should split whitespace and preserve word order.");

const auto clear = parser.parse("/clear");
expect(clear &&
clear->name == "clear" &&
clear->arguments.empty(),
"/clear should have no arguments.");

const auto missing_path = parser.parse("/read");
expect(missing_path &&
missing_path->name == "read" &&
missing_path->arguments.empty(),
"Missing arguments should remain available for Agent validation.");

const auto unknown = parser.parse("/unknown value");
expect(unknown &&
unknown->name == "unknown" &&
unknown->arguments ==
std::vector<std::string>{"value"},
"Unknown commands should remain available for Agent validation.");

std::cout << "All parser checks passed.\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "Parser test failed: " << error.what() << '\n';
return 1;
}
}
Loading