Merge pull request 'feature/rebase' (#44) from feature/rebase into master

Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/44
This commit was merged in pull request #44.
This commit is contained in:
2026-07-23 17:03:55 -05:00
95 changed files with 4113 additions and 204 deletions
+2
View File
@@ -32,6 +32,8 @@ add_library(drharness_lib
src/debug_client.cpp
src/editor.cpp
src/skill.cpp
src/rebase_engine.cpp
src/utility.cpp
src/base64_util.cpp
${TOOL_SOURCES}
)
+13 -4
View File
@@ -94,10 +94,19 @@ Exception only if it is API calling code which is hard to test
deterministically.
### Adding new flags to the CLI for testing
Adding new flags to the CLI for testing is allowed and ENCOURAGED! For example
for the skill stuff, we added a flag -D that just dumps all skill directories
found. This is useless to the end user, but lets us test the fact that our
skill directory search was working.
Adding new flags to the CLI for testing is allowed and ENCOURAGED! These
flags serve as **hooks into internal functionality** to enable testing of
modules that don't have a natural CLI entry point.
For example, for the skill stuff, we added a flag -D that just dumps all skill
directories found. This is useless to the end user, but lets us test the fact
that our skill directory search was working.
**Test flags should be minimal hooks:**
- They should accept input, call the internal function, and output the result
- They should NOT contain test logic (assertions, validation, etc.)
- All test logic belongs in the Bash test scripts in `tests/`
- The flag's only job is to bridge CLI arguments to the internal function
If you add a new flag for testing you don't need to update the README.md (but
updating the -h message is still mandatory).
+5 -2
View File
@@ -1,6 +1,6 @@
# drharness
A C++20 LLM (Open AI Compatible) harness designed for agentic workflows, file
A C++23 LLM (Open AI Compatible) harness designed for agentic workflows, file
manipulation, and task delegation. It implements a tool-use loop that allows a
large language model to dynamically invoke shell commands, edit files, and
delegate sub-tasks to other specialized agents. (I wouldn't use the agents
@@ -21,7 +21,7 @@ to it)
## 🛠️ How to Build
The project requires CMake version 3.16 or newer, C++20 support, `curl`, and the `nlohmann_json` library.
The project requires CMake version 3.16 or newer, C++23 support, `curl`, and the `nlohmann_json` library.
```bash
# Configure the build
@@ -97,6 +97,9 @@ export EDITOR=nvim # or vscode, nano, vim, emacs, etc.
*Note: If neither `VISUAL` nor `EDITOR` is set, the harness will refuse to launch the editor and throw an error instead.*
For detailed documentation on history editing, see [docs/history-editing.md](docs/history-editing.md).
## 🐳 Docker Isolation
The harness can be compiled and run inside an isolated Alpine Linux container.
+83
View File
@@ -0,0 +1,83 @@
## 📜 History Editing
If a conversation goes off track, you can surgically edit your conversation history using the interactive history editing mode. This allows you to rewind to a specific point, modify messages, or remove problematic exchanges without starting over.
### Invoking History Editing
Use the `-b` or `--edit-history` flag along with `-F` to specify your chat file:
```bash
./build/drharness -F conversation.json -b
```
*Note: The `-F` flag is required because editing requires an existing chat file.*
### The Rebase Editor Format
When invoked, the harness opens your configured editor with all messages in your conversation listed in order. Each line follows a git rebase-style format:
```
<command> <index> [<role>] <display_content>
```
**Commands:**
- `pick`: Keep the message unchanged
- `edit`: Modify the message content and/or role
- `drop`: Delete the message (or simply remove the line)
**Role:** (optional, only for `edit` commands)
- `[user]`
- `[assistant]`
- `[tool]`
**Display content:** The first line of the message (for reference only)
Lines starting with `#` are comments and are ignored. Empty lines are treated as drops.
### Editing Message Content and Roles
When you mark a message with `edit`, the harness will open your editor again to let you modify the content. The edit prompt shows:
- The previous message as context (for reference)
- The `>8` separator
- The message content you can edit
You can change the role by modifying the role in the rebase line (e.g., `edit 2 [user]`).
### Common Workflows
**Editing a single message:**
```
pick 0 [user] Hello, can you help me?
pick 1 [assistant] Sure! What do you need?
edit 2 [assistant] I misunderstood. Let me try again.
pick 3 [user] That's better, thanks!
```
Then, when editing message 2, you'll be prompted to enter the new content.
**Dropping unwanted messages:**
```
pick 0 [user] Start the project
drop 1 [assistant] Wrong approach
pick 2 [user] Let me try a different way
```
Or simply delete the line:
```
pick 0 [user] Start the project
# pick 1 [assistant] Wrong approach
pick 2 [user] Let me try a different way
```
**Modifying message roles:**
```
pick 0 [user] Write a function
edit 1 [user] I meant write a class instead
```
**Continuing a conversation after editing:**
After saving and exiting the editor, your changes are persisted to the chat file. The program exits, allowing you to inspect the result. To continue the conversation:
```bash
./build/drharness -F conversation.json -m "Continue from where we left off"
```
*Note: The history editor uses strict validation. Any syntax error (invalid command, out-of-range index, invalid role) will cause the editor to abort with an aggregated error message. No partial changes are applied.*
+6 -2
View File
@@ -548,8 +548,12 @@ struct ShellToolParams {
DRH_DEFINE_TYPE_OPTIONAL(ShellToolParams, command, timeout_seconds)
// Safe JSON parsing - returns discarded JSON on parse failure
inline nlohmann::json parse_json_safely(const std::string& input) {
return nlohmann::json::parse(input, nullptr, false);
inline std::expected<nlohmann::json, std::string> parse_json_safely(const std::string& input) {
auto j = nlohmann::json::parse(input, nullptr, false);
if (j.is_discarded()) {
return std::unexpected("error: invalid JSON");
}
return j;
}
// Safe regex construction - returns error on invalid pattern
+83
View File
@@ -0,0 +1,83 @@
#pragma once
#include "json_types.hpp"
#include <string>
#include <vector>
#include <expected>
// Forward declaration
struct Message;
enum class RebaseCommand { Pick, Edit, Drop };
// JSON serialization for RebaseCommand
inline void to_json(nlohmann::json& j, RebaseCommand cmd) {
switch (cmd) {
case RebaseCommand::Pick: j = "pick"; break;
case RebaseCommand::Edit: j = "edit"; break;
case RebaseCommand::Drop: j = "drop"; break;
}
}
template<>
inline std::expected<RebaseCommand, std::string> json_to<RebaseCommand>(const nlohmann::json& j) {
if (!j.is_string()) return std::unexpected("expected string for RebaseCommand");
std::string s = j.get<std::string>();
if (s == "pick") return RebaseCommand::Pick;
if (s == "edit") return RebaseCommand::Edit;
if (s == "drop") return RebaseCommand::Drop;
return std::unexpected("invalid RebaseCommand: '" + s + "'");
}
template<> struct has_json_to<RebaseCommand> : std::true_type {};
struct RebaseLine {
RebaseCommand command;
size_t index;
std::string role;
std::string content; // For edit commands, stores the new content
bool content_edited = false; // True if content was explicitly edited (even if empty)
};
DRH_DEFINE_TYPE_MIXED(RebaseLine, (command, index, role), (content))
class RebaseEngine {
public:
std::expected<std::vector<RebaseLine>, std::string> parse_rebase_file(
const std::string& content, size_t history_size) const;
std::expected<std::vector<Message>, std::string> apply_rebase(
const std::vector<RebaseLine>& lines,
const std::vector<Message>& history) const;
};
class HistoryEditor {
public:
// Build the rebase editor content from a message history
// Each message is formatted as: pick N [role] first_line_of_content
// Tool calls display as: [tool_call: function_name]
// Empty messages display as: (empty)
std::string build_rebase_content(const std::vector<Message>& history) const;
// Build the edit prompt for editing a single message
// Shows previous message as context, separator, and current content to edit
// For message 0, shows "(no previous message)" instead of previous message
std::string build_edit_prompt(const std::vector<Message>& history,
size_t edit_index,
const std::string& role) const;
// Full interactive edit flow
// 1. Build rebase content and spawn editor
// 2. Parse and validate rebase commands
// 3. For each edit command, build edit prompt and spawn editor
// 4. Apply all changes
// Returns modified history on success, error message on failure
std::expected<std::vector<Message>, std::string> edit_history_interactive(
const std::vector<Message>& history) const;
private:
// Extract the first line of content from a message
std::string get_first_line(const std::string& content) const;
// Format display text for a single message
std::string get_message_display(const Message& msg) const;
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <string>
#include <expected>
#include <functional>
// Trim whitespace from both ends of a string
std::string trim(const std::string& s);
// Generate a random alphanumeric string
std::string random_string(size_t length = 8);
// Generate a random temp file path
std::string random_file_name(size_t length = 8);
// RAII defer helper class for cleanup
class Defer {
std::function<void()> fn;
public:
Defer(std::function<void()> f) : fn(f) {}
~Defer() { fn(); }
};
// Spawn an editor with initial content and return the content after the separator (-- >8 --)
std::expected<std::string, std::string> spawn_editor(const std::string& initial_content);
// Parse an unsigned long from a C-string, returning an error if parsing fails
std::expected<unsigned long, std::string> string_toul(std::string const& str);
+4 -3
View File
@@ -12,10 +12,11 @@ std::expected<Message, std::string> DebugClient::chat(const std::vector<Message>
return std::unexpected("DebugClient failed to read from stdin");
}
auto j = parse_json_safely(input);
if (j.is_discarded()) {
return std::unexpected("DebugClient invalid JSON input: parse error");
auto j_result = parse_json_safely(input);
if (!j_result) {
return std::unexpected("DebugClient invalid JSON input: " + j_result.error());
}
auto j = j_result.value();
auto result = json_to<ChatResponse>(j);
if (!result) {
return std::unexpected(std::string("DebugClient invalid JSON input: ") + result.error());
+43 -125
View File
@@ -1,138 +1,56 @@
#include "editor.hpp"
#include "ollama_client.hpp"
#include <fstream>
#include <iostream>
#include "utility.hpp"
#include <sstream>
#include <cstdlib>
#include <string>
#include <algorithm>
#include <filesystem>
#include <random>
#include <functional>
static std::string trim(const std::string& s) {
auto start = s.find_first_not_of(" \t\n\r");
return (start == std::string::npos) ? "" : s.substr(start);
}
static std::string random_string(size_t length = 8) {
static const char chars[] =
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789";
static std::random_device rd;
static std::mt19937 gen(rd());
static std::uniform_int_distribution<> dis(0, sizeof(chars) - 2);
std::string s;
for (size_t i = 0; i < length; ++i) {
s += chars[dis(gen)];
}
return s;
}
static std::string random_file_name(size_t length = 8)
{
namespace fs = std::filesystem;
fs::path temp_dir = fs::temp_directory_path();
fs::path temp_file = temp_dir / ("tmp_" + random_string(length));
return temp_file.string();
}
class Defer {
std::function<void()> fn;
public:
Defer(std::function<void()> f) : fn(f) {}
~Defer() { fn(); }
};
std::expected<std::string, std::string> run_editor(HarnessState state, bool full_history) {
auto file_path = random_file_name();
auto cleanup = Defer([&](){
std::filesystem::remove(file_path);
});
std::ostringstream initial_content;
{
std::ofstream file(file_path);
if (!file.is_open()) {
return std::unexpected("Failed to open temp file for editor.");
}
if (state.history.empty()) {
file << "New Conversation.\n\n";
file << "This is the start of a new chat. Type your prompt below the separator.\n";
file << "The AI will respond after you save and close the editor.\n\n";
if (state.history.empty()) {
initial_content << "New Conversation.\n\n";
initial_content << "This is the start of a new chat. Type your prompt below the separator.\n";
initial_content << "The AI will respond after you save and close the editor.\n\n";
} else {
nlohmann::json j = nlohmann::json(state);
if (full_history) {
std::ostringstream history_str;
print_history(history_str, j, false);
initial_content << history_str.str();
} else {
nlohmann::json j = nlohmann::json(state);
if (full_history) {
print_history(file, j, false);
} else {
// Find the last AI response
const Message* last_ai_msg = nullptr;
for (int i = static_cast<int>(state.history.size()) - 1; i >= 0; --i) {
if (state.history[i].role == "assistant") {
last_ai_msg = &state.history[i];
break;
}
}
if (last_ai_msg) {
file << "[" << last_ai_msg->role << "] ";
if (!last_ai_msg->reasoning.empty()) {
file << "[Thinking: " << last_ai_msg->reasoning << "] ";
}
file << last_ai_msg->content << "\n\n";
} else {
// Fallback to last message if no assistant message found
const auto& last_msg = state.history.back();
file << "[" << last_msg.role << "] " << last_msg.content << "\n\n";
// Find the last AI response
const Message* last_ai_msg = nullptr;
for (int i = static_cast<int>(state.history.size()) - 1; i >= 0; --i) {
if (state.history[i].role == "assistant") {
last_ai_msg = &state.history[i];
break;
}
}
if (last_ai_msg) {
initial_content << "[" << last_ai_msg->role << "] ";
if (!last_ai_msg->reasoning.empty()) {
initial_content << "[Thinking: " << last_ai_msg->reasoning << "] ";
}
initial_content << last_ai_msg->content << "\n\n";
} else {
// Fallback to last message if no assistant message found
const auto& last_msg = state.history.back();
initial_content << "[" << last_msg.role << "] " << last_msg.content << "\n\n";
}
}
file << "-- >8 --\n\n";
file.close();
const char* editor_env = getenv("VISUAL");
if (!editor_env) editor_env = getenv("EDITOR");
if (!editor_env) {
return std::unexpected("Neither $VISUAL nor $EDITOR is set.");
}
std::string cmd = std::string(editor_env) + " " + file_path;
int res = system(cmd.c_str());
if (res != 0) {
return std::unexpected("Editor exited with error.");
}
std::ifstream infile(file_path);
std::string content((std::istreambuf_iterator<char>(infile)), std::istreambuf_iterator<char>());
const std::string seperator = "\n-- >8 --\n";
size_t pos = content.find(seperator);
if (pos == std::string::npos) {
return std::unexpected("Scissors not found.");
}
size_t last_pos = std::string::npos;
while (pos != std::string::npos) {
last_pos = pos;
pos = content.find(seperator, pos+1);
}
std::string extracted = content.substr(last_pos + seperator.size());
extracted = trim(extracted);
if (extracted.empty()) {
return std::unexpected("Prompt is empty after separator.");
}
return extracted;
}
initial_content << "-- >8 --\n\n";
auto result = spawn_editor(initial_content.str());
if (!result) {
return result;
}
if (result->empty()) {
return std::unexpected("Prompt is empty after separator.");
}
return *result;
}
+4 -4
View File
@@ -114,12 +114,12 @@ std::expected<std::string, std::string> Harness::chat(const Message& user_messag
if (config_.verbose)
std::cerr << prefix << "[tool call] " << tool_name << "(" << args_str << ")\n";
Content result = "";
auto args_json = parse_json_safely(args_str);
if (args_json.is_discarded()) {
Content result = "";
auto args_json_result = parse_json_safely(args_str);
if (!args_json_result) {
result = "Error: invalid JSON in tool arguments";
} else {
result = registry_.invoke(tool_name, args_json);
result = registry_.invoke(tool_name, args_json_result.value());
}
if (config_.verbose)
+149 -35
View File
@@ -5,6 +5,8 @@
#include "editor.hpp"
#include "tool_registry.hpp"
#include "agent_profile.hpp"
#include "rebase_engine.hpp"
#include "utility.hpp"
#include "base64_util.hpp"
#if !defined(_WIN32)
#include "shell_tool.hpp"
@@ -76,6 +78,24 @@ static std::string generate_uuid() {
return ss.str();
}
// Helper function to read and deserialize HarnessState from a file
// Returns the parsed HarnessState, or exits on error
static HarnessState read_harness_state_or_exit(const char* file_path) {
std::ifstream file(file_path);
if (!file.is_open()) {
std::cerr << "Error: Cannot open file " << file_path << std::endl;
exit(1);
}
std::string file_content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
auto data = expect_or_exit(parse_json_safely(file_content));
auto state_result = json_to<HarnessState>(data);
if (!state_result) {
std::cerr << "Error: Failed to parse chat file: " << state_result.error() << std::endl;
exit(1);
}
return state_result.value();
}
int main(int argc, char** argv) {
OllamaConfig cfg;
if (const char* m = std::getenv("OLLAMA_MODEL")) cfg.model = m;
@@ -91,6 +111,14 @@ 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_content = nullptr;
char const* test_build_rebase_json = nullptr;
char const* test_apply_rebase_lines = nullptr;
char const* test_apply_rebase_history = nullptr;
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;
@@ -135,6 +163,7 @@ int main(int argc, char** argv) {
char const* print_file = nullptr;
std::vector<std::string> tools;
bool full_history = false;
bool edit_history = false;
std::vector<std::string> skill_filters;
std::vector<std::string> image_paths;
@@ -218,15 +247,39 @@ int main(int argc, char** argv) {
test_type_required_type = consume_arg();
test_type_required_json = consume_arg();
}
else if (argv[0] == std::string("-z"))
{
consume_arg();
test_rebase_history_size = consume_arg();
test_rebase_content = consume_arg();
}
else if (argv[0] == std::string("-Z"))
{
consume_arg();
test_build_rebase_json = consume_arg();
}
else if (argv[0] == std::string("-A"))
{
consume_arg();
test_apply_rebase_lines = consume_arg();
test_apply_rebase_history = consume_arg();
}
else if (argv[0] == std::string("-B"))
{
consume_arg();
test_base64_encode = consume_arg();
}
else if (argv[0] == std::string("-b"))
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"))
{
@@ -262,6 +315,11 @@ 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"))
{
consume_arg();
edit_history = true;
}
else if (argv[0] == std::string("-i"))
{
consume_arg();
@@ -286,6 +344,8 @@ int main(int argc, char** argv) {
<< " -P <file> Read prompt from file\n"
<< " -M <model> Ollama model name\n"
<< " -f Open editor with full history (requires -F)\n"
<< " -b Edit history (requires -F)\n"
<< " --edit-history\n"
<< " -l List available tools\n"
<< " -L List available models on server side\n"
<< " -D Debug: print all discovered skill paths and exit\n"
@@ -295,8 +355,12 @@ 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"
<< " -z <size> <content> Debug: test rebase parser 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"
<< " -W <json> <index> <role> Debug: test build_edit_prompt and exit\n"
<< " -B <string> Debug: test base64 encode and exit\n"
<< " -b <string> Debug: test base64 decode 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"
@@ -311,6 +375,44 @@ int main(int argc, char** argv) {
}
}
// Validate -b / --edit-history flag requirements
if (edit_history && !file_to_open)
{
std::cerr << "Error: -b/--edit-history requires -F <chat_file> to be specified" << std::endl;
return 1;
}
// Validate -b / --edit-history flag with empty history
if (edit_history && file_to_open)
{
HarnessState state_data = read_harness_state_or_exit(file_to_open);
if (state_data.history.empty()) {
std::cerr << "Error: Chat file has no history, cannot edit" << std::endl;
return 1;
}
// Run the interactive edit flow
HistoryEditor editor;
auto edit_result = editor.edit_history_interactive(state_data.history);
if (!edit_result) {
std::cerr << "Error: " << edit_result.error() << std::endl;
return 1;
}
// Save modified history to chat file
state_data.history = edit_result.value();
std::ofstream outfile(file_to_open);
if (!outfile.is_open()) {
std::cerr << "Error: Cannot write to file " << file_to_open << std::endl;
return 1;
}
outfile << nlohmann::json(state_data);
// Exit after successful edit
return 0;
}
// Helper function to create the client based on the debug flag
auto create_client = [&](bool debug) -> std::unique_ptr<Client> {
if (debug)
@@ -371,11 +473,7 @@ int main(int argc, char** argv) {
if (test_json_parse) {
auto result = parse_json_safely(test_json_parse);
if (result.is_discarded()) {
std::cout << "discarded" << std::endl;
} else {
std::cout << "valid" << std::endl;
}
std::cout << expect_or_exit(result).dump(2) << std::endl;
return 0;
}
@@ -393,23 +491,56 @@ int main(int argc, char** argv) {
}
if (test_type_required_type && test_type_required_json) {
auto j = parse_json_safely(test_type_required_json);
if (j.is_discarded()) {
std::cout << "error: invalid JSON" << std::endl;
return 1;
}
auto j = expect_or_exit(parse_json_safely(test_type_required_json));
std::string type_str(test_type_required_type);
auto it = json_dispatch_map.find(type_str);
if (it != json_dispatch_map.end()) {
it->second(j);
} else {
std::cout << "error: unknown type " << type_str << std::endl;
return 1;
}
return 0;
}
if (test_rebase_history_size && test_rebase_content) {
size_t history_size = expect_or_exit(string_toul(test_rebase_history_size));
RebaseEngine engine;
auto result = engine.parse_rebase_file(test_rebase_content, history_size);
nlohmann::json output = expect_or_exit(result);
std::cout << output.dump(2) << std::endl;
return 0;
}
if (test_build_rebase_json) {
auto j = expect_or_exit(parse_json_safely(test_build_rebase_json));
auto history_result = expect_or_exit(json_to<std::vector<Message>>(j));
HistoryEditor editor;
std::string content = editor.build_rebase_content(history_result);
std::cout << content;
return 0;
}
if (test_apply_rebase_lines && test_apply_rebase_history) {
auto lines_j = expect_or_exit(parse_json_safely(test_apply_rebase_lines));
auto lines_result = expect_or_exit(json_to<std::vector<RebaseLine>>(lines_j));
auto history_j = expect_or_exit(parse_json_safely(test_apply_rebase_history));
auto history_result = expect_or_exit(json_to<std::vector<Message>>(history_j));
RebaseEngine engine;
auto result = engine.apply_rebase(lines_result, history_result);
nlohmann::json output = expect_or_exit(result);
std::cout << output.dump(2) << std::endl;
return 0;
}
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));
const std::string role = test_edit_prompt_role;
HistoryEditor editor;
std::string prompt = editor.build_edit_prompt(history_result, edit_index, role);
std::cout << prompt;
return 0;
}
if (test_base64_encode) {
std::cout << base64_encode(test_base64_encode) << std::endl;
return 0;
@@ -470,11 +601,7 @@ int main(int argc, char** argv) {
}
std::string file_content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
nlohmann::json data = parse_json_safely(file_content);
if (data.is_discarded()) {
std::cerr << "Error parsing JSON: invalid JSON" << "\n";
return 1;
}
auto data = expect_or_exit(parse_json_safely(file_content));
print_history(std::cout, data);
return 0;
}
@@ -524,20 +651,7 @@ int main(int argc, char** argv) {
if (!start_new)
{
std::ifstream old_harness_state(file_to_open);
std::string file_content((std::istreambuf_iterator<char>(old_harness_state)), std::istreambuf_iterator<char>());
nlohmann::json data = parse_json_safely(file_content);
if (data.is_discarded()) {
std::cerr << "Failed to parse chat file: invalid JSON" << std::endl;
return 1;
}
auto state_result = json_to<HarnessState>(data);
if (!state_result) {
std::cerr << "Failed to parse chat file: " << state_result.error() << std::endl;
return 1;
}
HarnessState state_data = state_result.value();
HarnessState state_data = read_harness_state_or_exit(file_to_open);
harness = Harness(create_client(use_debug), master_registery, state_data);
}
+7 -5
View File
@@ -137,12 +137,13 @@ std::expected<Message, std::string> OllamaClient::chat(const std::vector<Message
}
//std::cerr << new_packet << std::endl;
auto j = parse_json_safely(new_packet.substr(6));
if (j.is_discarded())
auto j_result = parse_json_safely(new_packet.substr(6));
if (!j_result)
{
std::cerr << "\nDiscarding: " << new_packet << std::endl;
continue;
}
auto j = j_result.value();
auto chat_response = json_to<ChatResponse>(j);
if (!chat_response.has_value())
@@ -233,10 +234,11 @@ std::expected<std::vector<std::string>, std::string> OllamaClient::list_models()
}
std::string response_str = response_result.value();
auto j = parse_json_safely(response_str);
if (j.is_discarded()) {
return std::unexpected("JSON parse error: input was discarded");
auto j_result = parse_json_safely(response_str);
if (!j_result) {
return std::unexpected("JSON parse error: " + j_result.error());
}
auto j = j_result.value();
auto result = json_to<ModelListResponse>(j);
if (!result) {
return std::unexpected(std::string("JSON parse error: ") + result.error());
+322
View File
@@ -0,0 +1,322 @@
#include "rebase_engine.hpp"
#include "json_types.hpp"
#include "utility.hpp"
#include <sstream>
#include <algorithm>
#include <unordered_set>
static std::vector<std::string> split_lines(const std::string& content) {
std::vector<std::string> lines;
std::istringstream stream(content);
std::string line;
while (std::getline(stream, line)) {
lines.push_back(line);
}
return lines;
}
static std::string format_error(size_t line_num, const std::string& message) {
return "line " + std::to_string(line_num + 1) + ": " + message;
}
static std::expected<RebaseCommand, std::string> parse_command(const std::string& cmd) {
if (cmd == "pick") return RebaseCommand::Pick;
if (cmd == "edit") return RebaseCommand::Edit;
if (cmd == "drop") return RebaseCommand::Drop;
return std::unexpected("invalid command: '" + cmd + "'");
}
std::expected<std::vector<RebaseLine>, std::string> RebaseEngine::parse_rebase_file(
const std::string& content, size_t history_size) const {
std::vector<RebaseLine> lines;
std::unordered_set<size_t> seen_indices;
auto file_lines = split_lines(content);
for (size_t line_num = 0; line_num < file_lines.size(); ++line_num) {
std::string line = trim(file_lines[line_num]);
// Skip empty lines (treated as drops) and comment lines
if (line.empty() || line[0] == '#') {
continue;
}
// Parse: <command> <index> [<role>] <content...>
std::istringstream iss(line);
std::string cmd_str;
std::string index_str;
std::string role_str;
if (!(iss >> cmd_str)) {
return std::unexpected(format_error(line_num, "missing command"));
}
if (!(iss >> index_str)) {
return std::unexpected(format_error(line_num, "missing index"));
}
if (!(iss >> role_str)) {
return std::unexpected(format_error(line_num, "missing role"));
}
// Parse command
auto cmd_result = parse_command(cmd_str);
if (!cmd_result) {
return std::unexpected(format_error(line_num, cmd_result.error()));
}
// Parse index
size_t index;
auto index_result = string_toul(index_str);
if (!index_result) {
return std::unexpected(format_error(line_num, "invalid index: '" + index_str + "'"));
}
index = index_result.value();
// Validate index is in range
if (index >= history_size) {
return std::unexpected(format_error(line_num, "index " + std::to_string(index) + " out of range (history size: " + std::to_string(history_size) + ")"));
}
// Check for duplicate indices
if (seen_indices.count(index)) {
return std::unexpected(format_error(line_num, "duplicate index " + std::to_string(index)));
}
// Parse and validate role
// Role must be in format [user], [assistant], or [tool]
if (role_str.size() < 3 || role_str.front() != '[' || role_str.back() != ']') {
return std::unexpected(format_error(line_num, "malformed role: '" + role_str + "'"));
}
std::string role_content = role_str.substr(1, role_str.size() - 2);
if (role_content != "user" && role_content != "assistant" && role_content != "tool") {
return std::unexpected(format_error(line_num, "invalid role: '" + role_str + "'"));
}
// Valid line - add to results
seen_indices.insert(index);
lines.push_back({cmd_result.value(), index, role_content, ""});
}
return lines;
}
std::string HistoryEditor::get_first_line(const std::string& content) const {
auto pos = content.find('\n');
if (pos == std::string::npos) {
return content;
}
return content.substr(0, pos);
}
std::string HistoryEditor::get_message_display(const Message& msg) const {
// Check for assistant messages with tool_calls only
if (msg.role == "assistant" && !msg.tool_calls.empty()) {
if (msg.tool_calls.size() == 1) {
return "[tool_call: " + msg.tool_calls[0].function.name + "]";
} else {
// Multiple tool calls - show first one
return "[tool_call: " + msg.tool_calls[0].function.name + " ...]";
}
}
// Get content from message
std::string content = msg.get_content_string();
// If no content, show (empty)
if (content.empty()) {
return "(empty)";
}
// Return first line of content
return get_first_line(content);
}
std::string HistoryEditor::build_rebase_content(const std::vector<Message>& history) const {
std::ostringstream oss;
// Header comment block
oss << "# History Rebase Editor\n";
oss << "#\n";
oss << "# Commands:\n";
oss << "# pick - keep the message unchanged\n";
oss << "# edit - modify the message content and/or role\n";
oss << "# drop - remove the message from history\n";
oss << "#\n";
oss << "# Each line format: <command> <index> [<role>] <display_content>\n";
oss << "# - index: 0-based message index\n";
oss << "# - role: [user], [assistant], or [tool] (editable)\n";
oss << "# - display_content: first line of message (display only, not editable here)\n";
oss << "#\n";
oss << "# Lines starting with # are comments and will be ignored.\n";
oss << "# Empty lines are treated as drops.\n";
oss << "#\n";
oss << "-- >8 --\n";
// Format each message
for (size_t i = 0; i < history.size(); ++i) {
const auto& msg = history[i];
std::string display = get_message_display(msg);
oss << "pick " << i << " [" << msg.role << "] " << display << "\n";
}
return oss.str();
}
std::string HistoryEditor::build_edit_prompt(const std::vector<Message>& history,
size_t edit_index,
const std::string& role) const {
std::ostringstream oss;
// Show previous message as context
if (edit_index == 0) {
oss << "# (no previous message)\n";
} else {
const auto& prev_msg = history[edit_index - 1];
oss << "# Previous message:\n\n";
oss << "[" << (edit_index - 1) << "] " << prev_msg.role << ": " << prev_msg.content << "\n";
}
// Edit prompt
oss << "\nEdit message " << edit_index << " (role: " << role << ") below:\n";
// Separator - editable content goes after this
oss << "\n-- >8 --\n";
// Current content (editable)
if (edit_index < history.size()) {
oss << history[edit_index].content;
}
return oss.str();
}
std::expected<std::vector<Message>, std::string> RebaseEngine::apply_rebase(
const std::vector<RebaseLine>& lines,
const std::vector<Message>& history) const {
// Build a map from index to RebaseLine for quick lookup
std::unordered_map<size_t, const RebaseLine*> command_map;
for (const auto& line : lines) {
command_map[line.index] = &line;
}
std::vector<Message> result;
// Process messages in forward order (lowest index first)
for (size_t i = 0; i < history.size(); ++i) {
auto it = command_map.find(i);
if (it == command_map.end()) {
// No command for this index - implicit drop (message is removed)
// Special case: if no commands at all were parsed, keep all messages
if (command_map.empty()) {
result.push_back(history[i]);
}
// Otherwise, the message is dropped (do nothing)
} else {
const auto& cmd = it->second;
switch (cmd->command) {
case RebaseCommand::Pick: {
// Keep message unchanged
result.push_back(history[i]);
break;
}
case RebaseCommand::Drop: {
// Remove message from result (don't add it)
break;
}
case RebaseCommand::Edit: {
// Create a copy and update role and content
Message edited = history[i];
edited.role = cmd->role;
// Update content only if it was explicitly edited
if (cmd->content_edited) {
edited.content = cmd->content;
}
result.push_back(std::move(edited));
break;
}
}
}
}
return result;
}
std::expected<std::vector<Message>, std::string> HistoryEditor::edit_history_interactive(
const std::vector<Message>& history) const {
// Step 1: Build rebase content and spawn editor
std::string rebase_content = build_rebase_content(history);
auto editor_result = spawn_editor(rebase_content);
if (!editor_result) {
return std::unexpected("Failed to run rebase editor: " + editor_result.error());
}
std::string rebase_output = editor_result.value();
// Step 2: Parse and validate rebase commands
RebaseEngine engine;
auto parse_result = engine.parse_rebase_file(rebase_output, history.size());
if (!parse_result) {
return std::unexpected("Rebase parse error: " + parse_result.error());
}
std::vector<RebaseLine> lines = parse_result.value();
// Step 3: For each edit command (in forward order), build edit prompt and spawn editor
// First, sort lines by index to ensure forward order
std::sort(lines.begin(), lines.end(), [](const RebaseLine& a, const RebaseLine& b) {
return a.index < b.index;
});
// Collect edit indices first (forward order)
std::vector<size_t> edit_indices;
for (const auto& line : lines) {
if (line.command == RebaseCommand::Edit) {
edit_indices.push_back(line.index);
}
}
// For each edit, spawn editor to get new content
for (size_t edit_idx : edit_indices) {
// Find the RebaseLine for this index
const RebaseLine* edit_line = nullptr;
for (const auto& line : lines) {
if (line.index == edit_idx && line.command == RebaseCommand::Edit) {
edit_line = &line;
break;
}
}
if (!edit_line) continue;
// Build edit prompt with context
std::string prompt = build_edit_prompt(history, edit_idx, edit_line->role);
// Spawn editor to get new content
auto edit_result = spawn_editor(prompt);
if (!edit_result) {
return std::unexpected("Failed to run edit editor for message " +
std::to_string(edit_idx) + ": " + edit_result.error());
}
// Update the RebaseLine with the new content
for (auto& line : lines) {
if (line.index == edit_idx && line.command == RebaseCommand::Edit) {
line.content = edit_result.value();
line.content_edited = true; // Mark that content was explicitly edited
break;
}
}
}
// Step 4: Apply all changes
auto apply_result = engine.apply_rebase(lines, history);
if (!apply_result) {
return std::unexpected("Failed to apply rebase: " + apply_result.error());
}
return apply_result.value();
}
+104
View File
@@ -0,0 +1,104 @@
#include "utility.hpp"
#include <fstream>
#include <cstdlib>
#include <filesystem>
#include <random>
#include <cctype>
std::string trim(const std::string& s) {
auto start = s.find_first_not_of(" \t\n\r");
if (start == std::string::npos) return "";
auto end = s.find_last_not_of(" \t\n\r");
return s.substr(start, end - start + 1);
}
std::string random_string(size_t length) {
static const char chars[] =
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789";
static std::random_device rd;
static std::mt19937 gen(rd());
static std::uniform_int_distribution<> dis(0, sizeof(chars) - 2);
std::string s;
for (size_t i = 0; i < length; ++i) {
s += chars[dis(gen)];
}
return s;
}
std::string random_file_name(size_t length) {
std::filesystem::path temp_dir = std::filesystem::temp_directory_path();
std::filesystem::path temp_file = temp_dir / ("tmp_" + random_string(length));
return temp_file.string();
}
std::expected<std::string, std::string> spawn_editor(const std::string& initial_content) {
auto file_path = random_file_name();
auto cleanup = Defer([&](){
std::filesystem::remove(file_path);
});
{
std::ofstream file(file_path);
if (!file.is_open()) {
return std::unexpected("Failed to open temp file for editor.");
}
file << initial_content;
file.close();
const char* editor_env = getenv("VISUAL");
if (!editor_env) editor_env = getenv("EDITOR");
if (!editor_env) {
return std::unexpected("Neither $VISUAL nor $EDITOR is set.");
}
std::string cmd = std::string(editor_env) + " " + file_path;
int res = system(cmd.c_str());
if (res != 0) {
return std::unexpected("Editor exited with error.");
}
std::ifstream infile(file_path);
std::string content((std::istreambuf_iterator<char>(infile)), std::istreambuf_iterator<char>());
const std::string seperator = "\n-- >8 --\n";
size_t pos = content.find(seperator);
if (pos == std::string::npos) {
return std::unexpected("Scissors not found.");
}
size_t last_pos = std::string::npos;
while (pos != std::string::npos) {
last_pos = pos;
pos = content.find(seperator, pos + 1);
}
std::string extracted = content.substr(last_pos + seperator.size());
extracted = trim(extracted);
return extracted;
}
}
std::expected<unsigned long, std::string> string_toul(std::string const& str) {
size_t pos = 0;
try {
unsigned long result = std::stoul(str, &pos);
// Check if the entire string was consumed
while (pos < str.size() && std::isspace(static_cast<unsigned char>(str[pos]))) {
pos++;
}
if (pos != str.size()) {
return std::unexpected("trailing characters after number");
}
return result;
} catch (const std::exception& e) {
return std::unexpected(e.what());
}
}
+84
View File
@@ -56,6 +56,90 @@ add_dr_test(test_json_variant)
# Add expected error handling tests
add_dr_test(test_expected_errors)
# Add rebase parser tests (split into individual tests)
add_dr_test(test_rebase_parser_all_commands)
add_dr_test(test_rebase_parser_with_comments)
add_dr_test(test_rebase_parser_with_empty_lines)
add_dr_test(test_rebase_parser_invalid_command)
add_dr_test(test_rebase_parser_index_out_of_range)
add_dr_test(test_rebase_parser_duplicate_indices)
add_dr_test(test_rebase_parser_malformed_role)
add_dr_test(test_rebase_parser_invalid_role_value)
add_dr_test(test_rebase_parser_first_error_returned)
add_dr_test(test_rebase_parser_empty_rebase)
add_dr_test(test_rebase_parser_only_comments)
add_dr_test(test_rebase_parser_all_valid_roles)
add_dr_test(test_rebase_parser_invalid_command_digit)
add_dr_test(test_rebase_parser_invalid_index)
add_dr_test(test_rebase_parser_missing_role)
add_dr_test(test_rebase_parser_whitespace_variations)
add_dr_test(test_rebase_parser_comments_and_empty_lines_mixed)
# Add rebase content formatting tests (split into individual tests)
add_dr_test(test_rebase_content_basic_messages)
add_dr_test(test_rebase_content_multiline_content)
add_dr_test(test_rebase_content_tool_call_display)
add_dr_test(test_rebase_content_tool_message)
add_dr_test(test_rebase_content_empty_message)
add_dr_test(test_rebase_content_header_comments)
add_dr_test(test_rebase_content_multiple_tool_calls)
add_dr_test(test_rebase_content_message_indices)
add_dr_test(test_rebase_content_scissors_line)
add_dr_test(test_rebase_content_special_characters)
add_dr_test(test_rebase_content_unicode_characters)
add_dr_test(test_rebase_content_very_long_content)
# Add rebase apply tests (split into individual tests)
add_dr_test(test_rebase_apply_pick_preserves)
add_dr_test(test_rebase_apply_drop_removes)
add_dr_test(test_rebase_apply_edit_updates_role)
add_dr_test(test_rebase_apply_edit_preserves_fields)
add_dr_test(test_rebase_apply_implicit_pick)
add_dr_test(test_rebase_apply_mixed_commands)
add_dr_test(test_rebase_apply_empty_lines_preserves)
add_dr_test(test_rebase_apply_lenient_orphaned_results)
add_dr_test(test_rebase_apply_edit_preserves_tool_calls)
add_dr_test(test_rebase_apply_deleting_lines_drops)
add_dr_test(test_rebase_apply_all_lines_deleted_keeps)
# Add edit prompt tests (split from test_edit_prompt.sh)
add_dr_test(test_edit_prompt_with_context)
add_dr_test(test_edit_prompt_first_message)
add_dr_test(test_edit_prompt_separator_placement)
add_dr_test(test_edit_prompt_multiline_context)
add_dr_test(test_edit_prompt_tool_role)
add_dr_test(test_edit_prompt_middle_message)
# Add edit history flag tests (split from test_edit_history_flag.sh)
add_dr_test(test_edit_history_flag_requires_F_short)
add_dr_test(test_edit_history_flag_requires_F_long)
add_dr_test(test_edit_history_flag_empty_file_short)
add_dr_test(test_edit_history_flag_no_history)
add_dr_test(test_edit_history_flag_empty_file_long)
# Add edit history integration tests (split into individual tests)
add_dr_test(test_edit_history_pick_all_messages)
add_dr_test(test_edit_history_no_edits_unchanged)
add_dr_test(test_edit_history_drop_single_message)
add_dr_test(test_edit_history_edit_message_content)
add_dr_test(test_edit_history_change_message_role)
add_dr_test(test_edit_history_delete_line_equals_drop)
add_dr_test(test_edit_history_edit_first_message)
add_dr_test(test_edit_history_prompt_noop)
add_dr_test(test_edit_history_empty_history_rejected)
add_dr_test(test_edit_history_invalid_command_error)
add_dr_test(test_edit_history_invalid_index_error)
add_dr_test(test_edit_history_invalid_role_error)
add_dr_test(test_edit_history_multiple_edits)
add_dr_test(test_edit_history_then_continue)
add_dr_test(test_edit_history_remove_all_content)
add_dr_test(test_edit_history_multiple_consecutive_edits)
add_dr_test(test_edit_history_special_characters)
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)
+27
View File
@@ -2,6 +2,33 @@
# $1 is the file path passed to the editor
FILE=$1
# Support sequential invocations using a counter file
# Track invocations in $TEST_TEMP_DIR/.editor_invocation_count
# Copy from $TEST_TEMP_DIR/.editor_input_$INVOCATION to the editor file
if [ -n "$TEST_TEMP_DIR" ] && [ -f "$TEST_TEMP_DIR/.editor_input_0" ]; then
# Initialize counter file if it doesn't exist
if [ ! -f "$TEST_TEMP_DIR/.editor_invocation_count" ]; then
echo "0" > "$TEST_TEMP_DIR/.editor_invocation_count"
fi
# Read current invocation count
INVOCATION=$(cat "$TEST_TEMP_DIR/.editor_invocation_count")
# Check if there's a staged input file for this invocation
STAGED_INPUT="$TEST_TEMP_DIR/.editor_input_$INVOCATION"
if [ -f "$STAGED_INPUT" ]; then
# Copy staged content to the editor file
cp "$STAGED_INPUT" "$FILE"
fi
# Increment the counter for next invocation
echo $((INVOCATION + 1)) > "$TEST_TEMP_DIR/.editor_invocation_count"
# For inspecting by tests
cat $FILE > hist.md
exit 0
fi
# For inspecting by tests
cat $FILE > hist.md
+4 -4
View File
@@ -8,12 +8,12 @@ $DRHARNESS -n -F reasoning_content.json -m "Test" -d < reasoning_content
if [[ "$(cat reasoning.json | jq .history[1].reasoning -r)" != "I should do X because Y" ]]; then
cat reasoning.json | jq
if [[ "$(dr_reasoning reasoning.json 1)" != "I should do X because Y" ]]; then
echo "reasoning mismatch"
exit 1
fi
if [[ "$(cat reasoning_content.json | jq .history[1].reasoning_content -r)" != "I should do X because Y" ]]; then
cat reasoning_content.json | jq
if [[ "$(dr_reasoning_content reasoning_content.json 1)" != "I should do X because Y" ]]; then
echo "reasoning_content mismatch"
exit 1
fi
+217
View File
@@ -144,6 +144,223 @@ function dr_result() {
}
export -f dr_result
function dr_role() {
local chat_file="$1"
local entry="$2"
jq -r --arg e "$entry" '.history[$e | tonumber].role' "$chat_file" 2>/dev/null || echo ""
}
export -f dr_role
function dr_reasoning() {
local chat_file="$1"
local entry="$2"
jq -r --arg e "$entry" '.history[$e | tonumber].reasoning // ""' "$chat_file" 2>/dev/null || echo ""
}
export -f dr_reasoning
function dr_reasoning_content() {
local chat_file="$1"
local entry="$2"
jq -r --arg e "$entry" '.history[$e | tonumber].reasoning_content // ""' "$chat_file" 2>/dev/null || echo ""
}
export -f dr_reasoning_content
# Create a chat file with history messages
# Usage: create_chat_file <filename> <role> <content> [role content ...]
# Example: create_chat_file chat.json user "hello" assistant "hi there"
function create_chat_file() {
local file="$1"
shift
create_history_json "$@" | jq '{history: ., tools: []}' > "$file"
}
export -f create_chat_file
# Create a JSON array of history messages (for -T flag testing)
# Usage: create_history_json role content [role content ...]
# Example: create_history_json user "hello" assistant "hi there"
function create_history_json() {
local history="[]"
while [ $# -gt 0 ]; do
local role="$1"
local content="$2"
shift 2
history=$(echo "$history" | jq --arg r "$role" --arg c "$content" '. + [{"role": $r, "content": $c}]')
done
echo "$history"
}
export -f create_history_json
# Helper functions for rebase parser testing
# Get number of lines in rebase JSON output
function rebase_line_count() {
local output="$1"
echo "$output" | jq 'length'
}
export -f rebase_line_count
# Get command at position in rebase JSON output
function rebase_command_at() {
local output="$1"
local pos="$2"
echo "$output" | jq -r ".[$pos | tonumber].command"
}
export -f rebase_command_at
# Get index at position in rebase JSON output
function rebase_index_at() {
local output="$1"
local pos="$2"
echo "$output" | jq -r ".[$pos | tonumber].index"
}
export -f rebase_index_at
# Get role at position in rebase JSON output
function rebase_role_at() {
local output="$1"
local pos="$2"
echo "$output" | jq -r ".[$pos | tonumber].role"
}
export -f rebase_role_at
# Helper functions for rebase apply testing
# Create a rebase line JSON object
# Usage: create_rebase_line <command> <index> <role>
# Example: create_rebase_line pick 0 user
function create_rebase_line() {
local cmd="$1"
local idx="$2"
local role="$3"
jq -n --arg c "$cmd" --argjson i "$idx" --arg r "$role" '{"command": $c, "index": $i, "role": $r}'
}
export -f create_rebase_line
# Create a JSON array of rebase lines
# Usage: create_rebase_lines [command index role ...]
# Example: create_rebase_lines pick 0 user drop 1 assistant edit 2 tool
function create_rebase_lines() {
local lines="[]"
while [ $# -gt 0 ]; do
local cmd="$1"
local idx="$2"
local role="$3"
shift 3
local line
line=$(create_rebase_line "$cmd" "$idx" "$role")
lines=$(echo "$lines" | jq --argjson l "$line" '. + [$l]')
done
echo "$lines"
}
export -f create_rebase_lines
# Add a field to a message JSON object
function add_message_field() {
local msg="$1"
local field="$2"
local value="$3"
case "$field" in
reasoning)
echo "$msg" | jq --arg v "$value" '. + {reasoning: $v}'
;;
reasoning_content)
echo "$msg" | jq --arg v "$value" '. + {reasoning_content: $v}'
;;
tool_call_id)
echo "$msg" | jq --arg v "$value" '. + {tool_call_id: $v}'
;;
tool_call)
local call_id="$3"
local call_name="$4"
local call_args="$5"
echo "$msg" | jq --arg id "$call_id" --arg name "$call_name" --arg args "$call_args" \
'.tool_calls = ((.tool_calls // []) + [{"index": 0, "id": $id, "type": "function", "function": {"name": $name, "arguments": $args}}])'
;;
*)
echo "ERROR: Unknown field '$field'" >&2
return 1
;;
esac
}
export -f add_message_field
# Create a message JSON object with optional fields
# Usage: create_message_json <role> <content> [field value ...]
# Supported fields: reasoning, reasoning_content, tool_call_id, tool_call
# tool_call takes 3 args: tool_call <id> <name> <arguments>
# Example: create_message_json user "hello"
# Example: create_message_json user "hello" reasoning "thought" tool_call_id "call_123"
# Example: create_message_json assistant "" tool_call "call_1" "test" "{}"
function create_message_json() {
local role="$1"
local content="$2"
shift 2
local msg
msg=$(jq -n --arg r "$role" --arg c "$content" '{"role": $r, "content": $c}')
while [ $# -gt 0 ]; do
local field="$1"
if [ "$field" = "tool_call" ]; then
local call_id="$2"
local call_name="$3"
local call_args="$4"
shift 4
msg=$(add_message_field "$msg" "$field" "$call_id" "$call_name" "$call_args")
else
local value="$2"
shift 2
msg=$(add_message_field "$msg" "$field" "$value")
fi
done
echo "$msg"
}
export -f create_message_json
# Check if a string is a known message field name
function is_message_field() {
case "$1" in
reasoning|reasoning_content|tool_call_id|tool_call) return 0 ;;
*) return 1 ;;
esac
}
export -f is_message_field
# Create a JSON array of messages
# Usage: create_messages_json <role> <content> [field value ...] [role content ...]
# Each message: role content [field value ...]
# Supported fields: reasoning, reasoning_content, tool_call_id, tool_call
# tool_call takes 3 args: tool_call <id> <name> <arguments>
# Example: create_messages_json user "hello" assistant "hi there"
# Example: create_messages_json user "hello" reasoning "thought" assistant "hi"
function create_messages_json() {
local messages="[]"
while [ $# -gt 0 ]; do
local role="$1"
local content="$2"
shift 2
# Collect field-value pairs for this message into an array
local -a field_args=()
while [ $# -gt 0 ] && is_message_field "$1"; do
if [ "$1" = "tool_call" ]; then
field_args+=("$1" "$2" "$3" "$4")
shift 4
else
field_args+=("$1" "$2")
shift 2
fi
done
local msg
msg=$(create_message_json "$role" "$content" "${field_args[@]}")
messages=$(echo "$messages" | jq --argjson m "$msg" '. + [$m]')
done
echo "$messages"
}
export -f create_messages_json
function create_state() {
local file="$1"
shift
+2 -2
View File
@@ -16,7 +16,7 @@ if [ "${OUTPUT}" != "" ]; then
fi
# Test 3: Base64 decoding
OUTPUT=$("${DRHARNESS}" -b "SGVsbG8sIFdvcmxkIQ==")
OUTPUT=$("${DRHARNESS}" -K "SGVsbG8sIFdvcmxkIQ==")
if [ "${OUTPUT}" != "Hello, World!" ]; then
echo "FAIL: base64_decode - expected 'Hello, World!', got '${OUTPUT}'"
exit 1
@@ -25,7 +25,7 @@ fi
# Test 4: Round-trip encoding/decoding
ORIGINAL="Test string with special chars: !@#$%^&*()"
ENCODED=$("${DRHARNESS}" -B "${ORIGINAL}")
DECODED=$("${DRHARNESS}" -b "${ENCODED}")
DECODED=$("${DRHARNESS}" -K "${ENCODED}")
if [ "${DECODED}" != "${ORIGINAL}" ]; then
echo "FAIL: round-trip - expected '${ORIGINAL}', got '${DECODED}'"
exit 1
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# test_edit_history_assistant_with_tool_calls.sh - Editing assistant messages that contain tool_calls
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with an assistant message containing tool_calls
cat > chat.json << 'CHATJSON'
{
"history": [
{"role":"user","content":"hello"},
{"role":"assistant","content":"calling tool","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]},
{"role":"tool","content":"result","tool_call_id":"call_1"}
],
"tools": []
}
CHATJSON
# Stage the rebase editor output (edit message 1)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [assistant] calling tool [tool_call: test]
pick 2 [tool] result
EDITEOF
# Stage the edit prompt response
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: assistant) below:
-- >8 --
edited assistant message
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_assistant_with_tool_calls - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history still has 3 messages
len=$(history_length chat.json)
if [ "$len" != "3" ]; then
echo "FAIL: edit_assistant_with_tool_calls - expected 3 messages, got $len"
exit 1
fi
# Verify message 1 content was edited
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "edited assistant message" ]; then
echo "FAIL: edit_assistant_with_tool_calls - message 1 content not updated correctly"
echo "Expected: 'edited assistant message', got: '$msg1_content'"
exit 1
fi
# Verify tool_calls are preserved
if ! cat chat.json | jq -e '.history[1].tool_calls' > /dev/null 2>&1; then
echo "FAIL: edit_assistant_with_tool_calls - tool_calls not preserved"
exit 1
fi
echo "PASS: edit_assistant_with_tool_calls"
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# test_edit_history_change_message_role.sh - Change message role
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (edit message 1 role to user)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [user] hi there
EDITEOF
# Stage the edit prompt response (keep same content)
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: user) below:
-- >8 --
hi there
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: change_message_role - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify message 1 role was changed
msg1_role=$(dr_role chat.json 1)
if [ "$msg1_role" != "user" ]; then
echo "FAIL: change_message_role - message 1 role should be 'user', got '$msg1_role'"
exit 1
fi
echo "PASS: change_message_role"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# test_edit_history_delete_line_equals_drop.sh - Delete line equals drop (implicit drop for messages not mentioned)
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage rebase editor output with only message 0 mentioned
# Messages not mentioned are implicitly dropped
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: delete_line_equals_drop - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Messages not mentioned are implicitly dropped, so we should have 1 message
len=$(history_length chat.json)
if [ "$len" != "1" ]; then
echo "FAIL: delete_line_equals_drop - expected 1 message, got $len"
exit 1
fi
echo "PASS: delete_line_equals_drop"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# test_edit_history_drop_all_messages.sh - Dropping all messages results in empty history
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (drop all)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
drop 0 [user] hello
drop 1 [assistant] hi there
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: drop_all_messages - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history is empty
len=$(history_length chat.json)
if [ "$len" != "0" ]; then
echo "FAIL: drop_all_messages - expected 0 messages, got $len"
exit 1
fi
echo "PASS: drop_all_messages"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# test_edit_history_drop_single_message.sh - Drop a single message
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (drop message 1)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
drop 1 [assistant] hi there
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: drop_single_message - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify message 1 was dropped
len=$(history_length chat.json)
if [ "$len" != "1" ]; then
echo "FAIL: drop_single_message - expected 1 message, got $len"
exit 1
fi
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "hello" ]; then
echo "FAIL: drop_single_message - message 0 content should be unchanged"
exit 1
fi
echo "PASS: drop_single_message"
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# test_edit_history_edit_first_message.sh - Edit first message (no context)
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (edit message 0)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
edit 0 [user] hello
pick 1 [assistant] hi there
EDITEOF
# Stage the edit prompt response (message 0 has no previous message)
cat > .editor_input_1 << 'EDITEOF'
# (no previous message)
Edit message 0 (role: user) below:
-- >8 --
goodbye
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_first_message - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify message 0 was edited
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "goodbye" ]; then
echo "FAIL: edit_first_message - message 0 content not updated correctly"
echo "Expected: 'goodbye', got: '$msg0_content'"
exit 1
fi
echo "PASS: edit_first_message"
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# test_edit_history_edit_message_content.sh - Edit message content
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (edit message 1)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [assistant] hi there
EDITEOF
# Stage the edit prompt response (new content for message 1)
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: assistant) below:
-- >8 --
edited content here
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_message_content - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify message 1 was edited
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: edit_message_content - expected 2 messages, got $len"
exit 1
fi
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "edited content here" ]; then
echo "FAIL: edit_message_content - message 1 content not updated correctly"
echo "Expected: 'edited content here', got: '$msg1_content'"
exit 1
fi
echo "PASS: edit_message_content"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# test_edit_history_empty_history_rejected.sh - Empty history rejected
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with no history
create_chat_file chat.json
# Run drharness with -b flag
set +e
run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: empty_history_rejected - expected non-zero exit code"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "no history"; then
echo "FAIL: empty_history_rejected - expected error about no history"
echo "Output: $OUTPUT"
exit 1
fi
echo "PASS: empty_history_rejected"
@@ -0,0 +1,17 @@
#!/bin/bash
# test_edit_history_flag_empty_file_long.sh - Test that --edit-history with empty file fails
# Create empty file
> empty_chat.json
set +e
OUTPUT=$("${DRHARNESS}" --edit-history -F empty_chat.json 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: expected non-zero exit code, got 0"
exit 1
fi
echo "PASS: edit_history_flag_empty_file_long"
@@ -0,0 +1,17 @@
#!/bin/bash
# test_edit_history_flag_empty_file_short.sh - Test that -b with empty file fails
# Create empty file
> empty_chat.json
set +e
OUTPUT=$("${DRHARNESS}" -b -F empty_chat.json 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: expected non-zero exit code, got 0"
exit 1
fi
echo "PASS: edit_history_flag_empty_file_short"
@@ -0,0 +1,23 @@
#!/bin/bash
# test_edit_history_flag_no_history.sh - Test that -b with no history fails
# Create file with valid JSON but empty history
create_chat_file no_history.json
set +e
OUTPUT=$("${DRHARNESS}" -b -F no_history.json 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: expected non-zero exit code, got 0"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "no history"; then
echo "FAIL: expected error message about no history"
echo "Output was: $OUTPUT"
exit 1
fi
echo "PASS: edit_history_flag_no_history"
@@ -0,0 +1,20 @@
#!/bin/bash
# test_edit_history_flag_requires_F_long.sh - Test that --edit-history without -F fails
set +e
OUTPUT=$("${DRHARNESS}" --edit-history 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: expected non-zero exit code, got 0"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "requires -F"; then
echo "FAIL: expected error message about -F requirement"
echo "Output was: $OUTPUT"
exit 1
fi
echo "PASS: edit_history_flag_requires_F_long"
@@ -0,0 +1,20 @@
#!/bin/bash
# test_edit_history_flag_requires_F_short.sh - Test that -b without -F fails
set +e
OUTPUT=$("${DRHARNESS}" -b 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: expected non-zero exit code, got 0"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "requires -F"; then
echo "FAIL: expected error message about -F requirement"
echo "Output was: $OUTPUT"
exit 1
fi
echo "PASS: edit_history_flag_requires_F_short"
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# test_edit_history_invalid_command_error.sh - Invalid command error
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with a message
create_chat_file chat.json user "hello"
# Stage the rebase editor output with invalid command
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
foo 0 [user] hello
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: invalid_command_error - expected non-zero exit code"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi "invalid\|error"; then
echo "FAIL: invalid_command_error - expected error message"
echo "Output: $OUTPUT"
exit 1
fi
echo "PASS: invalid_command_error"
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# test_edit_history_invalid_index_error.sh - Invalid index error
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with one message
create_chat_file chat.json user "hello"
# Stage the rebase editor output with out-of-range index
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 5 [user] hello
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: invalid_index_error - expected non-zero exit code"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi "out of range\|error"; then
echo "FAIL: invalid_index_error - expected error about out of range"
echo "Output: $OUTPUT"
exit 1
fi
echo "PASS: invalid_index_error"
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# test_edit_history_invalid_role_error.sh - Invalid role error
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with a message
create_chat_file chat.json user "hello"
# Stage the rebase editor output with invalid role
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [invalid] hello
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: invalid_role_error - expected non-zero exit code"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi "invalid\|error"; then
echo "FAIL: invalid_role_error - expected error about invalid role"
echo "Output: $OUTPUT"
exit 1
fi
echo "PASS: invalid_role_error"
+136
View File
@@ -0,0 +1,136 @@
#!/bin/bash
# test_edit_history_multiple_consecutive_edits.sh - Multiple consecutive edits to the same message (changes stack correctly)
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "original"
# First session: Edit message 1 to change content from "original" to "first edit"
# Staged inputs for first drharness invocation:
# Invocation 0: rebase editor
# Invocation 1: content editor for message 1
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [assistant] original
EDITEOF
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: assistant) below:
-- >8 --
first edit
EDITEOF
# Staged inputs for second drharness invocation:
# Invocation 2: rebase editor (second session)
# Invocation 3: content editor for message 1 (second session)
cat > .editor_input_2 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [assistant] first edit
EDITEOF
cat > .editor_input_3 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: assistant) below:
-- >8 --
second edit
EDITEOF
# Run first edit session
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: multiple_consecutive_edits_same_message - first edit expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify first edit was applied
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "first edit" ]; then
echo "FAIL: multiple_consecutive_edits_same_message - after first edit, expected 'first edit', got: $msg1_content"
exit 1
fi
# Run second edit session (counter continues from 2)
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: multiple_consecutive_edits_same_message - second edit expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify second edit was applied (changes stack correctly)
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "second edit" ]; then
echo "FAIL: multiple_consecutive_edits_same_message - after second edit, expected 'second edit', got: $msg1_content"
exit 1
fi
echo "PASS: multiple_consecutive_edits_same_message"
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# test_edit_history_multiple_edits.sh - Multiple edits in one session
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with three messages
create_chat_file chat.json user "msg0" assistant "msg1" user "msg2"
# Stage the rebase editor output (edit messages 0 and 2)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
edit 0 [user] msg0
pick 1 [assistant] msg1
edit 2 [user] msg2
EDITEOF
# Stage the edit prompt response for message 0
cat > .editor_input_1 << 'EDITEOF'
# (no previous message)
Edit message 0 (role: user) below:
-- >8 --
edited msg0
EDITEOF
# Stage the edit prompt response for message 2
cat > .editor_input_2 << 'EDITEOF'
# Previous message:
[1] assistant: msg1
Edit message 2 (role: user) below:
-- >8 --
edited msg2
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: multiple_edits - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify both messages were edited
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "edited msg0" ]; then
echo "FAIL: multiple_edits - message 0 not edited correctly"
echo "Expected: 'edited msg0', got: '$msg0_content'"
exit 1
fi
msg2_content=$(dr_result chat.json 2)
if [ "$msg2_content" != "edited msg2" ]; then
echo "FAIL: multiple_edits - message 2 not edited correctly"
echo "Expected: 'edited msg2', got: '$msg2_content'"
exit 1
fi
echo "PASS: multiple_edits"
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# test_edit_history_no_edits_unchanged.sh - No edits, history remains the same
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with three messages
create_chat_file chat.json user "first message" assistant "second message" user "third message"
# Stage the rebase editor output exactly as generated by build_rebase_content
# This simulates the user doing nothing and just saving the editor
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] first message
pick 1 [assistant] second message
pick 2 [user] third message
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: no_edits_history_unchanged - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history is unchanged - same number of messages
len=$(history_length chat.json)
if [ "$len" != "3" ]; then
echo "FAIL: no_edits_history_unchanged - expected 3 messages, got $len"
exit 1
fi
# Verify all messages have their original content
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "first message" ]; then
echo "FAIL: no_edits_history_unchanged - message 0 content changed"
exit 1
fi
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "second message" ]; then
echo "FAIL: no_edits_history_unchanged - message 1 content changed"
exit 1
fi
msg2_content=$(dr_result chat.json 2)
if [ "$msg2_content" != "third message" ]; then
echo "FAIL: no_edits_history_unchanged - message 2 content changed"
exit 1
fi
# Verify all roles are unchanged
msg0_role=$(dr_role chat.json 0)
if [ "$msg0_role" != "user" ]; then
echo "FAIL: no_edits_history_unchanged - message 0 role changed"
exit 1
fi
msg1_role=$(dr_role chat.json 1)
if [ "$msg1_role" != "assistant" ]; then
echo "FAIL: no_edits_history_unchanged - message 1 role changed"
exit 1
fi
msg2_role=$(dr_role chat.json 2)
if [ "$msg2_role" != "user" ]; then
echo "FAIL: no_edits_history_unchanged - message 2 role changed"
exit 1
fi
echo "PASS: no_edits_history_unchanged"
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# test_edit_history_pick_all_messages.sh - Test picking all messages (history unchanged)
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (pick all)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
pick 1 [assistant] hi there
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: pick_all_messages - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history is unchanged
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: pick_all_messages - expected 2 messages, got $len"
exit 1
fi
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "hello" ]; then
echo "FAIL: pick_all_messages - message 0 content changed"
exit 1
fi
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "hi there" ]; then
echo "FAIL: pick_all_messages - message 1 content changed"
exit 1
fi
echo "PASS: pick_all_messages"
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# test_edit_history_prompt_noop.sh - No edits at edit prompt keeps message unchanged
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (edit message 1)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [assistant] hi there
EDITEOF
# Stage the edit prompt response (keep same content - no edits)
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: assistant) below:
-- >8 --
hi there
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_prompt_noop - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify message 1 content is unchanged
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: edit_prompt_noop - expected 2 messages, got $len"
exit 1
fi
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "hi there" ]; then
echo "FAIL: edit_prompt_noop - message 1 content should be unchanged"
exit 1
fi
echo "PASS: edit_prompt_noop"
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# test_edit_history_remove_all_content.sh - Editing a message and removing all content (empty content after edit)
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (edit message 1)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello
edit 1 [assistant] hi there
EDITEOF
# Stage the edit prompt response (empty content)
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello
Edit message 1 (role: assistant) below:
-- >8 --
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_remove_all_content - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history still has 2 messages
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: edit_remove_all_content - expected 2 messages, got $len"
exit 1
fi
# Verify message 1 content is now empty
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "" ]; then
echo "FAIL: edit_remove_all_content - message 1 content should be empty, got: $msg1_content"
exit 1
fi
echo "PASS: edit_remove_all_content"
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# test_edit_history_special_characters.sh - Editing messages with special characters in content
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with special characters (quotes, dollar sign)
create_chat_file chat.json user 'hello "quotes" and $dollar' assistant 'backslash\\here'
# Stage the rebase editor output (edit message 1)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] hello "quotes" and $dollar
edit 1 [assistant] backslash\here
EDITEOF
# Stage the edit prompt response (with special characters)
cat > .editor_input_1 << 'EDITEOF'
# Previous message:
[0] user: hello "quotes" and $dollar
Edit message 1 (role: assistant) below:
-- >8 --
edited with "quotes" and $dollar signs
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_special_characters - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history still has 2 messages
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: edit_special_characters - expected 2 messages, got $len"
exit 1
fi
# Verify message 1 content has the special characters
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != 'edited with "quotes" and $dollar signs' ]; then
echo "FAIL: edit_special_characters - message 1 content mismatch, got: $msg1_content"
exit 1
fi
echo "PASS: edit_special_characters"
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# test_edit_history_then_continue.sh - Edit then continue conversation normally
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with two messages
create_chat_file chat.json user "hello" assistant "hi there"
# Stage the rebase editor output (edit message 0)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
edit 0 [user] hello
pick 1 [assistant] hi there
EDITEOF
# Stage the edit prompt response
cat > .editor_input_1 << 'EDITEOF'
# (no previous message)
Edit message 0 (role: user) below:
-- >8 --
hello world
EDITEOF
# Run drharness with -b flag to edit history
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_then_continue - edit phase: expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify the edit was saved
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "hello world" ]; then
echo "FAIL: edit_then_continue - edit not saved correctly"
exit 1
fi
# Now run drharness normally (without -b) to continue the conversation
# We need a new editor input for the normal flow
# Reset staged inputs for the second run
cat > .editor_input_0 << 'EDITEOF'
[assistant] hi there
-- >8 --
how are you?
EDITEOF
# Reset the invocation counter for the new run
echo "0" > .editor_invocation_count
# Create mock LLM response for the debug client
be_done "I'm doing well, thanks for asking!" > mock_responses.txt
# Run drharness normally (uses DebugClient to read from stdin)
set +e
VISUAL="$MOCK_EDITOR" run_drharness -d -F chat.json < mock_responses.txt
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: edit_then_continue - continue phase: expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify the history now has 4 messages (original 2 + new user message + new assistant response)
len=$(history_length chat.json)
if [ "$len" != "4" ]; then
echo "FAIL: edit_then_continue - expected 4 messages after continue, got $len"
exit 1
fi
echo "PASS: edit_then_continue"
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# test_edit_history_unicode_characters.sh - Unicode characters in message content
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a chat file with unicode characters
create_chat_file chat.json user "こんにちは" assistant "👋 hello 世界"
# Stage the rebase editor output (pick all)
cat > .editor_input_0 << 'EDITEOF'
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] こんにちは
pick 1 [assistant] 👋 hello 世界
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: unicode_characters - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history still has 2 messages
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: unicode_characters - expected 2 messages, got $len"
exit 1
fi
# Verify message content preserved unicode
msg0_content=$(dr_result chat.json 0)
if [ "$msg0_content" != "こんにちは" ]; then
echo "FAIL: unicode_characters - message 0 unicode not preserved, got: $msg0_content"
exit 1
fi
msg1_content=$(dr_result chat.json 1)
if [ "$msg1_content" != "👋 hello 世界" ]; then
echo "FAIL: unicode_characters - message 1 unicode not preserved, got: $msg1_content"
exit 1
fi
echo "PASS: unicode_characters"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# test_edit_history_very_long_content.sh - Very long message content in rebase editor
# Helper function to run drharness and capture output to a temp file
run_drharness() {
"$DRHARNESS" "$@" > "$TEST_TEMP_DIR/drharness_output.txt" 2>&1
return $?
}
# Helper to get the output
get_output() {
cat "$TEST_TEMP_DIR/drharness_output.txt"
}
# Helper to reset editor state for each test
reset_editor_state() {
rm -f .editor_input_* .editor_invocation_count hist.md
}
reset_editor_state
# Create a very long message (500 characters)
long_content=$(printf 'x%.0s' {1..500})
create_chat_file chat.json user "short" assistant "$long_content"
# Stage the rebase editor output (pick all - just verify it handles long content)
cat > .editor_input_0 << EDITEOF
# History Rebase Editor
#
# Commands:
# pick - keep the message unchanged
# edit - modify the message content and/or role
# drop - remove the message from history
#
# Each line format: <command> <index> [<role>] <display_content>
# - index: 0-based message index
# - role: [user], [assistant], or [tool] (editable)
# - display_content: first line of message (display only, not editable here)
#
# Lines starting with # are comments and will be ignored.
# Empty lines are treated as drops.
#
-- >8 --
pick 0 [user] short
pick 1 [assistant] $long_content
EDITEOF
# Run drharness with -b flag
set +e
VISUAL="$MOCK_EDITOR" run_drharness -b -F chat.json
EXIT_CODE=$?
OUTPUT=$(get_output)
set -e
if [ ${EXIT_CODE} -ne 0 ]; then
echo "FAIL: very_long_content - expected exit code 0, got $EXIT_CODE"
echo "Output: $OUTPUT"
exit 1
fi
# Verify history still has 2 messages
len=$(history_length chat.json)
if [ "$len" != "2" ]; then
echo "FAIL: very_long_content - expected 2 messages, got $len"
exit 1
fi
echo "PASS: very_long_content"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# test_edit_prompt_first_message.sh - Test edit first message (no previous message)
test_name="edit_first_message"
json=$(create_history_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -W "$json" "0" "user")
# Check for "no previous message" text
if ! echo "$OUTPUT" | grep -q "# (no previous message)"; then
echo "FAIL: $test_name - should show no previous message"
echo "Got: $OUTPUT"
exit 1
fi
# Should not have "Previous message:" header
if echo "$OUTPUT" | grep -q "# Previous message:"; then
echo "FAIL: $test_name - should not show previous message header for index 0"
echo "Got: $OUTPUT"
exit 1
fi
# Check for separator
if ! echo "$OUTPUT" | grep -q -- "-- >8 --"; then
echo "FAIL: $test_name - missing separator"
echo "Got: $OUTPUT"
exit 1
fi
# Check for edit prompt
if ! echo "$OUTPUT" | grep -q "Edit message 0 (role: user) below:"; then
echo "FAIL: $test_name - missing edit prompt"
echo "Got: $OUTPUT"
exit 1
fi
# Check for current content
if ! echo "$OUTPUT" | grep -q "hello"; then
echo "FAIL: $test_name - missing current content"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# test_edit_prompt_middle_message.sh - Test edit middle message shows only immediate previous as context
test_name="edit_middle_message"
json=$(create_history_json user "msg0" assistant "msg1" tool "msg2")
OUTPUT=$("${DRHARNESS}" -W "$json" "2" "tool")
# Should show message 1 as context (immediate previous)
if ! echo "$OUTPUT" | grep -q "\[1\] assistant: msg1"; then
echo "FAIL: $test_name - should show message 1 as context"
echo "Got: $OUTPUT"
exit 1
fi
# Should NOT show message 0 in context
if echo "$OUTPUT" | grep -q "\[0\]"; then
echo "FAIL: $test_name - should not show message 0 in context"
echo "Got: $OUTPUT"
exit 1
fi
# Should show edit prompt for message 2
if ! echo "$OUTPUT" | grep -q "Edit message 2 (role: tool) below:"; then
echo "FAIL: $test_name - should show edit prompt for message 2"
echo "Got: $OUTPUT"
exit 1
fi
# Should show current content (msg2)
if ! echo "$OUTPUT" | grep -q "msg2"; then
echo "FAIL: $test_name - should show current content"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
@@ -0,0 +1,23 @@
#!/bin/bash
# test_edit_prompt_multiline_context.sh - Test multi-line content in context shows full content
test_name="multiline_context"
json=$(create_history_json user $'line one\nline two' assistant "response")
OUTPUT=$("${DRHARNESS}" -W "$json" "1" "assistant")
# Both lines should appear in context
if ! echo "$OUTPUT" | grep -q "line one"; then
echo "FAIL: $test_name - first line of context missing"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "line two"; then
echo "FAIL: $test_name - second line of context missing"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
@@ -0,0 +1,18 @@
#!/bin/bash
# test_edit_prompt_separator_placement.sh - Test separator correctly placed after edit prompt
test_name="separator_placement"
json=$(create_history_json user "first" assistant "second")
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
if ! echo "$OUTPUT" | grep -A2 "Edit message 1" | grep -q -- "-- >8 --"; then
echo "FAIL: $test_name - separator not correctly placed after edit prompt"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# test_edit_prompt_tool_role.sh - Test edit with tool role
test_name="edit_tool_role"
json=$(create_history_json assistant "call tool" tool "result")
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
echo "FAIL: $test_name - should show tool role in edit prompt"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# test_edit_prompt_with_context.sh - Test edit message with previous message context
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}" -W "$json" "1" "assistant")
# Check for previous message context
if ! echo "$OUTPUT" | grep -q "# Previous message:"; then
echo "FAIL: $test_name - missing previous message header"
echo "Got: $OUTPUT"
exit 1
fi
# Check for index 0 in context
if ! echo "$OUTPUT" | grep -q "\[0\] user: hello"; then
echo "FAIL: $test_name - missing context message with index"
echo "Got: $OUTPUT"
exit 1
fi
# Check for separator
if ! echo "$OUTPUT" | grep -q -- "-- >8 --"; then
echo "FAIL: $test_name - missing separator"
echo "Got: $OUTPUT"
exit 1
fi
# Check for edit prompt
if ! echo "$OUTPUT" | grep -q "Edit message 1 (role: assistant) below:"; then
echo "FAIL: $test_name - missing edit prompt"
echo "Got: $OUTPUT"
exit 1
fi
# Check for current content
if ! echo "$OUTPUT" | grep -q "hi there"; then
echo "FAIL: $test_name - missing current content"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+36 -15
View File
@@ -3,43 +3,64 @@
# Test parse_json_safely with valid JSON
OUTPUT=$("${DRHARNESS}" -J '{"key": "value"}')
if [ "${OUTPUT}" != "valid" ]; then
echo "FAIL: parse_json_safely - expected 'valid' for valid JSON, got '${OUTPUT}'"
if ! echo "$OUTPUT" | jq -e '.key == "value"' > /dev/null 2>&1; then
echo "FAIL: parse_json_safely - expected valid JSON with key='value', got '${OUTPUT}'"
exit 1
fi
# Test parse_json_safely with valid JSON object (empty)
OUTPUT=$("${DRHARNESS}" -J '{}')
if [ "${OUTPUT}" != "valid" ]; then
echo "FAIL: parse_json_safely - expected 'valid' for empty object, got '${OUTPUT}'"
if ! echo "$OUTPUT" | jq -e 'type == "object"' > /dev/null 2>&1; then
echo "FAIL: parse_json_safely - expected valid empty object, got '${OUTPUT}'"
exit 1
fi
# Test parse_json_safely with valid JSON array
OUTPUT=$("${DRHARNESS}" -J '[1, 2, 3]')
if [ "${OUTPUT}" != "valid" ]; then
echo "FAIL: parse_json_safely - expected 'valid' for array, got '${OUTPUT}'"
if ! echo "$OUTPUT" | jq -e 'type == "array" and length == 3' > /dev/null 2>&1; then
echo "FAIL: parse_json_safely - expected valid array, got '${OUTPUT}'"
exit 1
fi
# Test parse_json_safely with invalid JSON (missing quotes)
OUTPUT=$("${DRHARNESS}" -J '{key: value}')
if [ "${OUTPUT}" != "discarded" ]; then
echo "FAIL: parse_json_safely - expected 'discarded' for invalid JSON, got '${OUTPUT}'"
set +e
OUTPUT=$("${DRHARNESS}" -J '{key: value}' 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: parse_json_safely - expected non-zero exit code for invalid JSON, got 0"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "error: invalid JSON"; then
echo "FAIL: parse_json_safely - expected 'error: invalid JSON' for invalid JSON, got '${OUTPUT}'"
exit 1
fi
# Test parse_json_safely with invalid JSON (random text)
OUTPUT=$("${DRHARNESS}" -J 'not json at all')
if [ "${OUTPUT}" != "discarded" ]; then
echo "FAIL: parse_json_safely - expected 'discarded' for random text, got '${OUTPUT}'"
set +e
OUTPUT=$("${DRHARNESS}" -J 'not json at all' 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: parse_json_safely - expected non-zero exit code for invalid JSON, got 0"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "error: invalid JSON"; then
echo "FAIL: parse_json_safely - expected 'error: invalid JSON' for random text, got '${OUTPUT}'"
exit 1
fi
# Test parse_json_safely with empty string
OUTPUT=$("${DRHARNESS}" -J '')
if [ "${OUTPUT}" != "discarded" ]; then
echo "FAIL: parse_json_safely - expected 'discarded' for empty string, got '${OUTPUT}'"
set +e
OUTPUT=$("${DRHARNESS}" -J '' 2>&1)
EXIT_CODE=$?
set -e
if [ ${EXIT_CODE} -eq 0 ]; then
echo "FAIL: parse_json_safely - expected non-zero exit code for empty string, got 0"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "error: invalid JSON"; then
echo "FAIL: parse_json_safely - expected 'error: invalid JSON' for empty string, got '${OUTPUT}'"
exit 1
fi
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_apply_all_lines_deleted_keeps.sh - All lines deleted (only header) keeps all messages
# Empty commands - simulates deleting all message lines
lines='[]'
history=$(create_messages_json user "item 0" assistant "item 1")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should have 2 messages (all kept because no commands specified)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 2 ]; then
echo "FAIL: all_lines_deleted_keeps_all - expected 2 messages, got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: all_lines_deleted_keeps_all"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# test_rebase_apply_deleting_lines_drops.sh - Deleting lines from rebase editor drops those messages (issue 120)
# Simulate editing a 6-item history where items 4 and 5 are deleted
# Only items 0-3 are mentioned in the commands
lines=$(create_rebase_lines pick 0 user pick 1 assistant pick 2 tool pick 3 assistant)
history=$(create_messages_json \
user "item 0" assistant "item 1" tool "item 2" assistant "item 3" \
user "item 4" assistant "item 5")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should have 4 messages (items 4 and 5 dropped)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 4 ]; then
echo "FAIL: deleting_lines_drops_messages - expected 4 messages, got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
# Should not contain item 4 or item 5
if echo "$OUTPUT" | grep -q '"item 4"'; then
echo "FAIL: deleting_lines_drops_messages - item 4 should be dropped"
echo "Got: $OUTPUT"
exit 1
fi
if echo "$OUTPUT" | grep -q '"item 5"'; then
echo "FAIL: deleting_lines_drops_messages - item 5 should be dropped"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: deleting_lines_drops_messages"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# test_rebase_apply_drop_removes.sh - Drop commands remove messages from result
lines=$(create_rebase_lines drop 0 user pick 1 assistant)
history=$(create_messages_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should only have one message (the assistant response)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 1 ]; then
echo "FAIL: drop_removes_message - expected 1 message, got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
# Should not contain the dropped message content
if echo "$OUTPUT" | grep -q '"hello"'; then
echo "FAIL: drop_removes_message - dropped message should not be in output"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: drop_removes_message"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# test_rebase_apply_edit_preserves_fields.sh - Non-edited fields are preserved during edits
lines=$(create_rebase_lines edit 0 assistant)
history=$(create_messages_json user "hello" reasoning "thought process" tool_call_id "call_123")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Check reasoning is preserved
if ! echo "$OUTPUT" | grep -q '"thought process"'; then
echo "FAIL: edit_preserves_fields - reasoning should be preserved"
echo "Got: $OUTPUT"
exit 1
fi
# Check tool_call_id is preserved
if ! echo "$OUTPUT" | grep -q '"call_123"'; then
echo "FAIL: edit_preserves_fields - tool_call_id should be preserved"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: edit_preserves_fields"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# test_rebase_apply_edit_preserves_tool_calls.sh - tool_calls field preserved during edit
lines=$(create_rebase_lines edit 0 assistant)
history=$(create_messages_json assistant "calling tool" tool_call "call_1" "test" "{}")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# tool_calls should be preserved
if ! echo "$OUTPUT" | grep -q '"tool_calls"'; then
echo "FAIL: edit_preserves_tool_calls - tool_calls should be preserved during edit"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: edit_preserves_tool_calls"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# test_rebase_apply_edit_updates_role.sh - Edit commands update role
lines=$(create_rebase_lines edit 0 assistant)
history=$(create_messages_json user "hello world")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Check role was changed to assistant
if ! echo "$OUTPUT" | grep -q '"role".*"assistant"'; then
echo "FAIL: edit_updates_role - role should be changed to assistant"
echo "Got: $OUTPUT"
exit 1
fi
# Content should still be preserved
if ! echo "$OUTPUT" | grep -q '"hello world"'; then
echo "FAIL: edit_updates_role - content should be preserved during edit"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: edit_updates_role"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# test_rebase_apply_empty_lines_preserves.sh - Empty lines array preserves all messages
lines='[]'
history=$(create_messages_json user "hello" assistant "hi")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should have 2 messages (all implicitly picked)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 2 ]; then
echo "FAIL: empty_lines_preserves_all - expected 2 messages, got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: empty_lines_preserves_all"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_apply_implicit_pick.sh - Messages not referenced by any command are dropped (implicit drop)
# Only specify command for index 0, index 1 should be implicitly dropped
lines=$(create_rebase_lines pick 0 user)
history=$(create_messages_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should have 1 message (index 1 dropped)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 1 ]; then
echo "FAIL: implicit_pick - expected 1 message, got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: implicit_pick"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# test_rebase_apply_lenient_orphaned_results.sh - Lenient mode - orphaned tool results are allowed
# Drop the tool call message but keep the result (orphaned)
lines=$(create_rebase_lines drop 0 assistant pick 1 tool)
history=$(create_messages_json \
assistant "" tool_call "call_1" "test" "" \
tool "result" tool_call_id "call_1")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should have 1 message (the orphaned tool result)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 1 ]; then
echo "FAIL: lenient_orphaned_results - expected 1 message (orphaned result), got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
# The orphaned tool result should be present
if ! echo "$OUTPUT" | grep -q '"tool"'; then
echo "FAIL: lenient_orphaned_results - orphaned tool result should be present"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: lenient_orphaned_results"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# test_rebase_apply_mixed_commands.sh - Mixed commands (pick, drop, edit)
lines=$(create_rebase_lines pick 0 user drop 1 assistant edit 2 tool)
history=$(create_messages_json user "hello" assistant "hi" tool "result")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
# Should have 2 messages (index 1 dropped)
msg_count=$(echo "$OUTPUT" | grep -o '"role"' | wc -l)
if [ "$msg_count" -ne 2 ]; then
echo "FAIL: mixed_commands - expected 2 messages, got $msg_count"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: mixed_commands"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# test_rebase_apply_pick_preserves.sh - Pick commands preserve messages unchanged
lines=$(create_rebase_lines pick 0 user)
history=$(create_messages_json user "hello world")
OUTPUT=$("${DRHARNESS}" -A "$lines" "$history")
if ! echo "$OUTPUT" | grep -q '"hello world"'; then
echo "FAIL: pick_preserves_message - content should be preserved"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q '"role".*"user"'; then
echo "FAIL: pick_preserves_message - role should be preserved"
echo "Got: $OUTPUT"
exit 1
fi
# Check we got 1 message back
if ! echo "$OUTPUT" | grep -q '"role"'; then
echo "FAIL: pick_preserves_message - expected output"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: pick_preserves_message"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# test_rebase_content_basic_messages.sh - Test basic messages in rebase content
test_name="basic_messages"
json=$(create_messages_json user "hello" assistant "hi there")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Check header exists
if ! echo "$OUTPUT" | grep -q "# History Rebase Editor"; then
echo "FAIL: $test_name - missing header"
echo "Got: $OUTPUT"
exit 1
fi
# Check pick commands
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] hello"; then
echo "FAIL: $test_name - missing pick 0"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "pick 1 \[assistant\] hi there"; then
echo "FAIL: $test_name - missing pick 1"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# test_rebase_content_empty_message.sh - Test empty message shows (empty)
test_name="empty_message"
json=$(create_messages_json user "")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] (empty)"; then
echo "FAIL: $test_name - should show (empty) for empty content"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# test_rebase_content_header_comments.sh - Test header explains pick/edit/drop commands
test_name="header_comments"
json=$(create_messages_json user "test")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick - keep the message unchanged"; then
echo "FAIL: $test_name - missing pick explanation"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "edit - modify the message content and/or role"; then
echo "FAIL: $test_name - missing edit explanation"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "drop - remove the message from history"; then
echo "FAIL: $test_name - missing drop explanation"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# test_rebase_content_message_indices.sh - Test each message on its own line with correct index
test_name="message_indices"
json=$(create_messages_json user "msg1" assistant "msg2" tool "msg3")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] msg1"; then
echo "FAIL: $test_name - missing index 0"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "pick 1 \[assistant\] msg2"; then
echo "FAIL: $test_name - missing index 1"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "pick 2 \[tool\] msg3"; then
echo "FAIL: $test_name - missing index 2"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# test_rebase_content_multiline_content.sh - Test multi-line content shows only first line
test_name="multiline_content"
json=$(create_messages_json user "first line
second line
third line")
OUTPUT=$("${DRHARNESS}" -Z "$json")
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] first line"; then
echo "FAIL: $test_name - should show only first line"
echo "Got: $OUTPUT"
exit 1
fi
# Make sure it doesn't include the other lines
if echo "$OUTPUT" | grep -q "second line"; then
echo "FAIL: $test_name - should not include second line in pick command"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# test_rebase_content_multiple_tool_calls.sh - Test multiple tool calls shows first one with ...
test_name="multiple_tool_calls"
json=$(create_messages_json assistant "" tool_call "call_1" "read_file" "" tool_call "call_2" "write_file" "")
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 ..."
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# test_rebase_content_scissors_line.sh - Test scissors line separates comments from commands
test_name="scissors_line"
json=$(create_messages_json user "test")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Check for scissors line
if ! echo "$OUTPUT" | grep -q -- "-- >8 --"; then
echo "FAIL: $test_name - missing scissors line"
echo "Got: $OUTPUT"
exit 1
fi
# Check that scissors line appears before pick commands
scissors_line=$(echo "$OUTPUT" | grep -n -- "-- >8 --" | head -1 | cut -d: -f1)
pick_line=$(echo "$OUTPUT" | grep -n "^pick" | head -1 | cut -d: -f1)
if [ "$scissors_line" -ge "$pick_line" ]; then
echo "FAIL: $test_name - scissors line should appear before pick commands"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# test_rebase_content_special_characters.sh - Test special characters in content
test_name="special_characters"
json=$(create_messages_json user 'hello "quotes" and $dollar' assistant 'backslash\here')
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Should handle quotes and special characters
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\]"; then
echo "FAIL: $test_name - should handle special characters"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# test_rebase_content_tool_call_display.sh - Test assistant message with tool_calls display
test_name="tool_call_display"
json=$(create_messages_json assistant "" tool_call "call_1" "run_shell" "")
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"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# test_rebase_content_tool_message.sh - Test tool message shows result content
test_name="tool_message"
json=$(create_messages_json tool "tool result content")
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"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# test_rebase_content_unicode_characters.sh - Test unicode characters in content
test_name="unicode_characters"
json=$(create_messages_json user "こんにちは" assistant "👋 hello 世界")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Should preserve unicode characters
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\] こんにちは"; then
echo "FAIL: $test_name - should preserve unicode"
echo "Got: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "pick 1 \[assistant\] 👋 hello 世界"; then
echo "FAIL: $test_name - should preserve unicode emoji and CJK"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_content_very_long_content.sh - Test very long content (truncation test)
test_name="very_long_content"
# Create a very long string (1000 characters)
long_content=$(printf 'x%.0s' {1..1000})
json=$(create_messages_json user "$long_content")
OUTPUT=$("${DRHARNESS}" -Z "$json")
# Should handle long content without crashing
if ! echo "$OUTPUT" | grep -q "pick 0 \[user\]"; then
echo "FAIL: $test_name - should handle long content"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# test_rebase_parser_all_commands.sh - Valid rebase file with all three commands
test_name="valid_rebase_all_commands"
content="pick 0 [user] hello world
edit 1 [assistant] ai response
drop 2 [tool] tool result"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 3 ]; then
echo "FAIL: $test_name - expected 3 lines, got $count"
echo "Got: $OUTPUT"
exit 1
fi
if [ "$(rebase_command_at "$OUTPUT" 0)" != "pick" ]; then
echo "FAIL: $test_name - expected pick at 0"
exit 1
fi
if [ "$(rebase_index_at "$OUTPUT" 0)" != "0" ]; then
echo "FAIL: $test_name - expected index 0 at 0"
exit 1
fi
if [ "$(rebase_role_at "$OUTPUT" 0)" != "user" ]; then
echo "FAIL: $test_name - expected role user at 0"
exit 1
fi
if [ "$(rebase_command_at "$OUTPUT" 1)" != "edit" ]; then
echo "FAIL: $test_name - expected edit at 1"
exit 1
fi
if [ "$(rebase_command_at "$OUTPUT" 2)" != "drop" ]; then
echo "FAIL: $test_name - expected drop at 2"
exit 1
fi
echo "PASS: $test_name"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# test_rebase_parser_all_valid_roles.sh - All three valid roles accepted
test_name="all_valid_roles"
content="pick 0 [user] hello
pick 1 [assistant] response
pick 2 [tool] result"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
if [ "$(rebase_role_at "$OUTPUT" 0)" != "user" ]; then
echo "FAIL: $test_name - expected user role at 0"
exit 1
fi
if [ "$(rebase_role_at "$OUTPUT" 1)" != "assistant" ]; then
echo "FAIL: $test_name - expected assistant role at 1"
exit 1
fi
if [ "$(rebase_role_at "$OUTPUT" 2)" != "tool" ]; then
echo "FAIL: $test_name - expected tool role at 2"
exit 1
fi
echo "PASS: $test_name"
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# test_rebase_parser_comments_and_empty_lines_mixed.sh - Comments and empty lines mixed with commands
test_name="comments_and_empty_lines_mixed"
content="# Header comment
pick 0 [user] hello
# Middle comment
edit 1 [assistant] response
drop 2 [tool] result
# Footer comment"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 3 ]; then
echo "FAIL: $test_name - expected 3 lines, got $count"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# test_rebase_parser_duplicate_indices.sh - Duplicate indices produces error
test_name="duplicate_indices"
content="pick 0 [user] hello
pick 1 [assistant] response
drop 0 [tool] duplicate"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "duplicate index 0"; then
echo "FAIL: $test_name - expected 'duplicate index 0' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# test_rebase_parser_empty_rebase.sh - Empty rebase file is valid (no lines)
test_name="empty_rebase"
OUTPUT=$("${DRHARNESS}" -z 3 "")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 0 ]; then
echo "FAIL: $test_name - expected 0 lines, got $count"
exit 1
fi
echo "PASS: $test_name"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# test_rebase_parser_first_error_returned.sh - First error is returned (not all errors aggregated)
test_name="first_error_returned"
content="invalid 0 [user] bad command
pick 10 [assistant] out of range
drop 100 [tool] also out of range"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "invalid command: 'invalid'"; then
echo "FAIL: $test_name - expected 'invalid command' as first error"
echo "Got: $OUTPUT"
exit 1
fi
# Should NOT see the other errors since we exit on first error
if echo "$OUTPUT" | grep -q "index 10 out of range"; then
echo "FAIL: $test_name - should not see second error (exit on first)"
echo "Got: $OUTPUT"
exit 1
fi
if echo "$OUTPUT" | grep -q "index 100 out of range"; then
echo "FAIL: $test_name - should not see third error (exit on first)"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_parser_index_out_of_range.sh - Out of range index produces error
test_name="index_out_of_range"
content="pick 0 [user] hello
pick 10 [assistant] response"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "index 10 out of range"; then
echo "FAIL: $test_name - expected 'index 10 out of range' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_parser_invalid_command.sh - Invalid command produces error
test_name="invalid_command"
content="pick 0 [user] hello
squash 1 [assistant] response"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "invalid command: 'squash'"; then
echo "FAIL: $test_name - expected 'invalid command: squash' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# test_rebase_parser_invalid_command_digit.sh - Line starting with digit (invalid command) produces error
test_name="invalid_command_digit"
content="0 [user] no command"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "invalid command: '0'"; then
echo "FAIL: $test_name - expected 'invalid command: 0' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# test_rebase_parser_invalid_index.sh - Invalid index produces error
test_name="invalid_index"
content="pick [user] no valid index"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "invalid index: '\[user\]'"; then
echo "FAIL: $test_name - expected 'invalid index' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_parser_invalid_role_value.sh - Invalid role value produces error
test_name="invalid_role_value"
content="pick 0 [user] hello
pick 1 [system] response"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "invalid role: '\[system\]'"; then
echo "FAIL: $test_name - expected 'invalid role' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_parser_malformed_role.sh - Missing role brackets produces error
test_name="malformed_role"
content="pick 0 [user] hello
pick 1 assistant response"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "malformed role: 'assistant'"; then
echo "FAIL: $test_name - expected 'malformed role' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# test_rebase_parser_missing_role.sh - Missing role produces error
test_name="missing_role"
content="pick 0"
set +e
OUTPUT=$("${DRHARNESS}" -z 3 "$content" 2>&1) || true
set -e
if ! echo "$OUTPUT" | grep -q "missing role"; then
echo "FAIL: $test_name - expected 'missing role' error"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# test_rebase_parser_only_comments.sh - Only comments produces 0 lines
test_name="only_comments"
content="# comment 1
# comment 2"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 0 ]; then
echo "FAIL: $test_name - expected 0 lines, got $count"
exit 1
fi
echo "PASS: $test_name"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# test_rebase_parser_whitespace_variations.sh - Pick commands with whitespace variations
test_name="whitespace_variations"
# Test with multiple spaces between fields
content="pick 0 [user] hello
edit 1 [assistant] response
drop 2 [tool] result"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 3 ]; then
echo "FAIL: $test_name - expected 3 lines, got $count"
echo "Got: $OUTPUT"
exit 1
fi
echo "PASS: $test_name"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# test_rebase_parser_with_comments.sh - Valid rebase with comments ignored
test_name="valid_rebase_with_comments"
content="# This is a comment
pick 0 [user] hello
# Another comment
edit 1 [assistant] response"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 2 ]; then
echo "FAIL: $test_name - expected 2 lines, got $count"
exit 1
fi
echo "PASS: $test_name"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# test_rebase_parser_with_empty_lines.sh - Valid rebase with empty lines
test_name="valid_rebase_with_empty_lines"
content="pick 0 [user] hello
edit 1 [assistant] response
drop 2 [tool] result"
OUTPUT=$("${DRHARNESS}" -z 3 "$content")
count=$(rebase_line_count "$OUTPUT")
if [ "$count" -ne 3 ]; then
echo "FAIL: $test_name - expected 3 lines, got $count"
exit 1
fi
echo "PASS: $test_name"
+1 -1
View File
@@ -35,7 +35,7 @@ Tool make_edit_tool() {
{"properties", {
{"filePath", {
{"type", "string"},
{"description", "The absolute path to the file to modify"}
{"description", "The path to the file to modify (relative or absolute)"}
}},
{"oldString", {
{"type", "string"},
+1 -1
View File
@@ -12,7 +12,7 @@ Tool make_file_list_tool() {
{"properties", {
{"path", {
{"type", "string"},
{"description", "The absolute path to the directory to list."}
{"description", "The path to the directory to list (relative or absolute)."}
}},
{"recursive", {
{"type", "boolean"},
+1 -1
View File
@@ -14,7 +14,7 @@ Tool make_file_read_tool() {
{"properties", {
{"path", {
{"type", "string"},
{"description", "The absolute path to the file to read."}
{"description", "The path to the file to read (relative or absolute)."}
}}
}},
{"required", {"path"}}