Merge origin/master into work branch (issue #169)

Resolved merge conflicts:
- CMakeLists.txt: Added both rebase_engine/utility and base64_util sources
- src/harness.cpp: Combined Content type with proper error handling
- src/main.cpp: Merged includes, test flags, and CLI parsing
  - Renamed conflicting flags: -B→-Z (build_rebase), -T→-W (edit_prompt), -b→-K (base64_decode)
- tests/CMakeLists.txt: Added all test targets from both branches
- src/rebase_engine.cpp: Fixed to use get_content_string() for Content variant type
- Updated test files to use new flag names (-Z, -W, -K)
This commit is contained in:
2026-06-15 22:52:29 +00:00
45 changed files with 1545 additions and 51 deletions
+3 -1
View File
@@ -12,6 +12,7 @@ set(TOOL_SOURCES
tools/shell_tool.cpp
tools/agent_tool.cpp
tools/edit_tool.cpp
tools/read_multi_modal.cpp
tools/file_list_tool.cpp
tools/file_read_tool.cpp
tools/grep_tool.cpp
@@ -30,8 +31,9 @@ add_library(drharness_lib
src/debug_client.cpp
src/editor.cpp
src/skill.cpp
src/rebase_engine.cpp
src/rebase_engine.cpp
src/utility.cpp
src/base64_util.cpp
${TOOL_SOURCES}
)
target_include_directories(drharness_lib PUBLIC include tools)
+3
View File
@@ -11,6 +11,9 @@ RUN apk add nlohmann-json
RUN apk add cmake
RUN apk add jq
RUN apk add bash
RUN apk add git
RUN git config --global user.email "hal9000@example.com"
RUN git config --global user.name "hal9000"
COPY . /drharness
RUN mkdir -p /usr/local/share/ca-certificates/
RUN cp /drharness/192.168.8.63.crt /usr/local/share/ca-certificates/192.168.8.63.crt
+1
View File
@@ -73,6 +73,7 @@ configuration.
* `-p <file>`: Pretty-print a conversation JSON file without running the harness.
* `-t <tool>`: Manually constrain the harness to a specific subset of tools (by default no tools are provided to the model)
* `-s <name>`: Manually constrain the harness to a specific subset of skills (can be specified multiple times)
* `-i <path>`: Image file to attach to the prompt (can be specified multiple times)
* `-m <prompt>`: The user prompt to send to the agent.
* `-M <model>`: Override the Ollama model (e.g., `mistral`, `llama3`).
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <string>
#include <expected>
#include <vector>
// Base64 encode binary data to base64 string
std::string base64_encode(const std::string& input);
// Base64 decode base64 string to binary data
std::expected<std::string, std::string> base64_decode(const std::string& input);
// Get MIME type from file extension
std::string get_mime_type_from_extension(const std::string& extension);
// Generate data URI prefix with base64 data
std::string generate_data_uri(const std::string& extension, const std::string& base64_data);
// Read an image file and return it as a base64 data URI
std::expected<std::string, std::string> read_image_as_data_uri(const std::string& file_path);
+2
View File
@@ -15,6 +15,8 @@ public:
HarnessState deserialized);
std::expected<std::string, std::string> chat(const std::string& user_message);
std::expected<std::string, std::string> chat(const Message& user_message);
std::expected<std::string, std::string> chat(const std::string& user_message, const std::vector<std::string>& image_paths);
void reset();
HarnessState serialize() const;
+226 -2
View File
@@ -3,12 +3,14 @@
#include <string>
#include <vector>
#include <expected>
#include <optional>
#include <iostream>
#include <cstdlib>
#include <regex>
#include <type_traits>
#include <unordered_map>
#include <functional>
#include <variant>
// Forward declaration of template function for JSON deserialization
// Specialized implementations are generated by DRH_DEFINE_TYPE_* macros
@@ -29,6 +31,10 @@ inline constexpr bool has_json_to_v = has_json_to<T>::value;
template<typename T>
struct has_json_to<std::vector<T>, std::enable_if_t<has_json_to_v<T>>> : std::true_type {};
// Partial specialization for std::variant - always true since json_to_impl handles it
template<typename... Alternatives>
struct has_json_to<std::variant<Alternatives...>> : std::true_type {};
// Helper function for json_to - uses if constexpr to dispatch between
// recursive json_to<T>() calls (for custom types) and direct .get_to() calls (for primitives)
template<typename T>
@@ -88,6 +94,122 @@ std::expected<T, std::string> json_to(const nlohmann::json& j) {
return json_to_impl<T>::apply(j);
}
// ============================================================================
// std::optional serialization/deserialization support
// ============================================================================
// Partial specialization for std::optional<T> - always true since we handle it
template<typename T>
struct has_json_to<std::optional<T>> : std::true_type {};
// to_json for std::optional - serializes as null when empty, value when present
template<typename T>
void to_json(nlohmann::json& j, const std::optional<T>& opt) {
if (opt.has_value()) {
j = opt.value();
} else {
j = nullptr;
}
}
// json_to_impl specialization for std::optional<T>
// Deserializes null as std::nullopt, otherwise deserializes the inner type
template<typename T>
struct json_to_impl<std::optional<T>> {
static std::expected<std::optional<T>, std::string> apply(const nlohmann::json& j) {
if (j.is_null()) {
return std::optional<T>{};
}
if constexpr (has_json_to_v<T>) {
auto result = json_to<T>(j);
if (!result) {
return std::unexpected(result.error());
}
return std::optional<T>{std::move(result.value())};
} else {
try {
T value;
j.get_to(value);
return std::optional<T>{std::move(value)};
} catch (const nlohmann::json::exception& e) {
return std::unexpected(e.what());
}
}
}
};
// ============================================================================
// std::variant serialization/deserialization support
// ============================================================================
// Helper to get the JSON type that a type serializes to
// Used for type-based disambiguation during variant deserialization
template<typename T>
constexpr nlohmann::json::value_type json_type_for_type() {
if constexpr (std::is_same_v<std::decay_t<T>, std::string>) {
return nlohmann::json::value_t::string;
} else if constexpr (requires { typename std::decay_t<T>::value_type; }) {
// std::vector<T> or similar container
return nlohmann::json::value_t::array;
} else if constexpr (std::is_integral_v<std::decay_t<T>>) {
return nlohmann::json::value_t::number_integer;
} else if constexpr (std::is_floating_point_v<std::decay_t<T>>) {
return nlohmann::json::value_t::number_float;
} else if constexpr (std::is_same_v<std::decay_t<T>, bool>) {
return nlohmann::json::value_t::boolean;
} else {
// Custom types serialize to objects
return nlohmann::json::value_t::object;
}
}
// to_json for std::variant - serializes the active alternative
template<typename... Alternatives>
void to_json(nlohmann::json& j, const std::variant<Alternatives...>& v) {
std::visit([&j](const auto& value) {
j = value;
}, v);
}
// Helper for recursive variant alternative trying
// Base case: no more alternatives to try
template<typename VariantType, std::size_t CurrentIdx, std::size_t MaxIdx>
struct variant_try_helper;
// Primary template: try current alternative, then recurse
template<typename VariantType, std::size_t CurrentIdx, std::size_t MaxIdx, typename... Alternatives>
struct variant_try_helper_impl {
static std::expected<VariantType, std::string> apply(const nlohmann::json& j, nlohmann::json::value_type json_type) {
using Alt = std::tuple_element_t<CurrentIdx, std::tuple<Alternatives...>>;
if (json_type_for_type<Alt>() == json_type) {
auto result = json_to<Alt>(j);
if (result.has_value()) {
return VariantType{std::in_place_index<CurrentIdx>, std::move(result.value())};
}
// Deserialization failed, but JSON type matched - return error
return std::unexpected("variant alternative " + std::to_string(CurrentIdx) + ": " + result.error());
}
// JSON type doesn't match, try next alternative
if constexpr (CurrentIdx + 1 < MaxIdx) {
return variant_try_helper_impl<VariantType, CurrentIdx + 1, MaxIdx, Alternatives...>::apply(j, json_type);
} else {
return std::unexpected("no variant alternative matched JSON type");
}
}
};
// json_to_impl specialization for std::variant
// Uses type-based disambiguation: matches JSON type to variant alternatives
template<typename... Alternatives>
struct json_to_impl<std::variant<Alternatives...>> {
static std::expected<std::variant<Alternatives...>, std::string> apply(const nlohmann::json& j) {
using VariantType = std::variant<Alternatives...>;
return variant_try_helper_impl<VariantType, 0, sizeof...(Alternatives), Alternatives...>::apply(j, j.type());
}
};
// Helper macros for common registration logic used by all DRH_DEFINE_TYPE_* macros
// Register has_json_to<Type> trait
@@ -122,8 +244,29 @@ std::expected<T, std::string> json_to(const nlohmann::json& j) {
nlohmann_json_t.field = std::move(nlohmann_json_field_result.value()); \
}
// Helper to serialize a value to nlohmann::json
// Handles std::optional, std::variant, and primitive types
template<typename T>
nlohmann::json serialize_to_json(const T& value) {
return value; // Works for primitives via implicit conversion
}
template<typename T>
nlohmann::json serialize_to_json(const std::optional<T>& opt) {
nlohmann::json j;
::to_json(j, opt);
return j;
}
template<typename... Alternatives>
nlohmann::json serialize_to_json(const std::variant<Alternatives...>& v) {
nlohmann::json j;
::to_json(j, v);
return j;
}
// to_json using nlohmann's existing pattern
#define DRH_JSON_TO(field) nlohmann_json_j[#field] = nlohmann_json_t.field;
#define DRH_JSON_TO(field) nlohmann_json_j[#field] = serialize_to_json(nlohmann_json_t.field);
// The main macro - generates to_json and json_to<Type>() template specialization
#define DRH_DEFINE_TYPE_REQUIRED(Type, ...) \
@@ -238,6 +381,23 @@ inline const auto json_register_vector_FunctionCall = []() {
return 0u;
}();
// Test-only variant type for validating variant serialization/deserialization
// JSON string → std::string, JSON array → std::vector<FunctionCall>
using TestVariantType = std::variant<std::string, std::vector<FunctionCall>>;
// has_json_to specialization for TestVariantType
template<> struct has_json_to<TestVariantType> : std::true_type {};
// Register TestVariantType in json_dispatch_map for -R debug flag
inline const auto drh_register_TestVariantType = []() {
json_dispatch_map["TestVariantType"] = [](const nlohmann::json& j) {
auto result = json_to<TestVariantType>(j);
if (result) std::cout << "success" << std::endl;
else std::cout << "error: " << result.error() << std::endl;
};
return 0u;
}();
struct ToolCall {
size_t index;
std::string id = "";
@@ -246,15 +406,51 @@ struct ToolCall {
};
DRH_DEFINE_TYPE_MIXED(ToolCall, (index), (id, type, function))
struct ImageUrl {
std::string url = "";
std::string detail = "auto";
};
DRH_DEFINE_TYPE_OPTIONAL(ImageUrl, url, detail)
struct ContentPart {
std::string type = "text";
std::optional<std::string> text = std::nullopt;
std::optional<ImageUrl> image_url = std::nullopt;
};
DRH_DEFINE_TYPE_OPTIONAL(ContentPart, type, text, image_url)
using Content = std::variant<std::string, std::vector<ContentPart>>;
struct Message {
std::string role = "";
std::string content = "";
Content content = "";
std::string reasoning = "";
std::string reasoning_content = "";
std::string tool_call_id = "";
std::vector<ToolCall> tool_calls = {};
std::string dump(int indent = -1) const;
// Helper to get content as string (for C++ code that needs string)
std::string get_content_string() const {
if (auto* str = std::get_if<std::string>(&content)) {
return *str;
}
return "";
}
// Check if content is empty (for both variant alternatives)
bool content_empty() const {
if (auto* str = std::get_if<std::string>(&content)) {
return str->empty();
}
if (auto* vec = std::get_if<std::vector<ContentPart>>(&content)) {
return vec->empty();
}
return true;
}
};
DRH_DEFINE_TYPE_MIXED(Message, (), (role, content, reasoning, reasoning_content, tool_call_id, tool_calls))
@@ -262,6 +458,31 @@ inline std::string Message::dump(int indent) const {
return nlohmann::json(*this).dump(indent);
}
// Operator<< for variant content - outputs string if variant holds string,
// or renders text parts and indicates image parts for vector<ContentPart>
inline std::ostream& operator<<(std::ostream& os, const std::variant<std::string, std::vector<ContentPart>>& content) {
if (auto* str = std::get_if<std::string>(&content)) {
os << *str;
} else if (auto* parts = std::get_if<std::vector<ContentPart>>(&content)) {
for (const auto& part : *parts) {
if (part.type == "text" && part.text.has_value()) {
os << part.text.value();
} else if (part.type == "image_url" && part.image_url.has_value()) {
os << "[Image]";
}
}
}
return os;
}
// Append to string variant content (for streaming concatenation)
inline void append_to_variant_content(std::variant<std::string, std::vector<ContentPart>>& content, const std::string& append) {
if (auto* str = std::get_if<std::string>(&content)) {
*str += append;
}
// If variant holds vector<ContentPart>, ignore append (multimodal content shouldn't be concatenated)
}
struct Choice {
Message delta;
std::string finish_reason;
@@ -412,3 +633,6 @@ DRH_DEFINE_TYPE_MIXED(MixedTypeAllOptionalTest, (), (field1, field2))
static_assert(has_json_to_v<MixedTypeSmokeTest>, "MixedTypeSmokeTest should have json_to specialization");
static_assert(has_json_to_v<MixedTypeAllRequiredTest>, "MixedTypeAllRequiredTest should have json_to specialization");
static_assert(has_json_to_v<MixedTypeAllOptionalTest>, "MixedTypeAllOptionalTest should have json_to specialization");
// Compile-time test for variant type
static_assert(has_json_to_v<TestVariantType>, "TestVariantType should have json_to specialization");
+5 -5
View File
@@ -7,11 +7,11 @@
// Template function that wraps a typed lambda for tool execution
// Handles JSON deserialization internally, eliminating std::any
template<typename ParamsType>
std::function<std::string(const nlohmann::json&)> create_tool_execute(
std::function<std::string(const ParamsType&)> fn
template<typename ParamsType, typename F>
std::function<Content(const nlohmann::json&)> create_tool_execute(
F const& fn
) {
return [fn = std::move(fn)](const nlohmann::json& json) -> std::string {
return [fn = std::move(fn)](const nlohmann::json& json) -> Content {
auto result = json_to<ParamsType>(json);
if (!result) {
return "Error: " + result.error();
@@ -24,7 +24,7 @@ struct Tool {
std::string name;
std::string description;
nlohmann::json parameters_schema;
std::function<std::string(const nlohmann::json&)> execute;
std::function<Content(const nlohmann::json&)> execute;
nlohmann::json to_api_json() const {
return {
+1 -1
View File
@@ -11,7 +11,7 @@ public:
std::expected<void, std::string> register_tool(Tool tool);
std::optional<const Tool*> find(const std::string& name) const;
std::vector<Tool> tools() const;
std::string invoke(const std::string& name, const nlohmann::json& args) const;
Content invoke(const std::string& name, const nlohmann::json& args) const;
std::expected<ToolRegistry, std::string> create_subregistry(std::vector<std::string> const& names) const;
std::vector<std::string> const& tool_names() const;
+165
View File
@@ -0,0 +1,165 @@
#include "base64_util.hpp"
#include <unordered_map>
#include <fstream>
#include <filesystem>
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static inline bool is_base64(unsigned char c) {
return (isalnum(c) || (c == '+') || (c == '/'));
}
std::string base64_encode(const std::string& input) {
std::string ret;
int i = 0;
unsigned char char_array_3[3];
unsigned char char_array_4[4];
const std::string& chars = input;
int in_len = static_cast<int>(chars.size());
int pos = 0;
while (in_len--) {
char_array_3[i++] = chars[pos++];
if (i == 3) {
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (i = 0; (i < 4); i++)
ret += base64_chars[char_array_4[i]];
i = 0;
}
}
if (i) {
for (int j = i; j < 3; j++)
char_array_3[j] = '\0';
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (int j = 0; (j < i + 1); j++)
ret += base64_chars[char_array_4[j]];
while ((i++ < 3))
ret += '=';
}
return ret;
}
std::expected<std::string, std::string> base64_decode(const std::string& input) {
int in_len = static_cast<int>(input.size());
int i = 0;
int in_pos = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (input[in_pos] != '=') && is_base64(input[in_pos])) {
char_array_4[i++] = input[in_pos]; in_pos++;
if (i == 4) {
for (i = 0; i < 4; i++)
char_array_4[i] = static_cast<unsigned char>(base64_chars.find(char_array_4[i]));
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; (i < 3); i++)
ret += static_cast<char>(char_array_3[i]);
i = 0;
}
}
if (i) {
for (int j = i; j < 4; j++)
char_array_4[j] = 0;
for (int j = 0; j < 4; j++)
char_array_4[j] = static_cast<unsigned char>(base64_chars.find(char_array_4[j]));
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (int j = 0; (j < i - 1); j++)
ret += static_cast<char>(char_array_3[j]);
}
return ret;
}
std::string get_mime_type_from_extension(const std::string& extension) {
static const std::unordered_map<std::string, std::string> mime_map = {
{"png", "png"},
{"jpg", "jpeg"},
{"jpeg", "jpeg"},
{"gif", "gif"},
{"webp", "webp"},
{"bmp", "bmp"},
{"tiff", "tiff"},
{"tif", "tiff"},
{"svg", "svg+xml"},
};
// Convert extension to lowercase
std::string lower_ext = extension;
for (auto& c : lower_ext) {
c = static_cast<char>(tolower(c));
}
// Remove leading dot if present
if (!lower_ext.empty() && lower_ext[0] == '.') {
lower_ext = lower_ext.substr(1);
}
auto it = mime_map.find(lower_ext);
if (it != mime_map.end()) {
return it->second;
}
// Default to png if unknown
return "png";
}
std::string generate_data_uri(const std::string& extension, const std::string& base64_data) {
std::string mime_type = get_mime_type_from_extension(extension);
return "data:image/" + mime_type + ";base64," + base64_data;
}
std::expected<std::string, std::string> read_image_as_data_uri(const std::string& file_path) {
// Check if file exists
if (!std::filesystem::exists(file_path)) {
return std::unexpected("Image file not found: " + file_path);
}
// Check if it's a regular file
if (!std::filesystem::is_regular_file(file_path)) {
return std::unexpected("Not a regular file: " + file_path);
}
// Open and read the file
std::ifstream file(file_path, std::ios::binary);
if (!file.is_open()) {
return std::unexpected("Cannot open image file: " + file_path);
}
// Read file contents
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
file.close();
// Get file extension
std::filesystem::path p(file_path);
std::string extension = p.extension().string();
// Base64 encode the content
std::string encoded = base64_encode(content);
// Generate data URI
return generate_data_uri(extension, encoded);
}
+48 -8
View File
@@ -1,5 +1,6 @@
#include "harness.hpp"
#include "json_types.hpp"
#include "base64_util.hpp"
#include <iostream>
Harness::Harness(std::unique_ptr<Client> client, const ToolRegistry& registry,
@@ -43,8 +44,40 @@ std::expected<std::string, std::string> Harness::chat(const std::string& user_me
Message message{};
message.role = "user";
message.content = user_message;
return chat(message);
}
push_message(message);
std::expected<std::string, std::string> Harness::chat(const std::string& user_message, const std::vector<std::string>& image_paths) {
Message message{};
message.role = "user";
std::vector<ContentPart> content_parts;
// Add text content part
ContentPart text_part;
text_part.type = "text";
text_part.text = user_message;
content_parts.push_back(text_part);
// Read image files and add image content parts
for (const auto& path : image_paths) {
auto result = read_image_as_data_uri(path);
if (!result) {
return std::unexpected("failed to read image '" + path + "': " + result.error());
}
ContentPart image_part;
image_part.type = "image_url";
ImageUrl img_url;
img_url.url = result.value();
image_part.image_url = img_url;
content_parts.push_back(image_part);
}
message.content = content_parts;
return chat(message);
}
std::expected<std::string, std::string> Harness::chat(const Message& user_message) {
push_message(user_message);
for (size_t i = 0; i < config_.max_tool_iterations; ++i) {
Message response;
@@ -68,7 +101,7 @@ std::expected<std::string, std::string> Harness::chat(const std::string& user_me
push_message(response);
if (response.content.empty() && response.tool_calls.empty()) {
if (response.content_empty() && response.tool_calls.empty()) {
return "";
}
@@ -81,7 +114,7 @@ std::expected<std::string, std::string> Harness::chat(const std::string& user_me
if (config_.verbose)
std::cerr << prefix << "[tool call] " << tool_name << "(" << args_str << ")\n";
std::string result;
Content result = "";
auto args_json_result = parse_json_safely(args_str);
if (!args_json_result) {
result = "Error: invalid JSON in tool arguments";
@@ -90,9 +123,16 @@ std::expected<std::string, std::string> Harness::chat(const std::string& user_me
}
if (config_.verbose)
std::cerr << prefix << "[tool result] "
<< result.substr(0, 300)
<< (result.size() > 300 ? "..." : "") << "\n";
{
std::cerr << prefix << "[tool result] ";
if (std::holds_alternative<std::string>(result))
{
std::string str = std::get<std::string>(result);
std::cerr
<< str.substr(0, 300)
<< (str.size() > 300 ? "..." : "") << "\n";
}
}
Message tool_result{};
tool_result.tool_call_id = tc.id;
@@ -103,8 +143,8 @@ std::expected<std::string, std::string> Harness::chat(const std::string& user_me
continue;
}
// No tool calls — return content
return response.content;
// No tool calls — return content as string
return response.get_content_string();
}
return std::unexpected("max tool iterations exceeded");
+98 -9
View File
@@ -7,11 +7,13 @@
#include "agent_profile.hpp"
#include "rebase_engine.hpp"
#include "utility.hpp"
#include "base64_util.hpp"
#if !defined(_WIN32)
#include "shell_tool.hpp"
#endif
#include "agent_tool.hpp"
#include "edit_tool.hpp"
#include "read_multi_modal.hpp"
#include "file_list_tool.hpp"
#include "file_read_tool.hpp"
#include "grep_tool.hpp"
@@ -109,7 +111,7 @@ int main(int argc, char** argv) {
char const* test_expect_exit_error = nullptr;
char const* test_type_required_type = nullptr;
char const* test_type_required_json = nullptr;
char const* test_rebase_history_size = nullptr;
char const* test_rebase_history_size = nullptr;
char const* test_rebase_content = nullptr;
char const* test_build_rebase_json = nullptr;
char const* test_apply_rebase_lines = nullptr;
@@ -117,10 +119,17 @@ int main(int argc, char** argv) {
char const* test_edit_prompt_json = nullptr;
char const* test_edit_prompt_index = nullptr;
char const* test_edit_prompt_role = nullptr;
char const* test_base64_encode = nullptr;
char const* test_base64_decode = nullptr;
char const* test_mime_type = nullptr;
char const* test_data_uri_ext = nullptr;
char const* test_data_uri_b64 = nullptr;
char const* test_image_file = nullptr;
ToolRegistry master_registery;
ToolRegistry agent_tools;
expect_or_exit(master_registery.register_tool(make_edit_tool()));
expect_or_exit(master_registery.register_tool(make_read_multi_modal()));
expect_or_exit(master_registery.register_tool(make_file_list_tool()));
expect_or_exit(master_registery.register_tool(make_file_read_tool()));
expect_or_exit(master_registery.register_tool(make_grep_tool()));
@@ -155,6 +164,7 @@ int main(int argc, char** argv) {
bool full_history = false;
bool edit_history = false;
std::vector<std::string> skill_filters;
std::vector<std::string> image_paths;
consume_arg(); // progname
@@ -231,13 +241,13 @@ int main(int argc, char** argv) {
test_type_required_type = consume_arg();
test_type_required_json = consume_arg();
}
else if (argv[0] == std::string("-P"))
else if (argv[0] == std::string("-P"))
{
consume_arg();
test_rebase_history_size = consume_arg();
test_rebase_content = consume_arg();
}
else if (argv[0] == std::string("-B"))
else if (argv[0] == std::string("-Z"))
{
consume_arg();
test_build_rebase_json = consume_arg();
@@ -248,13 +258,34 @@ int main(int argc, char** argv) {
test_apply_rebase_lines = consume_arg();
test_apply_rebase_history = consume_arg();
}
else if (argv[0] == std::string("-T"))
else if (argv[0] == std::string("-B"))
{
consume_arg();
test_base64_encode = consume_arg();
}
else if (argv[0] == std::string("-K"))
{
consume_arg();
test_base64_decode = consume_arg();
}
else if (argv[0] == std::string("-W"))
{
consume_arg();
test_edit_prompt_json = consume_arg();
test_edit_prompt_index = consume_arg();
test_edit_prompt_role = consume_arg();
}
else if (argv[0] == std::string("-T"))
{
consume_arg();
test_mime_type = consume_arg();
}
else if (argv[0] == std::string("-U"))
{
consume_arg();
test_data_uri_ext = consume_arg();
test_data_uri_b64 = consume_arg();
}
else if (argv[0] == std::string("-L"))
{
consume_arg();
@@ -278,11 +309,21 @@ int main(int argc, char** argv) {
consume_arg();
skill_filters.push_back(consume_arg());
}
else if (argv[0] == std::string("-b") || argv[0] == std::string("--edit-history"))
else if (argv[0] == std::string("-b") || argv[0] == std::string("--edit-history"))
{
consume_arg();
edit_history = true;
}
else if (argv[0] == std::string("-i"))
{
consume_arg();
image_paths.push_back(consume_arg());
}
else if (argv[0] == std::string("-I"))
{
consume_arg();
test_image_file = consume_arg();
}
else if (argv[0] == std::string("-h"))
{
std::cout << "Usage: drharness [options]\n"
@@ -291,6 +332,7 @@ int main(int argc, char** argv) {
<< " -p <file> Pretty print a saved conversation JSON file\n"
<< " -t <tool> Tool name (can be specified multiple times)\n"
<< " -s <name> Skill name to enable (can be specified multiple times)\n"
<< " -i <path> Image file to attach (can be specified multiple times)\n"
<< " -n Start a new conversation (auto-generates temp file if -F not specified)\n"
<< " -m <prompt> Prompt message (required)\n"
<< " -M <model> Ollama model name\n"
@@ -306,10 +348,16 @@ int main(int argc, char** argv) {
<< " -E <string> Debug: test expect_or_exit success case and exit\n"
<< " -e <string> Debug: test expect_or_exit error case and exit\n"
<< " -R <type> <json> Debug: test json_to<Type>() for required fields and exit\n"
<< " -R <type> <json> Debug: test json_to<Type>() for required fields and exit\n"
<< " -P <size> <content> Debug: test rebase parser and exit\n"
<< " -B <json> Debug: test build_rebase_content with JSON history and exit\n"
<< " -Z <json> Debug: test build_rebase_content with JSON history and exit\n"
<< " -A <lines> <history> Debug: test apply_rebase with JSON and exit\n"
<< " -T <json> <index> <role> Debug: test build_edit_prompt and exit\n"
<< " -W <json> <index> <role> Debug: test build_edit_prompt and exit\n"
<< " -B <string> Debug: test base64 encode and exit\n"
<< " -K <string> Debug: test base64 decode and exit\n"
<< " -T <ext> Debug: test MIME type detection and exit\n"
<< " -U <ext> <b64> Debug: test data URI generation and exit\n"
<< " -I <file> Debug: test image file reading and exit\n"
<< " -d Use debug client (mock API via stdin)\n"
<< " -h Display this help menu\n";
exit(0);
@@ -476,7 +524,7 @@ int main(int argc, char** argv) {
return 0;
}
if (test_edit_prompt_json && test_edit_prompt_index && test_edit_prompt_role) {
if (test_edit_prompt_json && test_edit_prompt_index && test_edit_prompt_role) {
auto j = expect_or_exit(parse_json_safely(test_edit_prompt_json));
auto history_result = expect_or_exit(json_to<std::vector<Message>>(j));
size_t edit_index = expect_or_exit(string_toul(test_edit_prompt_index));
@@ -487,6 +535,41 @@ int main(int argc, char** argv) {
return 0;
}
if (test_base64_encode) {
std::cout << base64_encode(test_base64_encode) << std::endl;
return 0;
}
if (test_base64_decode) {
auto result = base64_decode(test_base64_decode);
if (!result) {
std::cout << "error: " << result.error() << std::endl;
return 1;
}
std::cout << result.value() << std::endl;
return 0;
}
if (test_mime_type) {
std::cout << get_mime_type_from_extension(test_mime_type) << std::endl;
return 0;
}
if (test_data_uri_ext && test_data_uri_b64) {
std::cout << generate_data_uri(test_data_uri_ext, test_data_uri_b64) << std::endl;
return 0;
}
if (test_image_file) {
auto result = read_image_as_data_uri(test_image_file);
if (!result) {
std::cerr << "Error: " << result.error() << std::endl;
return 1;
}
std::cout << result.value() << std::endl;
return 0;
}
if (list_models)
{
auto models_result = create_client(use_debug)->list_models();
@@ -580,7 +663,13 @@ int main(int argc, char** argv) {
std::cerr << "Prompt input:" << std::endl;
std::cerr << final_prompt << std::endl;
auto reply = harness.chat(final_prompt);
std::expected<std::string, std::string> reply;
if (!image_paths.empty()) {
reply = harness.chat(final_prompt, image_paths);
} else {
reply = harness.chat(final_prompt);
}
if (!reply) {
std::cerr << "Error: " << reply.error() << std::endl;
return 1;
+3 -3
View File
@@ -160,13 +160,13 @@ std::expected<Message, std::string> OllamaClient::chat(const std::vector<Message
//std::cerr << new_packet << std::endl;
total.content += partial_message->content;
append_to_variant_content(total.content, partial_message->get_content_string());
total.reasoning_content += partial_message->reasoning_content;
total.reasoning += partial_message->reasoning;
total.role += partial_message->role;
total.tool_call_id += partial_message->tool_call_id;
std::string to_write = partial_message->content
std::string to_write = partial_message->get_content_string()
+ partial_message->reasoning_content
+ partial_message->reasoning;
@@ -283,7 +283,7 @@ void print_history(std::ostream& out, const nlohmann::json& data, bool colored)
}
}
if (!msg.content.empty()) {
if (!msg.content_empty()) {
if (colored) out << "\033[37m[Ai Response]\033[0m ";
else out << "[Ai Response] ";
out << msg.content << "\n\n";
+1 -1
View File
@@ -123,7 +123,7 @@ std::string HistoryEditor::get_message_display(const Message& msg) const {
}
// Get content from message
std::string content = msg.content;
std::string content = msg.get_content_string();
// If no content, show (empty)
if (content.empty()) {
+2 -2
View File
@@ -24,8 +24,8 @@ std::vector<Tool> ToolRegistry::tools() const {
return ret;
}
std::string ToolRegistry::invoke(const std::string& name,
const nlohmann::json& args) const {
Content ToolRegistry::invoke(const std::string& name,
const nlohmann::json& args) const {
auto it = tools_.find(name);
if (it == tools_.end())
return "Error: unknown tool: " + name;
+22
View File
@@ -19,6 +19,7 @@ add_dr_test(test_tool_read_file)
add_dr_test(test_tool_list_files)
add_dr_test(test_tool_grep)
add_dr_test(test_tool_shell)
add_dr_test(test_tool_read_multi_modal)
# Add feature validation tests
add_dr_test(test_tool_restrictions)
@@ -48,6 +49,9 @@ add_dr_test(test_json_type_mixed)
# Add json_to_field tests
add_dr_test(test_json_to_field)
# Add std::variant JSON serialization tests
add_dr_test(test_json_variant)
# Add expected error handling tests
add_dr_test(test_expected_errors)
@@ -134,3 +138,21 @@ add_dr_test(test_edit_history_very_long_content)
add_dr_test(test_edit_history_assistant_with_tool_calls)
add_dr_test(test_edit_history_drop_all_messages)
add_dr_test(test_edit_history_unicode_characters)
# Add base64 encoding tests
add_dr_test(test_base64_encode)
# Add image file reading tests
add_dr_test(test_image_flag)
# Add ImageUrl and ContentPart tests
add_dr_test(test_image_url)
# Add multimodal message construction tests
add_dr_test(test_multimodal_message)
# Add multimodal round-trip persistence tests
add_dr_test(test_multimodal_roundtrip)
# Add print_history multimodal tests
add_dr_test(test_print_history_multimodal)
+68
View File
@@ -387,6 +387,74 @@ function create_state() {
}
export -f create_state
# Get message role from chat history at given index
function dr_history_role() {
local chat_file="$1"
local index="$2"
jq -r --argjson i "$index" '.history[$i].role' "$chat_file"
}
export -f dr_history_role
# Get content type (string/array) from message at given history index
function dr_content_type() {
local chat_file="$1"
local index="$2"
jq -r --argjson i "$index" '.history[$i].content | type' "$chat_file"
}
export -f dr_content_type
# Get content part type at given history and content part indices
function dr_content_part_type() {
local chat_file="$1"
local history_idx="$2"
local part_idx="$3"
jq -r --argjson h "$history_idx" --argjson p "$part_idx" '.history[$h].content[$p].type' "$chat_file"
}
export -f dr_content_part_type
# Get text content from a content part at given history and content part indices
function dr_content_part_text() {
local chat_file="$1"
local history_idx="$2"
local part_idx="$3"
jq -r --argjson h "$history_idx" --argjson p "$part_idx" '.history[$h].content[$p].text' "$chat_file"
}
export -f dr_content_part_text
# Get image URL from a content part at given history and content part indices
function dr_content_part_image_url() {
local chat_file="$1"
local history_idx="$2"
local part_idx="$3"
jq -r --argjson h "$history_idx" --argjson p "$part_idx" '.history[$h].content[$p].image_url.url' "$chat_file"
}
export -f dr_content_part_image_url
# Get image detail from a content part at given history and content part indices
function dr_content_part_image_detail() {
local chat_file="$1"
local history_idx="$2"
local part_idx="$3"
jq -r --argjson h "$history_idx" --argjson p "$part_idx" '.history[$h].content[$p].image_url.detail' "$chat_file"
}
export -f dr_content_part_image_detail
# Get content parts count from message at given history index
function dr_content_parts_count() {
local chat_file="$1"
local index="$2"
jq --argjson i "$index" '.history[$i].content | length' "$chat_file"
}
export -f dr_content_parts_count
# Get content from message at given history index (for text-only messages)
function dr_content_text() {
local chat_file="$1"
local index="$2"
jq -r --argjson i "$index" '.history[$i].content' "$chat_file"
}
export -f dr_content_text
# Resolve paths relative to this script
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJECT_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd)
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# test_base64_encode.sh - Tests for base64 encoding utility
# Test 1: Basic base64 encoding
OUTPUT=$("${DRHARNESS}" -B "Hello, World!")
if [ "${OUTPUT}" != "SGVsbG8sIFdvcmxkIQ==" ]; then
echo "FAIL: base64_encode - expected 'SGVsbG8sIFdvcmxkIQ==', got '${OUTPUT}'"
exit 1
fi
# Test 2: Empty string encoding
OUTPUT=$("${DRHARNESS}" -B "")
if [ "${OUTPUT}" != "" ]; then
echo "FAIL: base64_encode empty - expected empty string, got '${OUTPUT}'"
exit 1
fi
# Test 3: Base64 decoding
OUTPUT=$("${DRHARNESS}" -K "SGVsbG8sIFdvcmxkIQ==")
if [ "${OUTPUT}" != "Hello, World!" ]; then
echo "FAIL: base64_decode - expected 'Hello, World!', got '${OUTPUT}'"
exit 1
fi
# Test 4: Round-trip encoding/decoding
ORIGINAL="Test string with special chars: !@#$%^&*()"
ENCODED=$("${DRHARNESS}" -B "${ORIGINAL}")
DECODED=$("${DRHARNESS}" -K "${ENCODED}")
if [ "${DECODED}" != "${ORIGINAL}" ]; then
echo "FAIL: round-trip - expected '${ORIGINAL}', got '${DECODED}'"
exit 1
fi
# Test 5: MIME type detection - PNG
OUTPUT=$("${DRHARNESS}" -T "png")
if [ "${OUTPUT}" != "png" ]; then
echo "FAIL: mime_type png - expected 'png', got '${OUTPUT}'"
exit 1
fi
# Test 6: MIME type detection - JPG
OUTPUT=$("${DRHARNESS}" -T "jpg")
if [ "${OUTPUT}" != "jpeg" ]; then
echo "FAIL: mime_type jpg - expected 'jpeg', got '${OUTPUT}'"
exit 1
fi
# Test 7: MIME type detection - JPEG
OUTPUT=$("${DRHARNESS}" -T "jpeg")
if [ "${OUTPUT}" != "jpeg" ]; then
echo "FAIL: mime_type jpeg - expected 'jpeg', got '${OUTPUT}'"
exit 1
fi
# Test 8: MIME type detection - GIF
OUTPUT=$("${DRHARNESS}" -T "gif")
if [ "${OUTPUT}" != "gif" ]; then
echo "FAIL: mime_type gif - expected 'gif', got '${OUTPUT}'"
exit 1
fi
# Test 9: MIME type detection - WEBP
OUTPUT=$("${DRHARNESS}" -T "webp")
if [ "${OUTPUT}" != "webp" ]; then
echo "FAIL: mime_type webp - expected 'webp', got '${OUTPUT}'"
exit 1
fi
# Test 10: MIME type detection - case insensitive (uppercase)
OUTPUT=$("${DRHARNESS}" -T "PNG")
if [ "${OUTPUT}" != "png" ]; then
echo "FAIL: mime_type PNG - expected 'png', got '${OUTPUT}'"
exit 1
fi
# Test 11: MIME type detection - with leading dot
OUTPUT=$("${DRHARNESS}" -T ".jpg")
if [ "${OUTPUT}" != "jpeg" ]; then
echo "FAIL: mime_type .jpg - expected 'jpeg', got '${OUTPUT}'"
exit 1
fi
# Test 12: MIME type detection - unknown extension defaults to png
OUTPUT=$("${DRHARNESS}" -T "xyz")
if [ "${OUTPUT}" != "png" ]; then
echo "FAIL: mime_type unknown - expected 'png', got '${OUTPUT}'"
exit 1
fi
# Test 13: Data URI generation - PNG
OUTPUT=$("${DRHARNESS}" -U "png" "SGVsbG8=")
if [ "${OUTPUT}" != "data:image/png;base64,SGVsbG8=" ]; then
echo "FAIL: data_uri png - expected 'data:image/png;base64,SGVsbG8=', got '${OUTPUT}'"
exit 1
fi
# Test 14: Data URI generation - JPG
OUTPUT=$("${DRHARNESS}" -U "jpg" "SGVsbG8=")
if [ "${OUTPUT}" != "data:image/jpeg;base64,SGVsbG8=" ]; then
echo "FAIL: data_uri jpg - expected 'data:image/jpeg;base64,SGVsbG8=', got '${OUTPUT}'"
exit 1
fi
# Test 15: Data URI generation - GIF
OUTPUT=$("${DRHARNESS}" -U "gif" "SGVsbG8=")
if [ "${OUTPUT}" != "data:image/gif;base64,SGVsbG8=" ]; then
echo "FAIL: data_uri gif - expected 'data:image/gif;base64,SGVsbG8=', got '${OUTPUT}'"
exit 1
fi
echo "All base64 encode tests passed"
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="edit_first_message"
json=$(create_history_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -T "$json" "0" "user")
OUTPUT=$("${DRHARNESS}" -W "$json" "0" "user")
# Check for "no previous message" text
if ! echo "$OUTPUT" | grep -q "# (no previous message)"; then
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="edit_middle_message"
json=$(create_history_json user "msg0" assistant "msg1" tool "msg2")
OUTPUT=$("${DRHARNESS}" -T "$json" "2" "tool")
OUTPUT=$("${DRHARNESS}" -W "$json" "2" "tool")
# Should show message 1 as context (immediate previous)
if ! echo "$OUTPUT" | grep -q "\[1\] assistant: msg1"; then
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="multiline_context"
json=$(create_history_json user $'line one\nline two' assistant "response")
OUTPUT=$("${DRHARNESS}" -T "$json" "1" "assistant")
OUTPUT=$("${DRHARNESS}" -W "$json" "1" "assistant")
# Both lines should appear in context
if ! echo "$OUTPUT" | grep -q "line one"; then
@@ -5,7 +5,7 @@ test_name="separator_placement"
json=$(create_history_json user "first" assistant "second")
OUTPUT=$("${DRHARNESS}" -T "$json" "1" "assistant")
OUTPUT=$("${DRHARNESS}" -W "$json" "1" "assistant")
# The separator should appear after the context and edit prompt
# Check that "Edit message" is followed by the separator
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="edit_tool_role"
json=$(create_history_json assistant "call tool" tool "result")
OUTPUT=$("${DRHARNESS}" -T "$json" "1" "tool")
OUTPUT=$("${DRHARNESS}" -W "$json" "1" "tool")
# Check for tool role in edit prompt
if ! echo "$OUTPUT" | grep -q "Edit message 1 (role: tool) below:"; then
+1 -1
View File
@@ -6,7 +6,7 @@ test_name="edit_with_context"
# Two messages: editing message 1, so message 0 should be shown as context
json=$(create_history_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -T "$json" "1" "assistant")
OUTPUT=$("${DRHARNESS}" -W "$json" "1" "assistant")
# Check for previous message context
if ! echo "$OUTPUT" | grep -q "# Previous message:"; then
+2 -1
View File
@@ -51,8 +51,9 @@ test_max_tool_iterations() {
# Generate 1001 tool call responses (exceeds default max of 1000)
> mock_responses.txt
resp_line=$(tool_call read_file '{"path":"test_file.txt"}')
for i in $(seq 1 1001); do
tool_call read_file '{"path":"test_file.txt"}' >> mock_responses.txt
echo "$resp_line" >> mock_responses.txt
done
be_done "final response" >> mock_responses.txt
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# test_image_flag.sh - Tests for -i CLI flag with image file reading
# Test 1: Nonexistent file produces clear error and non-zero exit
OUTPUT=$("${DRHARNESS}" -I "nonexistent.png" 2>&1) || true
if ! echo "${OUTPUT}" | grep -q "Image file not found"; then
echo "FAIL: nonexistent file error - expected 'Image file not found', got '${OUTPUT}'"
exit 1
fi
# Test 2: Create a test image file and read it
# Create a minimal 1x1 PNG file
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test.png"
# Test 3: Valid PNG file is read and returns data URI
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/test.png" 2>&1)
if ! echo "${OUTPUT}" | grep -q "^data:image/png;base64,"; then
echo "FAIL: valid PNG file - expected data URI starting with 'data:image/png;base64,', got '${OUTPUT}'"
exit 1
fi
# Test 4: Create a minimal JPEG file
printf '\xFF\xD8\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xFF\xD9' > "${TEST_TEMP_DIR}/test.jpg"
# Test 5: Valid JPG file is read and returns correct data URI
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/test.jpg" 2>&1)
if ! echo "${OUTPUT}" | grep -q "^data:image/jpeg;base64,"; then
echo "FAIL: valid JPG file - expected data URI starting with 'data:image/jpeg;base64,', got '${OUTPUT}'"
exit 1
fi
# Test 6: Create a GIF file
printf '\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xFF\xFF\xFF\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3B' > "${TEST_TEMP_DIR}/test.gif"
# Test 7: Valid GIF file is read and returns correct data URI
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/test.gif" 2>&1)
if ! echo "${OUTPUT}" | grep -q "^data:image/gif;base64,"; then
echo "FAIL: valid GIF file - expected data URI starting with 'data:image/gif;base64,', got '${OUTPUT}'"
exit 1
fi
# Test 8: Not a file (directory) produces error
mkdir -p "${TEST_TEMP_DIR}/not_a_file"
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/not_a_file" 2>&1) || true
if ! echo "${OUTPUT}" | grep -q "Not a regular file"; then
echo "FAIL: directory error - expected 'Not a regular file', got '${OUTPUT}'"
exit 1
fi
# Test 9: Multiple images - second file nonexistent should produce error
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/valid.png"
OUTPUT=$("${DRHARNESS}" -i "${TEST_TEMP_DIR}/valid.png" -i "nonexistent.png" -F "${TEST_TEMP_DIR}/chat.json" -n -m "test" 2>&1) || true
if ! echo "${OUTPUT}" | grep -q "Image file not found"; then
echo "FAIL: multiple images with nonexistent second file - expected error, got '${OUTPUT}'"
exit 1
fi
echo "All image flag tests passed"
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# test_image_url.sh - Tests for ImageUrl and ContentPart with image_url support
# Test ImageUrl deserialization with url and detail fields
OUTPUT=$("${DRHARNESS}" -R ImageUrl '{"url": "data:image/png;base64,abc123", "detail": "auto"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ImageUrl with url and detail - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ImageUrl deserialization with only url field (detail should default to "auto")
OUTPUT=$("${DRHARNESS}" -R ImageUrl '{"url": "https://example.com/image.png"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ImageUrl with only url - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ImageUrl deserialization with empty object (should use defaults)
OUTPUT=$("${DRHARNESS}" -R ImageUrl '{}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ImageUrl empty object - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with text type
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "text", "text": "Hello world"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with text type - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with text type and only type field (text should be optional)
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "text"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with only type text - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123", "detail": "auto"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with image_url type - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type and minimal image_url (only url)
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with minimal image_url - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with empty object (should use defaults)
OUTPUT=$("${DRHARNESS}" -R ContentPart '{}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart empty object - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type and detail "low"
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc", "detail": "low"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with image_url detail low - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type and detail "high"
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc", "detail": "high"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with image_url detail high - expected 'success', got '${OUTPUT}'"
exit 1
fi
echo "All ImageUrl and ContentPart tests passed!"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# test_json_variant.sh - Tests for std::variant JSON serialization/deserialization
# Test TestVariantType with string alternative (JSON string)
OUTPUT=$("${DRHARNESS}" -R TestVariantType '"hello world"')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: TestVariantType string alternative - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with vector alternative (JSON array with valid FunctionCall)
OUTPUT=$("${DRHARNESS}" -R TestVariantType '[{"name": "test", "arguments": "{}"}]')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: TestVariantType vector alternative - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with empty vector
OUTPUT=$("${DRHARNESS}" -R TestVariantType '[]')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: TestVariantType empty vector - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with multiple FunctionCalls in vector
OUTPUT=$("${DRHARNESS}" -R TestVariantType '[{"name": "func1", "arguments": "{}"}, {"name": "func2", "arguments": "{}"}]')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: TestVariantType multiple items in vector - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with invalid JSON type (number - no matching alternative)
OUTPUT=$("${DRHARNESS}" -R TestVariantType '123')
if [ "${OUTPUT}" != "error: no variant alternative matched JSON type" ]; then
echo "FAIL: TestVariantType number type - expected error, got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with invalid JSON type (object - no matching alternative)
OUTPUT=$("${DRHARNESS}" -R TestVariantType '{"name": "test"}')
if [ "${OUTPUT}" != "error: no variant alternative matched JSON type" ]; then
echo "FAIL: TestVariantType object type - expected error, got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with invalid JSON type (boolean - no matching alternative)
OUTPUT=$("${DRHARNESS}" -R TestVariantType 'true')
if [ "${OUTPUT}" != "error: no variant alternative matched JSON type" ]; then
echo "FAIL: TestVariantType boolean type - expected error, got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with empty string (valid string alternative)
OUTPUT=$("${DRHARNESS}" -R TestVariantType '""')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: TestVariantType empty string - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test TestVariantType with vector containing FunctionCall with only optional fields
OUTPUT=$("${DRHARNESS}" -R TestVariantType '[{"name": "test"}]')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: TestVariantType partial FunctionCall - expected 'success', got '${OUTPUT}'"
exit 1
fi
echo "All variant tests passed!"
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# test_multimodal_message.sh - Tests for multimodal message construction with -i flag
# Create a minimal 1x1 PNG file for testing
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test.png"
# Test 1: Verify multimodal message format in chat.json with -i and -d flags
echo "$(be_done "Response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat.json" \
-n \
-d \
-i "${TEST_TEMP_DIR}/test.png" \
-m "describe this image" 2>/dev/null
# Verify the first message (index 0) is a user message with multimodal content
ROLE=$(dr_history_role "${TEST_TEMP_DIR}/chat.json" 0)
if [ "$ROLE" != "user" ]; then
echo "FAIL: Expected first message role to be 'user', got '${ROLE}'"
exit 1
fi
# Verify content is an array (multimodal format)
CONTENT_TYPE=$(dr_content_type "${TEST_TEMP_DIR}/chat.json" 0)
if [ "$CONTENT_TYPE" != "array" ]; then
echo "FAIL: Expected content to be an array for multimodal message, got '${CONTENT_TYPE}'"
exit 1
fi
# Verify first content part is text type
TEXT_TYPE=$(dr_content_part_type "${TEST_TEMP_DIR}/chat.json" 0 0)
if [ "$TEXT_TYPE" != "text" ]; then
echo "FAIL: Expected first content part type to be 'text', got '${TEXT_TYPE}'"
exit 1
fi
# Verify text content matches the prompt
TEXT_CONTENT=$(dr_content_part_text "${TEST_TEMP_DIR}/chat.json" 0 0)
if [ "$TEXT_CONTENT" != "describe this image" ]; then
echo "FAIL: Expected text content to be 'describe this image', got '${TEXT_CONTENT}'"
exit 1
fi
# Verify second content part is image_url type
IMAGE_TYPE=$(dr_content_part_type "${TEST_TEMP_DIR}/chat.json" 0 1)
if [ "$IMAGE_TYPE" != "image_url" ]; then
echo "FAIL: Expected second content part type to be 'image_url', got '${IMAGE_TYPE}'"
exit 1
fi
# Verify image_url has a valid data URI
IMAGE_URL=$(dr_content_part_image_url "${TEST_TEMP_DIR}/chat.json" 0 1)
if ! echo "$IMAGE_URL" | grep -q "^data:image/png;base64,"; then
echo "FAIL: Expected image URL to be a PNG data URI, got '${IMAGE_URL}'"
exit 1
fi
# Verify image detail is "auto"
IMAGE_DETAIL=$(dr_content_part_image_detail "${TEST_TEMP_DIR}/chat.json" 0 1)
if [ "$IMAGE_DETAIL" != "auto" ]; then
echo "FAIL: Expected image detail to be 'auto', got '${IMAGE_DETAIL}'"
exit 1
fi
# Test 2: Verify multiple images are included
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test2.png"
echo "$(be_done "Response 2")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat2.json" \
-n \
-d \
-i "${TEST_TEMP_DIR}/test.png" \
-i "${TEST_TEMP_DIR}/test2.png" \
-m "compare images" 2>/dev/null
# Verify content array has 3 parts (1 text + 2 images)
CONTENT_LENGTH=$(dr_content_parts_count "${TEST_TEMP_DIR}/chat2.json" 0)
if [ "$CONTENT_LENGTH" != "3" ]; then
echo "FAIL: Expected 3 content parts (1 text + 2 images), got '${CONTENT_LENGTH}'"
exit 1
fi
# Verify third content part is also image_url
THIRD_TYPE=$(dr_content_part_type "${TEST_TEMP_DIR}/chat2.json" 0 2)
if [ "$THIRD_TYPE" != "image_url" ]; then
echo "FAIL: Expected third content part type to be 'image_url', got '${THIRD_TYPE}'"
exit 1
fi
# Test 3: Verify backward compatibility - text-only message when no images
echo "$(be_done "Response 3")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat3.json" \
-n \
-d \
-m "text only prompt" 2>/dev/null
# Verify content is a string (not an array) when no images
BACKWARD_TYPE=$(dr_content_type "${TEST_TEMP_DIR}/chat3.json" 0)
if [ "$BACKWARD_TYPE" != "string" ]; then
echo "FAIL: Expected content to be a string for text-only message, got '${BACKWARD_TYPE}'"
exit 1
fi
BACKWARD_CONTENT=$(dr_content_text "${TEST_TEMP_DIR}/chat3.json" 0)
if [ "$BACKWARD_CONTENT" != "text only prompt" ]; then
echo "FAIL: Expected text content to be 'text only prompt', got '${BACKWARD_CONTENT}'"
exit 1
fi
# Test 4: Verify -i works with -t tool filter flag
echo "$(be_done "Response with tool filter")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat4.json" \
-n \
-d \
-t read_file \
-i "${TEST_TEMP_DIR}/test.png" \
-m "read and describe" 2>/dev/null
# Verify multimodal format still works with tool filter
TOOL_FILTER_TYPE=$(dr_content_type "${TEST_TEMP_DIR}/chat4.json" 0)
if [ "$TOOL_FILTER_TYPE" != "array" ]; then
echo "FAIL: Expected multimodal content array with -t flag, got '${TOOL_FILTER_TYPE}'"
exit 1
fi
# Test 5: Verify -i works with -M model override flag
echo "$(be_done "Response with model override")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat5.json" \
-n \
-d \
-M "test-model" \
-i "${TEST_TEMP_DIR}/test.png" \
-m "test model override" 2>/dev/null
# Verify multimodal format still works with model override
MODEL_OVERRIDE_TYPE=$(dr_content_type "${TEST_TEMP_DIR}/chat5.json" 0)
if [ "$MODEL_OVERRIDE_TYPE" != "array" ]; then
echo "FAIL: Expected multimodal content array with -M flag, got '${MODEL_OVERRIDE_TYPE}'"
exit 1
fi
echo "All multimodal message tests passed"
+192
View File
@@ -0,0 +1,192 @@
#!/bin/bash
# test_multimodal_roundtrip.sh - Tests for chat file persistence round-trip with multimodal messages
# Create a minimal 1x1 PNG file for testing
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test.png"
# =============================================================================
# Test 1: Round-trip serialization preserves multimodal content
# =============================================================================
# Step 1: Create a chat with multimodal message
echo "$(be_done "First response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat.json" \
-n \
-d \
-i "${TEST_TEMP_DIR}/test.png" \
-m "describe this image" 2>/dev/null
# Verify the multimodal message was serialized correctly
CONTENT_TYPE=$(jq -r '.history[0].content | type' "${TEST_TEMP_DIR}/chat.json")
if [ "$CONTENT_TYPE" != "array" ]; then
echo "FAIL: Expected content to be array, got '${CONTENT_TYPE}'"
exit 1
fi
# Capture the original image URL for comparison
ORIGINAL_IMAGE_URL=$(jq -r '.history[0].content[1].image_url.url' "${TEST_TEMP_DIR}/chat.json")
if ! echo "$ORIGINAL_IMAGE_URL" | grep -q "^data:image/png;base64,"; then
echo "FAIL: Expected image URL to be PNG data URI"
exit 1
fi
# Step 2: Resume the chat and continue the conversation
echo "$(be_done "Second response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat.json" \
-d \
-m "continue conversation" 2>/dev/null
# =============================================================================
# Test 2: Verify round-trip preserves multimodal content after resume
# =============================================================================
# The first message (index 0) should still be the user multimodal message
ROLE=$(jq -r '.history[0].role' "${TEST_TEMP_DIR}/chat.json")
if [ "$ROLE" != "user" ]; then
echo "FAIL: Expected first message role to be 'user', got '${ROLE}'"
exit 1
fi
# Content should still be an array
CONTENT_TYPE=$(jq -r '.history[0].content | type' "${TEST_TEMP_DIR}/chat.json")
if [ "$CONTENT_TYPE" != "array" ]; then
echo "FAIL: After resume, expected content to be array, got '${CONTENT_TYPE}'"
exit 1
fi
# Verify first content part is still text type
TEXT_TYPE=$(jq -r '.history[0].content[0].type' "${TEST_TEMP_DIR}/chat.json")
if [ "$TEXT_TYPE" != "text" ]; then
echo "FAIL: Expected first content part type to be 'text', got '${TEXT_TYPE}'"
exit 1
fi
# Verify text content is preserved
TEXT_CONTENT=$(jq -r '.history[0].content[0].text' "${TEST_TEMP_DIR}/chat.json")
if [ "$TEXT_CONTENT" != "describe this image" ]; then
echo "FAIL: Expected text content 'describe this image', got '${TEXT_CONTENT}'"
exit 1
fi
# Verify second content part is still image_url type
IMAGE_TYPE=$(jq -r '.history[0].content[1].type' "${TEST_TEMP_DIR}/chat.json")
if [ "$IMAGE_TYPE" != "image_url" ]; then
echo "FAIL: Expected second content part type to be 'image_url', got '${IMAGE_TYPE}'"
exit 1
fi
# =============================================================================
# Test 3: Image data URI is preserved in round-trip
# =============================================================================
RESUMED_IMAGE_URL=$(jq -r '.history[0].content[1].image_url.url' "${TEST_TEMP_DIR}/chat.json")
if [ "$ORIGINAL_IMAGE_URL" != "$RESUMED_IMAGE_URL" ]; then
echo "FAIL: Image data URI not preserved in round-trip"
echo "Original: $ORIGINAL_IMAGE_URL"
echo "Resumed: $RESUMED_IMAGE_URL"
exit 1
fi
# Verify image detail is preserved
IMAGE_DETAIL=$(jq -r '.history[0].content[1].image_url.detail' "${TEST_TEMP_DIR}/chat.json")
if [ "$IMAGE_DETAIL" != "auto" ]; then
echo "FAIL: Expected image detail 'auto', got '${IMAGE_DETAIL}'"
exit 1
fi
# =============================================================================
# Test 4: Text-only message round-trip (backward compatibility)
# =============================================================================
# Create a new chat with text-only message
echo "$(be_done "Text response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/text_chat.json" \
-n \
-d \
-m "text only prompt" 2>/dev/null
# Verify text-only content is a string
TEXT_CONTENT_TYPE=$(jq -r '.history[0].content | type' "${TEST_TEMP_DIR}/text_chat.json")
if [ "$TEXT_CONTENT_TYPE" != "string" ]; then
echo "FAIL: Expected text-only content to be string, got '${TEXT_CONTENT_TYPE}'"
exit 1
fi
# Resume and verify text-only message is preserved
echo "$(be_done "More text")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/text_chat.json" \
-d \
-m "second text prompt" 2>/dev/null
# Verify first message content is still a string
RESUMED_TEXT_TYPE=$(jq -r '.history[0].content | type' "${TEST_TEMP_DIR}/text_chat.json")
if [ "$RESUMED_TEXT_TYPE" != "string" ]; then
echo "FAIL: After resume, expected text-only content to be string, got '${RESUMED_TEXT_TYPE}'"
exit 1
fi
RESUMED_TEXT=$(jq -r '.history[0].content' "${TEST_TEMP_DIR}/text_chat.json")
if [ "$RESUMED_TEXT" != "text only prompt" ]; then
echo "FAIL: Text content not preserved, expected 'text only prompt', got '${RESUMED_TEXT}'"
exit 1
fi
# =============================================================================
# Test 5: Mixed content types in same chat history
# =============================================================================
# Create chat with multimodal message first
echo "$(be_done "Response to multimodal")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/mixed_chat.json" \
-n \
-d \
-i "${TEST_TEMP_DIR}/test.png" \
-m "first multimodal" 2>/dev/null
# Resume and add text-only message
echo "$(be_done "Response to text")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/mixed_chat.json" \
-d \
-m "second text only" 2>/dev/null
# Verify first message (multimodal) is array
FIRST_CONTENT_TYPE=$(jq -r '.history[0].content | type' "${TEST_TEMP_DIR}/mixed_chat.json")
if [ "$FIRST_CONTENT_TYPE" != "array" ]; then
echo "FAIL: Mixed chat: first message should be array, got '${FIRST_CONTENT_TYPE}'"
exit 1
fi
# Verify third message (user text-only) is string (index 2: user, assistant, user)
THIRD_CONTENT_TYPE=$(jq -r '.history[2].content | type' "${TEST_TEMP_DIR}/mixed_chat.json")
if [ "$THIRD_CONTENT_TYPE" != "string" ]; then
echo "FAIL: Mixed chat: third message should be string, got '${THIRD_CONTENT_TYPE}'"
exit 1
fi
# Resume again and verify both types still preserved
echo "$(be_done "Third response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/mixed_chat.json" \
-d \
-m "third prompt" 2>/dev/null
# Verify after second resume
FINAL_FIRST_TYPE=$(jq -r '.history[0].content | type' "${TEST_TEMP_DIR}/mixed_chat.json")
if [ "$FINAL_FIRST_TYPE" != "array" ]; then
echo "FAIL: After second resume: first message should still be array"
exit 1
fi
FINAL_THIRD_TYPE=$(jq -r '.history[2].content | type' "${TEST_TEMP_DIR}/mixed_chat.json")
if [ "$FINAL_THIRD_TYPE" != "string" ]; then
echo "FAIL: After second resume: third message should still be string"
exit 1
fi
# Verify the multimodal content data is still intact
FINAL_IMAGE_URL=$(jq -r '.history[0].content[1].image_url.url' "${TEST_TEMP_DIR}/mixed_chat.json")
if ! echo "$FINAL_IMAGE_URL" | grep -q "^data:image/png;base64,"; then
echo "FAIL: After second resume: image URL corrupted"
exit 1
fi
echo "All multimodal round-trip tests passed"
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
# test_print_history_multimodal.sh - Tests for print_history (-p flag) with multimodal content
# Create a minimal 1x1 PNG file for testing
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test.png"
# =============================================================================
# Test 1: Verify -p flag displays text content parts in multimodal messages
# =============================================================================
# Create a chat with multimodal message
echo "$(be_done "Response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat.json" \
-n \
-d \
-i "${TEST_TEMP_DIR}/test.png" \
-m "describe this image" 2>/dev/null
# Print history using -p flag
OUTPUT=$("${DRHARNESS}" -p "${TEST_TEMP_DIR}/chat.json" 2>/dev/null)
# Verify text content is displayed
if ! echo "$OUTPUT" | grep -q "describe this image"; then
echo "FAIL: Expected 'describe this image' in output, got:"
echo "$OUTPUT"
exit 1
fi
# =============================================================================
# Test 2: Verify -p flag indicates presence of image_url content parts
# =============================================================================
# Verify [Image] indicator is present
if ! echo "$OUTPUT" | grep -q "\[Image\]"; then
echo "FAIL: Expected '[Image]' indicator in output, got:"
echo "$OUTPUT"
exit 1
fi
# =============================================================================
# Test 3: Verify text-only messages still display correctly
# =============================================================================
# Create a chat with text-only message
echo "$(be_done "Text response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat_text.json" \
-n \
-d \
-m "text only prompt" 2>/dev/null
# Print history using -p flag
TEXT_OUTPUT=$("${DRHARNESS}" -p "${TEST_TEMP_DIR}/chat_text.json" 2>/dev/null)
# Verify text content is displayed
if ! echo "$TEXT_OUTPUT" | grep -q "text only prompt"; then
echo "FAIL: Expected 'text only prompt' in output, got:"
echo "$TEXT_OUTPUT"
exit 1
fi
# Verify no [Image] indicator for text-only message
if echo "$TEXT_OUTPUT" | grep -q "\[Image\]"; then
echo "FAIL: Did not expect '[Image]' in text-only output, got:"
echo "$TEXT_OUTPUT"
exit 1
fi
# =============================================================================
# Test 4: Verify multiple images are all indicated
# =============================================================================
# Create second image file
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test2.png"
echo "$(be_done "Response 2")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat_multi.json" \
-n \
-d \
-i "${TEST_TEMP_DIR}/test.png" \
-i "${TEST_TEMP_DIR}/test2.png" \
-m "compare images" 2>/dev/null
# Print history using -p flag
MULTI_OUTPUT=$("${DRHARNESS}" -p "${TEST_TEMP_DIR}/chat_multi.json" 2>/dev/null)
# Count [Image] occurrences - should be 2
IMAGE_COUNT=$(echo "$MULTI_OUTPUT" | grep -o "\[Image\]" | wc -l)
if [ "$IMAGE_COUNT" != "2" ]; then
echo "FAIL: Expected 2 '[Image]' indicators for 2 images, got ${IMAGE_COUNT}"
echo "$MULTI_OUTPUT"
exit 1
fi
echo "All print_history multimodal tests passed"
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="basic_messages"
json=$(create_messages_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Check header exists
if ! echo "$OUTPUT" | grep -q "# History Rebase Editor"; then
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="empty_message"
json=$(create_messages_json user "")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] (empty)"; then
echo "FAIL: $test_name - should show (empty) for empty content"
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="header_comments"
json=$(create_messages_json user "test")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick - keep the message unchanged"; then
echo "FAIL: $test_name - missing pick explanation"
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="message_indices"
json=$(create_messages_json user "msg1" assistant "msg2" tool "msg3")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] msg1"; then
echo "FAIL: $test_name - missing index 0"
@@ -7,7 +7,7 @@ json=$(create_messages_json user "first line
second line
third line")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] first line"; then
echo "FAIL: $test_name - should show only first line"
@@ -5,7 +5,7 @@ test_name="multiple_tool_calls"
json=$(create_messages_json assistant "" tool_call "call_1" "read_file" "" tool_call "call_2" "write_file" "")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[assistant\] \[tool_call: read_file \.\.\.\]"; then
echo "FAIL: $test_name - should show first tool call with ..."
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="scissors_line"
json=$(create_messages_json user "test")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Check for scissors line
if ! echo "$OUTPUT" | grep -q -- "-- >8 --"; then
@@ -5,7 +5,7 @@ test_name="special_characters"
json=$(create_messages_json user 'hello "quotes" and $dollar' assistant 'backslash\here')
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Should handle quotes and special characters
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\]"; then
@@ -5,7 +5,7 @@ test_name="tool_call_display"
json=$(create_messages_json assistant "" tool_call "call_1" "run_shell" "")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[assistant\] \[tool_call: run_shell\]"; then
echo "FAIL: $test_name - should show tool_call display"
+1 -1
View File
@@ -5,7 +5,7 @@ test_name="tool_message"
json=$(create_messages_json tool "tool result content")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[tool\] tool result content"; then
echo "FAIL: $test_name - should show tool result content"
@@ -5,7 +5,7 @@ test_name="unicode_characters"
json=$(create_messages_json user "こんにちは" assistant "👋 hello 世界")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Should preserve unicode characters
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] こんにちは"; then
@@ -7,7 +7,7 @@ test_name="very_long_content"
long_content=$(printf 'x%.0s' {1..1000})
json=$(create_messages_json user "$long_content")
OUTPUT=$("${DRHARNESS}" -B "$json")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Should handle long content without crashing
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\]"; then
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
# test_tool_read_multi_modal.sh - Tests for read_multi_modal tool
# Create a minimal 1x1 PNG file for testing
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > test_image.png
# Test 1: Successfully read a valid image file
# Mock response: ask for read_multi_modal
tool_call read_multi_modal '{"path":"test_image.png"}' >> mock_responses.txt
be_done 'Read complete' >> mock_responses.txt
OUTPUT=$("${DRHARNESS}" -F chat.json -n -m "Read image" -t read_multi_modal -d < "mock_responses.txt" 2>/dev/null)
if [ "${OUTPUT}" != "Read complete" ]; then
echo "FAIL: read_multi_modal tool - expected 'Read complete', got '${OUTPUT}'"
exit 1
fi
# Verify tool result is stored as an array (Content with vector<ContentPart>)
CONTENT_TYPE=$(dr_content_type chat.json 2)
if [ "$CONTENT_TYPE" != "array" ]; then
echo "FAIL: Expected tool result content to be an array, got '${CONTENT_TYPE}'"
exit 1
fi
# Verify the content part type is image_url
PART_TYPE=$(dr_content_part_type chat.json 2 0)
if [ "$PART_TYPE" != "image_url" ]; then
echo "FAIL: Expected content part type to be 'image_url', got '${PART_TYPE}'"
exit 1
fi
# Verify the image URL is a valid PNG data URI
IMAGE_URL=$(dr_content_part_image_url chat.json 2 0)
if ! echo "$IMAGE_URL" | grep -q "^data:image/png;base64,"; then
echo "FAIL: Expected image URL to be PNG data URI, got '${IMAGE_URL}'"
exit 1
fi
# Test 2: Error handling for non-existent file
rm -f chat.json
tool_call read_multi_modal '{"path":"nonexistent_image.png"}' >> mock_responses2.txt
be_done 'Error handled' >> mock_responses2.txt
"${DRHARNESS}" -F chat.json -n -m "Read nonexistent" -t read_multi_modal -d < "mock_responses2.txt" >/dev/null 2>&1
# Verify tool result is a string error message
CONTENT_TYPE=$(dr_content_type chat.json 2)
if [ "$CONTENT_TYPE" != "string" ]; then
echo "FAIL: Expected error content to be a string, got '${CONTENT_TYPE}'"
exit 1
fi
RESULT=$(dr_result chat.json 2)
if ! echo "$RESULT" | grep -q "Failed to read image"; then
echo "FAIL: Expected error message about failed image read, got '${RESULT}'"
exit 1
fi
# Test 3: Read a JPG file (different MIME type)
printf '\xFF\xD8\xFF\xE0\x00\x10JFIF' > test_image.jpg
rm -f chat.json
tool_call read_multi_modal '{"path":"test_image.jpg"}' >> mock_responses3.txt
be_done 'JPG read complete' >> mock_responses3.txt
"${DRHARNESS}" -F chat.json -n -m "Read JPG" -t read_multi_modal -d < "mock_responses3.txt" >/dev/null 2>&1
# Verify the image URL is a valid JPEG data URI
IMAGE_URL=$(dr_content_part_image_url chat.json 2 0)
if ! echo "$IMAGE_URL" | grep -q "^data:image/jpeg;base64,"; then
echo "FAIL: Expected image URL to be JPEG data URI, got '${IMAGE_URL}'"
exit 1
fi
echo "PASS: tool_read_multi_modal"
+41
View File
@@ -0,0 +1,41 @@
#include "tool.hpp"
#include "read_multi_modal.hpp"
#include "json_types.hpp"
#include "base64_util.hpp"
Tool make_read_multi_modal() {
return Tool{
.name = "read_multi_modal",
.description = ""
"Reads multi modal files (images, audio, etc)\n",
.parameters_schema = {
{"type", "object"},
{"properties", {
{"path", {
{"type", "string"},
{"description", "Absolute or relative path to multi modal file to read"}
}},
}},
{"required", {"path"}}
},
.execute = create_tool_execute<ReadFileToolParams>([](const ReadFileToolParams& params) -> Content {
std::vector<ContentPart> ret{};
ContentPart image_part;
image_part.type = "image_url";
ImageUrl img_url;
auto uri = read_image_as_data_uri(params.path);
if (!uri)
return "Failed to read image " + uri.error();
img_url.url = *uri;
image_part.image_url = img_url;
ret.push_back(image_part);
return ret;
})
};
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include "tool.hpp"
Tool make_read_multi_modal();