89 Commits

Author SHA1 Message Date
darthrubik c6696490bd Merge pull request 'issue 215: Use OpenAI official API to list available models' (#58) from bugfix/fix-list-models into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/58
2026-08-12 19:21:44 -05:00
hal9000 afbf1c5484 issue 215: Use OpenAI official API to list available models
Changed list_models() to use the OpenAI-compatible /v1/models endpoint
instead of the Ollama-specific /api/tags endpoint. Updated ModelInfo and
ModelListResponse structs to match the OpenAI API response format.
2026-08-12 19:21:00 -05:00
darthrubik 34f317d38a Merge pull request 'bugfix/hanging-background-processes' (#54) from bugfix/hanging-background-processes into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/54
2026-08-12 16:08:30 -05:00
hal9000 8f50dd324b Remove unused fcntl.h include from shell_tool.cpp
The fcntl() calls for FD_CLOEXEC were removed in a previous commit but
the include statement was left behind. This removes the dead include.
2026-08-12 16:07:19 -05:00
darthrubik 4fbf19dd3b Removing extra slop the AI added 2026-08-12 16:00:43 -05:00
hal9000 0e2986b562 Fix shell tool hanging when command spawns background processes
The shell tool would hang indefinitely when running commands that spawn
background processes (e.g., 'sleep 10 &'). This was because:

1. Background processes inherit stdout, which points to our pipe
2. The original code read in a loop when POLLIN was detected
3. After reading available data, the second read() would block waiting
   for more data that never comes (since background process just sleeps)

The fix changes the POLLIN handler to read only once, then continue
polling. If POLLHUP is eventually set (when the background process is
killed by timeout), we'll read all remaining data then.

Additionally, FD_CLOEXEC is set on pipe file descriptors to prevent
inheritance during exec() calls (though this doesn't help with fork()
which is how shells spawn background processes).

Fixes issue 205 - failing unit tests.
2026-08-12 15:57:00 -05:00
darthrubik 95c415f06a Adding failing test for hanging background processes 2026-08-12 15:48:31 -05:00
darthrubik 3af0c1f0f7 Merge pull request 'feature/save-model-between-chats' (#52) from feature/save-model-between-chats into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/52
2026-08-12 15:41:38 -05:00
darthrubik 6c1906679d Merge remote-tracking branch 'origin/master' into feature/save-model-between-chats 2026-08-12 15:41:23 -05:00
darthrubik 6478617cb9 Merge pull request 'feature/ctrlc-cancel' (#49) from feature/ctrlc-cancel into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/49
2026-08-12 15:32:28 -05:00
darthrubik 8b1c7e9d3d Merge remote-tracking branch 'sandbox/195/work' into feature/ctrlc-cancel 2026-08-06 16:22:38 -05:00
hal9000 a3e044712f Move xferinfo_cb to ollama_client.cpp to avoid exposing libcurl in header
- Remove curl/curl.h include from ollama_client.hpp
- Remove xferinfo_cb static method declaration from OllamaClient class
- Make xferinfo_cb a file-scope static function in ollama_client.cpp

Closes: #195
2026-08-06 21:22:10 +00:00
darthrubik ee7db0d87c Merge remote-tracking branch 'sandbox/194/work' into feature/ctrlc-cancel 2026-08-06 16:21:22 -05:00
hal9000 3bc2b61b5a Speed up ctrlc test by using shorter sleep (1s instead of 5s) 2026-08-06 21:20:46 +00:00
hal9000 64d8e5bb49 Add test for ctrlc cancellation after tool execution
Test verifies that sending SIGINT during tool execution stops processing
after that tool completes and prevents subsequent tool calls from running.
Conversation state is preserved in the chat file.
2026-08-06 21:17:03 +00:00
darthrubik 1d5a5b5e17 On second ctrlc immediately die 2026-08-06 16:07:47 -05:00
hal9000 b152b96520 Add cancellation check after tool execution in harness chat loop
This allows the user to interrupt between rapid tool calls. When Ctrl+C is
pressed during a sequence of tool calls, the harness now stops processing
rather than completing the entire batch.

The check follows the same semantics as the existing cancellation checks:
returns empty string (success) to preserve conversation state in the chat file.
2026-08-06 21:06:14 +00:00
darthrubik ccc25e9ffd Merge remote-tracking branch 'sandbox/190/work' into feature/ctrlc-cancel 2026-08-06 15:44:49 -05:00
darthrubik dffa9d875b Merge remote-tracking branch 'sandbox/191/work' into feature/ctrlc-cancel 2026-08-06 15:41:35 -05:00
darthrubik e437fd8624 Merge remote-tracking branch 'sandbox/189/work' into feature/ctrlc-cancel 2026-08-06 15:41:25 -05:00
hal9000 bbc4f5acf0 Add unit test for ctrlc feature on shell tool (issue 193)
Test verifies that when SIGINT (Ctrl+C) is sent to drharness during
shell command execution, the tool returns the expected
'[user requested halt of program]' message.
2026-08-05 23:49:07 +00:00
hal9000 f2cefd243b Document return value semantics on cancellation (issue #191)
Add comments explaining that Harness::chat() returns an empty string
on Ctrl+C cancellation rather than std::unexpected. This preserves
the conversation state in the chat file, allowing users to resume
the conversation from the point of interruption.
2026-08-05 23:43:28 +00:00
hal9000 304a1ba133 Add cancellation check to grep_search tool
Add periodic ctrlc_request checks during directory traversal and file
processing in the grep_search tool. This allows the tool to respect user
cancellation (Ctrl+C) during recursive directory scanning of large codebases.

- Changed process_file to return bool indicating cancellation
- Added ctrlc_request check in directory iteration loop
- Added ctrlc_request check in file processing loop
- Returns early with cancellation message when cancelled

Closes issue #189
2026-08-05 23:39:59 +00:00
hal9000 a64cc3075f issue 190: Add Windows signal handling support
Add platform-specific signal handling to enable Ctrl+C cancellation on Windows.
- Windows uses SetConsoleCtrlHandler with CTRL_C_EVENT
- Unix continues to use signal() with SIGINT
- Unified setup_signal_handler() function for both platforms
2026-08-05 23:33:48 +00:00
darthrubik d2451f3484 Add ctrlc cancel to shell tool 2026-08-05 18:25:53 -05:00
darthrubik 36ebca3f75 When showing editor for -F just always show last thing no matter what 2026-08-05 18:12:28 -05:00
darthrubik 618976e316 Adding the ability to cancel an agent midstream 2026-08-05 18:11:32 -05:00
darthrubik 92d568773c Actually use the saved state 2026-08-04 15:34:16 -05:00
darthrubik 2332daa826 Refactor to make more sense 2026-08-04 15:25:49 -05:00
hal9000 02975ddfd4 Fix: Use helper functions in test_harness_config.sh
- Added dr_config_base_url and dr_config_model helper functions to run_test.sh
- Updated test_harness_config.sh to use be_done() and helper functions instead of inline JSON
2026-08-04 20:20:10 +00:00
hal9000 3e99b37683 Move base_url and model into HarnessConfig (issue 186)
- Added base_url and model fields to HarnessConfig in json_types.hpp
- Removed base_url and model from OllamaConfig in ollama_client.hpp
- Updated OllamaClient constructor to accept base_url and model as separate parameters
- Updated main.cpp to use HarnessConfig for base_url and model
- Updated agent_tool.cpp to use new OllamaClient constructor
- Added test_harness_config.sh to verify the new config fields
- Updated README.md to document the config fields
2026-08-04 15:13:37 -05:00
darthrubik 975069c9d0 Merge pull request 'feature/rebase' (#44) from feature/rebase into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/44
2026-07-23 17:03:55 -05:00
darthrubik 9fe510f00d Removing accidentally commited files 2026-07-23 17:02:35 -05:00
darthrubik 815eeeec44 Rename flag that was used in other branch 2026-07-23 16:59:50 -05:00
darthrubik d655a9d953 Merge remote-tracking branch 'origin/master' into feature/rebase 2026-07-23 16:59:40 -05:00
darthrubik 0e146dbd84 Merge pull request 'Update for build v0.3' (#41) from release/v0.3 into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/41
2026-07-22 09:11:22 -05:00
darthrubik e51397d532 Update for build v0.3 2026-07-22 09:10:55 -05:00
darthrubik 709ed403c1 Merge pull request 'Add -P flag to read prompt from file' (#40) from feature/read-from-file into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/40
2026-07-22 09:10:06 -05:00
hal9000 48a3f15999 Add -P flag to read prompt from file
Implement issue 185: Create a new flag -P that reads the prompt from a
file instead of the command line.

Changes:
- Add -P flag parsing in main.cpp
- Read prompt content from specified file
- Update help message
- Update README.md documentation
- Add test_prompt_file.sh with tests for basic, multi-line, and error cases
2026-07-22 14:04:04 +00:00
darthrubik 8a6d69750d Merge pull request 'Updating for release' (#39) from release/v0.2 into master
Reviewed-on: https://192.168.8.63/git/darthrubik/drharness/pulls/39
2026-07-10 16:06:29 -05:00
hal9000 5e96cb7ed8 Fix duplicate -R help line from merge conflict resolution
Remove duplicate '-R' help line that was incorrectly merged with
inconsistent indentation.
2026-06-15 22:56:38 +00:00
hal9000 861b73a050 Update issue status after merge completion
- Mark issue 169 as closed (merge completed)
- Unblock issue 93 (dependency on 169 satisfied)
2026-06-15 22:52:57 +00:00
hal9000 da919f9f6e Merge origin/master into work branch (issue #169)
Resolved merge conflicts:
- CMakeLists.txt: Added both rebase_engine/utility and base64_util sources
- src/harness.cpp: Combined Content type with proper error handling
- src/main.cpp: Merged includes, test flags, and CLI parsing
  - Renamed conflicting flags: -B→-Z (build_rebase), -T→-W (edit_prompt), -b→-K (base64_decode)
- tests/CMakeLists.txt: Added all test targets from both branches
- src/rebase_engine.cpp: Fixed to use get_content_string() for Content variant type
- Updated test files to use new flag names (-Z, -W, -K)
2026-06-15 22:52:29 +00:00
darthrubik fee692d262 Fix compilation errors and test failures after merge (issue #140)
- Update ollama_client.cpp to use std::expected pattern for parse_json_safely
- Add missing 'index' field to tool_calls in add_message_field helper
- Add missing 'index' field to test_edit_history_assistant_with_tool_calls test data
2026-06-04 16:09:09 -05:00
darthrubik ea8582644e Merge branch 'master' into feature/rebase (issue #140) 2026-06-04 16:07:42 -05:00
darthrubik d81c75a9a3 Fix issue 134: add_message_field no longer accepts raw JSON for tool_calls
The add_message_field function now constructs tool call JSON internally
from simple parameters (id, name, arguments) instead of accepting raw
JSON strings. This makes the test helper functions more robust and
easier to use.

Changed:
- add_message_field: tool_call field now takes id, name, args and builds JSON
- create_message_json: updated to handle tool_call's 3-parameter syntax
- create_messages_json: updated to handle tool_call's 3-parameter syntax
- is_message_field: changed tool_calls to tool_call
- Updated all test files that used the old tool_calls raw JSON syntax
2026-06-03 07:53:39 -05:00
darthrubik 0a734ec2ba Split test_rebase_parser.sh into individual test files (issue #133)
Each of the 17 tests from test_rebase_parser.sh is now in its own
test file, following the project convention of one test per script.
2026-06-03 07:49:25 -05:00
darthrubik 8cb6f5aa0b Delete old combined test_rebase_content.sh (issue 132)
The file was split into 12 individual test files in a previous
commit, but the original combined file was not deleted. This
commit removes the now-obsolete test_rebase_content.sh file.
2026-06-03 07:43:28 -05:00
darthrubik 5f88196c54 Use create_messages_json helper instead of manual JSON (issue 132)
Updated all 12 test_rebase_content_* test files to use the
create_messages_json helper function from run_test.sh instead of
manually creating JSON strings, following the pattern established
by test_rebase_apply_* tests.

This complies with CONTRIBUTING.md which states:
'If you do any json handling in the test files your changes will be rejected'

All 83 tests pass.
2026-06-03 07:41:24 -05:00
darthrubik a96246bcd3 Split test_rebase_content.sh into 12 individual test scripts (issue 132)
Each test in tests/test_rebase_content.sh is now its own test script:
- test_rebase_content_basic_messages.sh
- test_rebase_content_multiline_content.sh
- test_rebase_content_tool_call_display.sh
- test_rebase_content_tool_message.sh
- test_rebase_content_empty_message.sh
- test_rebase_content_header_comments.sh
- test_rebase_content_multiple_tool_calls.sh
- test_rebase_content_message_indices.sh
- test_rebase_content_scissors_line.sh
- test_rebase_content_special_characters.sh
- test_rebase_content_unicode_characters.sh
- test_rebase_content_very_long_content.sh

All 83 tests pass.
2026-06-03 07:38:10 -05:00
darthrubik 65aededace test: remove combined test_rebase_apply.sh (issue #131)
The tests have been split into individual test files and registered
in CMakeLists.txt. Removing the combined test file as it is no
longer needed.
2026-06-03 07:33:20 -05:00
darthrubik 949682a176 Split test_rebase_apply.sh into individual test files (issue 131)
- test_rebase_apply_pick_preserves.sh
- test_rebase_apply_drop_removes.sh
- test_rebase_apply_edit_updates_role.sh
- test_rebase_apply_edit_preserves_fields.sh
- test_rebase_apply_implicit_pick.sh
- test_rebase_apply_mixed_commands.sh
- test_rebase_apply_empty_lines_preserves.sh
- test_rebase_apply_lenient_orphaned_results.sh
- test_rebase_apply_edit_preserves_tool_calls.sh
- test_rebase_apply_deleting_lines_drops.sh
- test_rebase_apply_all_lines_deleted_keeps.sh

Removed the combined test_rebase_apply.sh file and updated CMakeLists.txt
to include the 11 new individual test files.
2026-06-03 07:02:15 -05:00
darthrubik e65616bfea Remove redundant code from run_test.sh (#130)
- Consolidated dr_* functions into dr_field helper
- Refactored create_chat_file to use create_history_json
- Refactored create_rebase_lines to use create_rebase_line
- Created add_message_field helper for message field handling
- Created is_message_field helper for field name checking
- Refactored create_messages_json to use create_message_json
- Added local variable declarations for better scoping
2026-06-02 17:19:21 -05:00
darthrubik 41b49aeeae Fix issue 129: Use TEST_TEMP_DIR instead of /tmp for test output files
Replace hardcoded /tmp/drharness_output.txt with $TEST_TEMP_DIR/drharness_output.txt
in all test files. Tests should only write to subfiles/directories of the working
directory (which is already a temp directory created by run_test.sh).
2026-06-02 17:09:32 -05:00
darthrubik cec6c72ecd fix(issue 128): replace manual JSON creation with helper functions
Add helper functions to run_test.sh for creating rebase lines and history
messages:
- create_rebase_line: creates a single rebase line JSON object
- create_rebase_lines: creates a JSON array of rebase lines
- create_message_json: creates a message JSON with optional fields
- create_messages_json: creates a JSON array of messages

Update test_rebase_apply.sh to use these helper functions instead of
manually constructing JSON strings, which violates CONTRIBUTING.md.
2026-06-02 16:33:50 -05:00
darthrubik 511b53ca18 Break test_edit_prompt.sh into multiple tests (issue 127)
Split the monolithic test_edit_prompt.sh into 6 individual test files:
- test_edit_prompt_with_context.sh
- test_edit_prompt_first_message.sh
- test_edit_prompt_separator_placement.sh
- test_edit_prompt_multiline_context.sh
- test_edit_prompt_tool_role.sh
- test_edit_prompt_middle_message.sh

Each test is now its own script that can be run independently.
2026-06-02 16:27:23 -05:00
darthrubik 15098d7515 Fix issue 126: Replace manual JSON history creation in test_edit_prompt.sh
Add create_history_json() helper function to run_test.sh and use it in
test_edit_prompt.sh to avoid manual JSON string construction. This follows
CONTRIBUTING.md guidelines that prohibit JSON handling in test files.
2026-06-02 16:21:54 -05:00
darthrubik 4393a5dba2 Remove old test_edit_history_flag.sh (issue 125) 2026-06-02 16:17:20 -05:00
darthrubik aa04ad0902 Split test_edit_history_flag.sh into individual test scripts (issue 125) 2026-06-02 16:16:42 -05:00
darthrubik 0366d3db57 Issue 124: Break test_edit_history.sh into multiple test scripts
Split the monolithic test_edit_history.sh file into 21 individual
test scripts, each containing a single test. This follows the
project's testing guideline of having no more than one test per script.

Each test file contains its own helper functions (run_drharness,
get_output, reset_editor_state) and runs independently.

Updated CMakeLists.txt to register all 21 new test targets and
removed the old test_edit_history target.
2026-06-02 16:12:23 -05:00
darthrubik ac755f5083 Slop cleanup 2026-06-02 15:55:30 -05:00
darthrubik 69bd732ce7 Fix: Drop messages when lines are deleted from rebase editor (issue 120)
- Modified parse_rebase_file to skip header comments before '-- >8 --'
- Modified apply_rebase to drop messages whose indices are not mentioned
  in the parsed commands (implicit drop)
- Special case: if no commands are parsed (all lines deleted), keep all messages
- Updated test_implicit_pick to reflect new behavior
- Added test_deleting_lines_drops_messages and test_all_lines_deleted_keeps_all
- Updated test_delete_line_equals_drop to reflect new behavior
2026-06-01 19:33:46 -05:00
darthrubik eee99a53c9 Replace std::stoul with custom string_toul wrapper
Add string_toul() function in utility.cpp that wraps std::stoul and returns
std::expected<unsigned long, std::string> instead of throwing exceptions.

Replace all uses of std::stoul with string_toul():
- src/main.cpp: 2 occurrences in test flags
- src/rebase_engine.cpp: 1 occurrence in parse_rebase_file()

The wrapper handles null input, empty input, and trailing characters,
converting any exceptions from std::stoul into error strings.
2026-06-01 19:11:32 -05:00
darthrubik 938f85a017 Refactor rebase_engine.cpp to use helper function for line number error formatting
- Added format_error() helper function to centralize line number error formatting
- Replaced all 11 occurrences of inline line number formatting with calls to format_error()
- No functional changes - all 33 tests pass
2026-06-01 18:47:05 -05:00
darthrubik 211f42a87e Implement issue 119: Exit on first parse error instead of aggregating all errors
- Removed the errors vector from parse_rebase_file()
- Changed all error handling to return std::unexpected() immediately
- Removed the error aggregation code at the end of the function
- Updated test to verify first error is returned (not all errors)
2026-06-01 18:22:13 -05:00
darthrubik 5351f4f72a Refactor: Extract read_harness_state_or_exit() helper function
This refactors the duplicated code for deserializing HarnessState from a file
into a reusable helper function. The same pattern was used in two places:
1. In the edit_history block
2. In the main flow when loading existing chat files

The helper function handles:
- Opening the file
- Reading file content
- Parsing JSON
- Converting to HarnessState
- Error reporting

This reduces code duplication and makes future modifications easier.
2026-06-01 18:14:41 -05:00
darthrubik 8441775410 Move History Editing section to docs/history-editing.md
As per issue #115, moved the History Editing documentation from README.md
to a separate docs/history-editing.md file. Added a short note with a link
to the new documentation in README.md.
2026-06-01 09:32:30 -05:00
darthrubik e9e19c199f Add documentation for history editing feature
Document the -b/--edit-history flag and rebase editor format.
Covers commands (pick/edit/drop), role editing, and common workflows.
2026-06-01 08:22:26 -05:00
darthrubik 7fe1656c7d Add edge case tests for history editing feature (issue 114)
- Add test for editing a message and removing all content (empty content)
- Add test for multiple consecutive edits to the same message (changes stack)
- Add test for editing messages with special characters
- Add test for very long message content
- Add test for editing assistant messages with tool_calls
- Add test for dropping all messages (empty history result)
- Add test for unicode characters in message content
- Add test for whitespace variations in rebase file
- Add test for comments and empty lines mixed with commands
- Add test for special characters and unicode in rebase content display
- Fix apply_rebase to preserve original content when only role is changed
- Add content_edited flag to RebaseLine to track explicit edits
2026-05-30 19:49:05 -05:00
darthrubik 51c911df8d Fix issue 112: Make test flags minimal hooks
- Changed parse_json_safely to return std::expected<nlohmann::json, std::string>
- Updated all usages to handle the expected return type properly
- Simplified test flags in main.cpp to be minimal hooks without extra validation
- Removed try/catch blocks and explicit error handling from test flags
- All tests pass
2026-05-30 18:14:15 -05:00
darthrubik db3e7b7d29 Fix: Make -J flag a minimal hook
The -J flag was outputting 'valid' or 'discarded' instead of the raw
JSON result. This was test logic that should be in the test scripts.

Changed the -J flag to output the raw JSON result (pretty-printed) or
an error message. Updated the test script to use jq for JSON validation.

All other debug flags (-E, -e, -R, -P, -B, -A, -T) were already minimal
hooks and didn't need to be changed.
2026-05-30 17:42:09 -05:00
darthrubik e129fcc99a Issue #110: Remove thick debug flags from main.cpp
Per CONTRIBUTING.md, test flags should be minimal hooks that:
- Accept input, call the internal function, and output the result
- Do NOT contain test logic (assertions, validation, etc.)

Changes:
1. Added json_to<RebaseCommand> and json_to<RebaseLine> serialization
   so the -A flag can use json_to<std::vector<RebaseLine>> instead of
   60+ lines of manual JSON parsing.

2. Simplified -P flag to output JSON directly instead of formatted
   text with switch statements.

3. Added helper functions to run_test.sh for parsing rebase JSON output
   (rebase_line_count, rebase_command_at, rebase_index_at, rebase_role_at).

4. Updated test_rebase_parser.sh to use JSON output and helper functions.

The -E, -e, -R, -J, -B, and -T flags are already minimal hooks and
remain unchanged.
2026-05-30 14:38:20 -05:00
darthrubik 11073a12c8 Update CONTRIBUTING.md to clarify test flag policy
Clarify that test flags should be minimal hooks into internal
functionality, not contain test logic. All test logic (assertions,
validation) belongs in Bash test scripts in tests/.

This addresses the ambiguity mentioned in issue 109 regarding
the distinction between test hooks in main.cpp and test scripts.
2026-05-30 14:20:05 -05:00
darthrubik 643388a24d Fix issue 108: Replace manual JSON history creation with helper function
Add create_chat_file() helper to run_test.sh and update test_edit_history.sh
and test_edit_history_flag.sh to use it instead of manually creating JSON.

This follows the CONTRIBUTING.md guideline that tests should use helper
functions instead of inline JSON handling.
2026-05-30 13:55:23 -05:00
darthrubik 1cb6553d85 Fix issue #107: Move JSON parsing from test file to helper functions
- Add dr_reasoning() helper to run_test.sh
- Add dr_reasoning_content() helper to run_test.sh
- Update process_reasoning.sh to use helper functions instead of direct jq calls

This follows CONTRIBUTING.md guidelines that prohibit direct JSON
parsing in test files.
2026-05-30 13:42:39 -05:00
darthrubik 9d8ec6c308 Fix issue #106: Place explanatory text before scissors in rebase editor
The edit prompt now shows the explanatory text 'Edit message X (role: Y) below:'
BEFORE the scissors (-- >8 --) separator, not after. This ensures the
explanatory text is not part of the editable content.

Also added test_edit_prompt_noop to verify that doing nothing at the edit
prompt stage keeps history unchanged.
2026-05-30 13:36:38 -05:00
darthrubik 56873828ad Add test for no edits scenario (issue #105)
- Added new test test_no_edits_history_unchanged that verifies when the
  user does nothing with the rebase editor, the history remains unchanged
- Fixed staged input format in all tests to match actual build_rebase_content
  output (pick commands only after -- >8 -- scissors line, not before)
2026-05-30 13:24:30 -05:00
darthrubik 62dabd0f93 Add scissors line to rebase editor output
The rebase editor now includes a scissors line (-- >8 --) that separates
the comment header from the actual pick commands. This helps editors like
Vim automatically position the cursor at the relevant content and signals
to users where the editable commands begin.

Issue: #104
2026-05-30 13:17:09 -05:00
darthrubik 271ac96100 Refactor: Extract shared utilities from rebase_engine and editor
Create new utility module (include/utility.hpp, src/utility.cpp) containing
common code that was duplicated between rebase_engine.cpp and editor.cpp:

- trim() function
- random_string() function
- random_file_name() function
- Defer RAII helper class
- spawn_editor() function

Both rebase_engine.cpp and editor.cpp now include utility.hpp instead of
duplicating these functions.

All 33 unit tests pass.
2026-05-29 16:30:54 -05:00
darthrubik 8f43e668b6 Fix #102: Update README to say C++23 not C++20
- Updated project description to mention C++23
- Updated build requirements to mention C++23
2026-05-29 16:27:00 -05:00
darthrubik c46ebb665b Issue 101: Update file operation tool descriptions to indicate paths can be relative or absolute 2026-05-29 16:25:22 -05:00
darthrubik 4c3d24f2c2 Fix CONTRIBUTING.md violations from auto review
- Remove namespace alias in src/rebase_engine.cpp (use full std::filesystem:: path)
- Add dr_role() helper function to tests/run_test.sh
- Replace inline JSON parsing in tests/test_edit_history.sh with dr_role() helper
2026-05-29 16:21:38 -05:00
darthrubik b507436801 Implement issue #100: Full Interactive Edit Flow and Integration Tests
- Add edit_history_interactive() method to HistoryEditor class
  - Implements the complete history editing flow:
    1. Build rebase content from history
    2. Spawn editor for rebase commands
    3. Parse and validate rebase commands
    4. For each edit command, build edit prompt and spawn editor
    5. Apply all changes
  - Returns expected<vector<Message>, string>

- Add content field to RebaseLine struct for storing edited content
- Update apply_rebase() to use content field when present
- Wire up -b/--edit-history flag to call edit_history_interactive()
- Program exits after successful edit and saves modified history

- Add 12 integration tests for the edit history feature:
  1. Pick all messages (history unchanged)
  2. Drop a single message
  3. Edit message content
  4. Change message role
  5. Delete line equals drop (implicit pick)
  6. Edit first message (no context)
  7. Empty history rejected
  8. Invalid command error
  9. Invalid index error
  10. Invalid role error
  11. Multiple edits in one session
  12. Edit then continue conversation normally

- Register test_edit_history in CMakeLists.txt
2026-05-29 16:18:00 -05:00
darthrubik 1d799cc05d Implement build_edit_prompt for history editing (issue #99)
- Add HistoryEditor::build_edit_prompt() to format edit prompts
  with previous message context and >8 separator
- Message 0 shows '(no previous message)' instead of context
- Add -T debug flag to test build_edit_prompt
- Extend mock_editor.sh to support sequential invocations via
  counter file and staged input files
- Add unit tests for build_edit_prompt functionality
- Register new test in CMakeLists.txt
2026-05-29 15:43:47 -05:00
darthrubik e8ebb08106 Fix: Use json_to_field() for JSON field access instead of .at().get<T>()
- Replace .at("field").get<T>() with json_to_field<T>() calls
- Use .contains() and .value() for safe field access
- Follows CONTRIBUTING.md rule to always use json_to<T>() for JSON deserialization
2026-05-29 15:37:20 -05:00
darthrubik a1aa63b260 Implement RebaseEngine::apply_rebase() for issue #98
- Added apply_rebase() method to RebaseEngine class
- Pick commands preserve messages unchanged
- Drop commands remove messages from result
- Edit commands update role while preserving all other fields
- Messages not referenced by any command are preserved (implicit pick)
- Lenient mode: orphaned tool results are allowed
- Added -A test flag for testing apply_rebase
- Added unit tests in test_rebase_apply.sh
2026-05-29 15:31:49 -05:00
darthrubik 9e45bebc3d Implement issue 97: Add -b/--edit-history CLI flag with validation
Add the -b/--edit-history CLI flag to main.cpp with the following behavior:
- Flag is recognized in both short (-b) and long (--edit-history) forms
- Requires -F <chat_file> to be specified, otherwise prints error and exits
- Rejects empty chat files with error message
- Rejects chat files with no history (empty history array) with error message
- Updates help message to document the new flag

This is a thin slice that validates the CLI integration point before the
full history editing feature is built.

Includes integration tests covering all error cases.
2026-05-29 15:24:59 -05:00
darthrubik a1e0a299cd Implement HistoryEditor::build_rebase_content() for issue 96
- Added HistoryEditor class with build_rebase_content() method
- Formats messages for rebase editor display:
  - Normal messages show first line of content
  - Assistant messages with tool_calls show [tool_call: function_name]
  - Tool messages show first line of result content
  - Empty messages show (empty)
- Added header comment block explaining pick/edit/drop commands
- Added -B CLI flag for testing
- Added unit tests covering all display format scenarios
2026-05-29 15:13:25 -05:00
darthrubik adc4be09ce Implement RebaseEngine::parse_rebase_file() for issue #95
Add rebase parser that validates rebase editor format with:
- RebaseCommand enum (Pick, Edit, Drop)
- RebaseLine struct (command, index, role)
- parse_rebase_file() with strict validation and error aggregation

Validations:
- Invalid commands (not pick/edit/drop)
- Index out of range
- Duplicate indices
- Missing/malformed role brackets
- Invalid role values (must be user/assistant/tool)
- Comment lines (#) and empty lines are skipped

Tests cover all validation scenarios via -P debug flag.
2026-05-29 15:05:57 -05:00
104 changed files with 4513 additions and 253 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).
+7 -3
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
@@ -17,11 +17,11 @@ to it)
* **`Client Layer`**:
* `OllamaClient`: Handles the actual communication with an Ollama-compatible API server via HTTP (using `libcurl`).
* `DebugClient`: A CLI-friendly mock client that reads a mock JSON response from `stdin`, allowing for deterministic testing of harness logic without relying on an external LLM.
* **State Management**: Conversations are serialized to and deserialized from JSON files. This includes the full message history (`history`), the active tool registry (`tools`), and harness configuration (`config`).
* **State Management**: Conversations are serialized to and deserialized from JSON files. This includes the full message history (`history`), the active tool registry (`tools`), and harness configuration (`config` including `base_url`, `model`, `max_tool_iterations`, `verbose`, and `depth`).
## 🛠️ 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
@@ -75,6 +75,7 @@ configuration.
* `-s <name>`: Manually constrain the harness to a specific subset of skills (can be specified multiple times)
* `-i <path>`: Image file to attach to the prompt (can be specified multiple times)
* `-m <prompt>`: The user prompt to send to the agent.
* `-P <file>`: Read the prompt from a file instead of the command line.
* `-M <model>`: Override the Ollama model (e.g., `mistral`, `llama3`).
## ✍️ Editor Integration
@@ -96,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.*
+13 -7
View File
@@ -362,8 +362,10 @@ struct HarnessConfig {
size_t max_tool_iterations = 16;
bool verbose = false;
size_t depth = 0;
std::string base_url = "https://192.168.8.63/ollama";
std::string model = "qwen3.6:35b-a3b-128k";
};
DRH_DEFINE_TYPE_OPTIONAL(HarnessConfig, max_tool_iterations, verbose, depth)
DRH_DEFINE_TYPE_OPTIONAL(HarnessConfig, max_tool_iterations, verbose, depth, base_url, model)
struct FunctionCall {
std::string name = "";
@@ -503,14 +505,14 @@ struct ChatResponse {
DRH_DEFINE_TYPE_MIXED(ChatResponse, (choices), (usage))
struct ModelInfo {
std::string name;
std::string id;
};
DRH_DEFINE_TYPE_REQUIRED(ModelInfo, name)
DRH_DEFINE_TYPE_REQUIRED(ModelInfo, id)
struct ModelListResponse {
std::vector<ModelInfo> models;
std::vector<ModelInfo> data;
};
DRH_DEFINE_TYPE_REQUIRED(ModelListResponse, models)
DRH_DEFINE_TYPE_REQUIRED(ModelListResponse, data)
// Tool parameter structs
@@ -548,8 +550,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
+3 -3
View File
@@ -9,15 +9,13 @@
using http_req_cb_t = std::function<std::string(std::string const& old, std::string const& new_str)>;
struct OllamaConfig {
std::string base_url = "https://192.168.8.63/ollama";
std::string model = "qwen3.6:35b-a3b-128k";
std::string auth_token = "";
int timeout_s = 60*10;
};
class OllamaClient : public Client {
public:
explicit OllamaClient(OllamaConfig config = {});
explicit OllamaClient(std::string base_url, std::string model, OllamaConfig config = {});
~OllamaClient() override;
std::expected<Message, std::string> chat(const std::vector<Message>& messages,
@@ -26,6 +24,8 @@ public:
std::expected<std::vector<std::string>, std::string> list_models() const override;
private:
std::string base_url_;
std::string model_;
OllamaConfig config_;
nlohmann::json build_body(const std::vector<Message>& messages,
const std::vector<Tool>& tools) const;
+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;
};
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <string>
#include <expected>
#include <functional>
#include <atomic>
extern std::atomic<bool> ctrlc_request;
// 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());
+27 -127
View File
@@ -1,138 +1,38 @@
#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";
}
}
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;
}
+31 -4
View File
@@ -2,6 +2,7 @@
#include "json_types.hpp"
#include "base64_util.hpp"
#include <iostream>
#include "utility.hpp"
Harness::Harness(std::unique_ptr<Client> client, const ToolRegistry& registry,
HarnessConfig config)
@@ -80,11 +81,31 @@ std::expected<std::string, std::string> Harness::chat(const Message& user_messag
push_message(user_message);
for (size_t i = 0; i < config_.max_tool_iterations; ++i) {
if (ctrlc_request)
{
// Cancellation (Ctrl+C): return empty string to indicate the chat was
// interrupted. The user message has already been pushed to history, so
// the conversation state will be saved to the chat file. This allows
// the user to resume the conversation from this point.
//
// Note: We return an empty string (success) rather than std::unexpected
// because cancellation is not an error condition - it's a deliberate
// user action to stop processing. Using std::unexpected would prevent
// the harness state from being saved, losing the user's input.
return "";
}
Message response;
bool done = false;
size_t retries = 20;
while (!done)
{
if (ctrlc_request)
// Cancellation during API call: same semantics as above - return
// empty string to preserve conversation state in the chat file.
return "";
retries--;
if (retries == 0)
{
@@ -114,12 +135,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)
@@ -140,6 +161,12 @@ std::expected<std::string, std::string> Harness::chat(const Message& user_messag
tool_result.content = result;
push_message(tool_result);
}
if (ctrlc_request)
// Cancellation after tool execution: same semantics as above -
// return empty string to preserve conversation state in the chat file.
return "";
continue;
}
+220 -44
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"
@@ -27,6 +29,47 @@
#include <filesystem>
#include <random>
#include <iomanip>
#ifndef _WIN32
#include <signal.h>
#else
#include <windows.h>
#endif
std::atomic<bool> ctrlc_request;
void cancel_request(void)
{
if (ctrlc_request)
{
exit(1); // Already asked to quit once, lets immediately die
}
ctrlc_request = true;
}
#if defined(_WIN32)
BOOL WINAPI console_handler(DWORD ctrl_type) {
if (ctrl_type == CTRL_C_EVENT) {
cancel_request();
return TRUE;
}
return FALSE;
}
void setup_signal_handler() {
SetConsoleCtrlHandler(console_handler, TRUE);
}
#else
void signalHandler(int signal) {
if (signal == SIGINT) {
cancel_request();
}
}
void setup_signal_handler() {
signal(SIGINT, signalHandler);
}
#endif
static const std::vector<AgentProfile> profiles = {
#if !defined(_WIN32)
@@ -76,10 +119,34 @@ 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) {
setup_signal_handler();
HarnessConfig harnessConfig = HarnessConfig{
.max_tool_iterations = 1000,
.verbose = true,
};
OllamaConfig cfg;
if (const char* m = std::getenv("OLLAMA_MODEL")) cfg.model = m;
if (const char* u = std::getenv("OLLAMA_URL")) cfg.base_url = u;
if (const char* m = std::getenv("OLLAMA_MODEL")) harnessConfig.model = m;
if (const char* u = std::getenv("OLLAMA_URL")) harnessConfig.base_url = u;
if (const char* a = std::getenv("OLLAMA_AUTHORIZATION_TOKEN")) cfg.auth_token = a;
bool use_debug = false;
@@ -91,6 +158,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;
@@ -112,7 +187,7 @@ int main(int argc, char** argv) {
// Add more base tools here:
for (const auto& profile : profiles)
expect_or_exit(master_registery.register_tool(make_agent_tool(profile, master_registery, cfg.base_url)));
expect_or_exit(master_registery.register_tool(make_agent_tool(profile, master_registery, harnessConfig.base_url)));
auto consume_arg = [&](){
argc--;
@@ -129,11 +204,13 @@ int main(int argc, char** argv) {
char const* file_to_open = nullptr;
std::string auto_temp_file; // Path to auto-generated temp file (if any)
char const* prompt = nullptr;
char const* prompt_file = nullptr;
bool start_new = false;
bool list_models = false;
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;
@@ -166,10 +243,15 @@ int main(int argc, char** argv) {
consume_arg();
prompt = consume_arg();
}
else if (argv[0] == std::string("-P"))
{
consume_arg();
prompt_file = consume_arg();
}
else if (argv[0] == std::string("-M"))
{
consume_arg();
cfg.model = consume_arg();
harnessConfig.model = consume_arg();
}
else if (argv[0] == std::string("-d"))
{
@@ -212,15 +294,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"))
{
@@ -256,6 +362,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();
@@ -277,8 +388,11 @@ int main(int argc, char** argv) {
<< " -i <path> Image file to attach (can be specified multiple times)\n"
<< " -n Start a new conversation (auto-generates temp file if -F not specified)\n"
<< " -m <prompt> Prompt message (required)\n"
<< " -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"
@@ -288,8 +402,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"
@@ -304,11 +422,49 @@ 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)
return std::make_unique<DebugClient>();
return std::make_unique<OllamaClient>(cfg);
return std::make_unique<OllamaClient>(harnessConfig.base_url, harnessConfig.model, cfg);
};
// Scan for skills
@@ -364,11 +520,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;
}
@@ -386,23 +538,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;
@@ -463,11 +648,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;
}
@@ -513,33 +694,28 @@ int main(int argc, char** argv) {
}
}
Harness harness(create_client(use_debug), agent_tools, {.max_tool_iterations = 1000, .verbose = true});
Harness harness(create_client(use_debug), agent_tools, harnessConfig);
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);
harnessConfig = state_data.config;
harness = Harness(create_client(use_debug), master_registery, state_data);
}
std::cerr << "drharness v0.2 | model: " << cfg.model
<< " | " << cfg.base_url << "\n\n";
std::cerr << "drharness v0.3 | model: " << harnessConfig.model
<< " | " << harnessConfig.base_url << "\n\n";
std::string final_prompt;
if (prompt) {
if (prompt_file) {
std::ifstream pf(prompt_file);
if (!pf.is_open()) {
std::cerr << "Error: Cannot open prompt file " << prompt_file << "\n";
return 1;
}
final_prompt = std::string((std::istreambuf_iterator<char>(pf)), std::istreambuf_iterator<char>());
} else if (prompt) {
final_prompt = prompt;
} else {
final_prompt = expect_or_exit(run_editor(harness.serialize(), full_history));
+37 -11
View File
@@ -4,10 +4,12 @@
#include <string>
#include <iostream>
#include <expected>
#include "utility.hpp"
static int ollama_clients = 0;
OllamaClient::OllamaClient(OllamaConfig config) : config_(std::move(config)) {
OllamaClient::OllamaClient(std::string base_url, std::string model, OllamaConfig config)
: base_url_(std::move(base_url)), model_(std::move(model)), config_(std::move(config)) {
if (ollama_clients == 0)
curl_global_init(CURL_GLOBAL_DEFAULT);
@@ -37,6 +39,26 @@ size_t OllamaClient::write_cb(char* ptr, size_t size, size_t nmemb, void* userda
return size * nmemb;
}
static int xferinfo_cb(void* cookie,
curl_off_t dltotal,
curl_off_t dlnow,
curl_off_t ultotal,
curl_off_t ulnow)
{
(void)cookie;
(void)dltotal;
(void)dlnow;
(void)ultotal;
(void)ulnow;
if (ctrlc_request)
{
return 1; // Cancel
}
return 0; // Continue transfer
}
std::expected<std::string, std::string> OllamaClient::http_request(const std::string& full_url,
const std::string& method,
const std::string& body,
@@ -70,6 +92,8 @@ std::expected<std::string, std::string> OllamaClient::http_request(const std::st
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferinfo_cb);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_buf);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
@@ -98,7 +122,7 @@ std::expected<std::string, std::string> OllamaClient::http_request(const std::st
nlohmann::json OllamaClient::build_body(const std::vector<Message>& messages,
const std::vector<Tool>& tools) const {
nlohmann::json body = {
{"model", config_.model},
{"model", model_},
{"stream", true},
{"messages", messages},
{"stream_options", {
@@ -119,7 +143,7 @@ std::expected<Message, std::string> OllamaClient::chat(const std::vector<Message
const std::vector<Tool>& tools) const {
nlohmann::json body = build_body(messages, tools);
std::string body_str = body.dump();
std::string url = config_.base_url + "/v1/chat/completions";
std::string url = base_url_ + "/v1/chat/completions";
Message total{};
Usage usage{};
@@ -137,12 +161,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())
@@ -226,25 +251,26 @@ std::expected<Message, std::string> OllamaClient::chat(const std::vector<Message
}
std::expected<std::vector<std::string>, std::string> OllamaClient::list_models() const {
std::string url = config_.base_url + "/api/tags";
std::string url = base_url_ + "/v1/models";
auto response_result = http_request(url, "GET", "");
if (!response_result) {
return std::unexpected(response_result.error());
}
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());
}
auto resp = result.value();
std::vector<std::string> models;
for (const auto& m : resp.models) {
models.push_back(m.name);
for (const auto& m : resp.data) {
models.push_back(m.id);
}
return models;
}
+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());
}
}
+90
View File
@@ -19,11 +19,14 @@ add_dr_test(test_tool_read_file)
add_dr_test(test_tool_list_files)
add_dr_test(test_tool_grep)
add_dr_test(test_tool_shell)
add_dr_test(test_tool_shell_ctrlc)
add_dr_test(test_ctrlc_after_tool)
add_dr_test(test_tool_read_multi_modal)
# Add feature validation tests
add_dr_test(test_tool_restrictions)
add_dr_test(test_n_flag)
add_dr_test(test_prompt_file)
add_dr_test(test_load_chat)
add_dr_test(process_reasoning)
add_dr_test(test_skill_scanner)
@@ -55,6 +58,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)
@@ -75,3 +162,6 @@ add_dr_test(test_print_history_multimodal)
# Add encoding tests
add_dr_test(test_encoding)
# Add harness config tests (base_url and model in HarnessConfig)
add_dr_test(test_harness_config)
+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
+231
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
@@ -238,6 +455,20 @@ function dr_content_text() {
}
export -f dr_content_text
# Get base_url from config in chat file
function dr_config_base_url() {
local chat_file="$1"
jq -r '.config.base_url' "$chat_file"
}
export -f dr_config_base_url
# Get model from config in chat file
function dr_config_model() {
local chat_file="$1"
jq -r '.config.model' "$chat_file"
}
export -f dr_config_model
# Resolve paths relative to this script
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJECT_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd)
+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
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# test_ctrlc_after_tool.sh - Test ctrlc (SIGINT) handling between tool calls
# Create mock responses: two tool calls followed by a final response
# First tool sleeps 1 second, second tool is instant
# We send SIGINT during the first tool's execution (at 0.5s)
# The new ctrlc check after tool execution should catch it at 1s
tool_call run_dangerous_shell '{"command":"sleep 1","timeout_seconds":5}' >> mock_ctrlc_between_tools.txt
tool_call run_dangerous_shell '{"command":"echo second_tool","timeout_seconds":5}' >> mock_ctrlc_between_tools.txt
be_done 'Final response' >> mock_ctrlc_between_tools.txt
# Run drharness in background
"${DRHARNESS}" -F chat_ctrlc.json -n -m "Test ctrlc between tools" -t run_dangerous_shell -d < "mock_ctrlc_between_tools.txt" > /dev/null 2>&1 &
DRHARNESS_PID=$!
# Send SIGINT at 0.5s - during first tool's sleep
sleep 0.5
kill -SIGINT $DRHARNESS_PID 2>/dev/null || true
# Wait for drharness to complete (first tool finishes at 1s, then ctrlc check catches it)
wait $DRHARNESS_PID 2>/dev/null || true
# Verify the chat file exists and has the conversation state preserved
if [ ! -f chat_ctrlc.json ]; then
echo "FAIL: chat_ctrlc.json was not created"
exit 1
fi
# Check that we have exactly 3 history entries: user message, assistant tool call, tool result
# The second tool call should NOT have executed because ctrlc was caught after first tool
HISTORY_LENGTH=$(history_length chat_ctrlc.json)
if [ "$HISTORY_LENGTH" -ne 3 ]; then
echo "FAIL: Expected exactly 3 history entries (user, assistant, tool), got $HISTORY_LENGTH"
echo "This indicates the second tool call was not cancelled properly"
cat chat_ctrlc.json
exit 1
fi
# Verify user message is present
USER_ROLE=$(dr_role chat_ctrlc.json 0)
if [ "$USER_ROLE" != "user" ]; then
echo "FAIL: Expected user role at index 0, got $USER_ROLE"
exit 1
fi
# Verify assistant with tool call is present
ASSISTANT_ROLE=$(dr_role chat_ctrlc.json 1)
if [ "$ASSISTANT_ROLE" != "assistant" ]; then
echo "FAIL: Expected assistant role at index 1, got $ASSISTANT_ROLE"
exit 1
fi
# Verify tool result is present
TOOL_ROLE=$(dr_role chat_ctrlc.json 2)
if [ "$TOOL_ROLE" != "tool" ]; then
echo "FAIL: Expected tool role at index 2, got $TOOL_ROLE"
exit 1
fi
echo "PASS: ctrlc_after_tool"
+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"
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# test_harness_config.sh - Tests for HarnessConfig with base_url and model fields
# Test 1: Verify config is saved with base_url and model in new conversations
echo "$(be_done "Response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat.json" \
-n \
-d \
-m "test" 2>/dev/null
# Verify config contains base_url and model
BASE_URL=$(dr_config_base_url "${TEST_TEMP_DIR}/chat.json")
MODEL=$(dr_config_model "${TEST_TEMP_DIR}/chat.json")
if [ -z "$BASE_URL" ] || [ "$BASE_URL" = "null" ]; then
echo "FAIL: Expected config.base_url to be set"
exit 1
fi
if [ -z "$MODEL" ] || [ "$MODEL" = "null" ]; then
echo "FAIL: Expected config.model to be set"
exit 1
fi
# Test 2: Verify -M flag sets model in config
echo "$(be_done "Response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/chat2.json" \
-n \
-d \
-M "custom-model" \
-m "test" 2>/dev/null
MODEL2=$(dr_config_model "${TEST_TEMP_DIR}/chat2.json")
if [ "$MODEL2" != "custom-model" ]; then
echo "FAIL: Expected config.model to be 'custom-model', got '${MODEL2}'"
exit 1
fi
# Test 3: Verify backward compatibility - loading file without base_url and model
create_state "${TEST_TEMP_DIR}/old_format.json" user "hello"
echo "$(be_done "Response")" | "${DRHARNESS}" \
-F "${TEST_TEMP_DIR}/old_format.json" \
-d \
-m "test" 2>/dev/null
# Verify the saved file now has base_url and model with defaults
SAVED_BASE_URL=$(dr_config_base_url "${TEST_TEMP_DIR}/old_format.json")
SAVED_MODEL=$(dr_config_model "${TEST_TEMP_DIR}/old_format.json")
if [ -z "$SAVED_BASE_URL" ] || [ "$SAVED_BASE_URL" = "null" ]; then
echo "FAIL: Expected saved config.base_url to be set"
exit 1
fi
if [ -z "$SAVED_MODEL" ] || [ "$SAVED_MODEL" = "null" ]; then
echo "FAIL: Expected saved config.model to be set"
exit 1
fi
echo "All harness config tests passed"
+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
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# test_prompt_file.sh - Validates the -P flag reads prompt from file
# Test 1: Basic prompt file reading
echo "This is my prompt from a file" > prompt.txt
be_done 'Response received' | "${DRHARNESS}" -F chat.json -n -P prompt.txt -d
# Verify the prompt from the file was stored in the chat history
STORED_PROMPT=$(dr_result chat.json 0)
if [ "${STORED_PROMPT}" != "This is my prompt from a file" ]; then
echo "FAIL: prompt_file - prompt not read correctly"
echo "Expected: 'This is my prompt from a file'"
echo "Got: '${STORED_PROMPT}'"
exit 1
fi
# Test 2: Multi-line prompt file
cat > multiline_prompt.txt << 'PROMPT'
Line 1
Line 2
Line 3
PROMPT
be_done 'Multi-line response' | "${DRHARNESS}" -F chat2.json -n -P multiline_prompt.txt -d
# Verify multi-line prompt was stored correctly
STORED_PROMPT=$(dr_result chat2.json 0)
EXPECTED_MULTILINE="Line 1
Line 2
Line 3"
if [ "${STORED_PROMPT}" != "${EXPECTED_MULTILINE}" ]; then
echo "FAIL: prompt_file multiline - prompt not read correctly"
echo "Expected:"
echo "${EXPECTED_MULTILINE}"
echo "Got:"
echo "${STORED_PROMPT}"
exit 1
fi
# Test 3: Non-existent file should fail
if "${DRHARNESS}" -F chat3.json -n -P nonexistent.txt -d < /dev/null 2>stderr_output; then
echo "FAIL: prompt_file nonexistent - should have failed"
cat stderr_output
exit 1
fi
echo "PASS: prompt_file"
+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"
+9
View File
@@ -41,6 +41,15 @@ timeout 5s "${DRHARNESS}" -F chat3.json -n -m "This is a test" -t run_dangerous_
echo "PASS: tool_shell"
tool_call run_dangerous_shell '{"command":"printf \"test\"; sleep 10 &","timeout_seconds":1}' >> background_task_test.txt
be_done 'done' >> background_task_test.txt
# If we hit the timeout of the timeout utility then we fail
timeout 5s "${DRHARNESS}" -F background_task_test.json -n -m "This is a test" -t run_dangerous_shell -d < "background_task_test.txt"
echo "PASS: background_task_test"
seq 1 1044 > long_file
tool_call run_dangerous_shell '{"command":"cat long_file"}' >> mock_responses4.txt
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# test_tool_shell_ctrlc.sh - Test ctrlc (SIGINT) handling in the shell tool
# Test 1: Verify that SIGINT during shell execution results in "[user requested halt of program]" message
tool_call run_dangerous_shell '{"command":"sleep 100","timeout_seconds":300}' >> mock_ctrlc.txt
be_done 'Done after interrupt' >> mock_ctrlc.txt
# Run drharness in background, then send SIGINT after a short delay
"${DRHARNESS}" -F chat_ctrlc.json -n -m "Run shell with ctrlc" -t run_dangerous_shell -d < "mock_ctrlc.txt" > /dev/null 2>&1 &
DRHARNESS_PID=$!
# Wait a moment for the shell command to start
sleep 1
# Send SIGINT (Ctrl+C) to the drharness process
kill -SIGINT $DRHARNESS_PID
# Wait for drharness to complete
wait $DRHARNESS_PID
# Check that the tool result contains the ctrlc message
TOOL_RESULT=$(dr_result chat_ctrlc.json 2)
if ! echo "${TOOL_RESULT}" | grep -q "\[user requested halt of program\]"; then
echo "FAIL: Expected '[user requested halt of program]' in tool output"
echo "Got: ${TOOL_RESULT}"
exit 1
fi
echo "PASS: tool_shell_ctrlc"
+5 -5
View File
@@ -48,14 +48,14 @@ Tool make_agent_tool(const AgentProfile& profile,
}
ToolRegistry sub_registry = std::move(sub_reg_result.value());
Harness harness(std::make_unique<OllamaClient>(OllamaConfig{
.base_url = base_url,
.model = profile.model,
Harness harness(std::make_unique<OllamaClient>(base_url, profile.model, OllamaConfig{
.timeout_s = 120
}), sub_registry, {
}), sub_registry, HarnessConfig{
.max_tool_iterations = 16,
.verbose = true,
.depth = depth
.depth = depth,
.base_url = base_url,
.model = profile.model
});
auto result = harness.chat(task);
+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"},

Some files were not shown because too many files have changed in this diff Show More