From 9d9cf163ecf3739e1099acb268947d81baac3d3e Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 16 Dec 2025 15:00:19 +0100 Subject: [PATCH] test for ai merge AI --- auto-claude/workspace.py | 12 + tests/test_merge.py | 528 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+) diff --git a/auto-claude/workspace.py b/auto-claude/workspace.py index 6e5055b4..2c758f4a 100644 --- a/auto-claude/workspace.py +++ b/auto-claude/workspace.py @@ -1990,12 +1990,17 @@ def _merge_file_with_ai( ) try: + debug(MODULE, "Calling AI for conflict-only merge") response = resolver.ai_call_fn( "You are an expert code merge assistant. Resolve ONLY the specific conflicts shown. Output the resolved code for each conflict.", prompt, ) if response: + debug(MODULE, "Conflict-only AI response received", + response_length=len(response), + preview=response[:200] if len(response) > 200 else response) + # Extract resolutions for each conflict resolutions = extract_conflict_resolutions(response, conflicts, language) @@ -2022,6 +2027,13 @@ def _merge_file_with_ai( print(muted(f" Conflict-only merge had syntax issues, trying full-file merge...")) else: return merged + else: + debug_warning(MODULE, "No resolutions extracted from AI response", + response_preview=response[:500] if len(response) > 500 else response) + print(muted(f" Could not extract conflict resolutions from AI response, trying full-file merge...")) + else: + debug_warning(MODULE, "Conflict-only AI returned empty response") + print(muted(f" AI returned empty response, trying full-file merge...")) except Exception as e: debug_warning(MODULE, f"Conflict-only merge failed: {e}, falling back to full-file") diff --git a/tests/test_merge.py b/tests/test_merge.py index 29c3f6e9..c39d3311 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -1298,3 +1298,531 @@ class TestMergeIntegration: assert restored["tasks_merged"] == ["task-001"] assert restored["success"] is True + + +# ============================================================================= +# CONFLICT-ONLY MERGE TESTS +# ============================================================================= + +from merge.prompts import ( + parse_conflict_markers, + extract_conflict_resolutions, + reassemble_with_resolutions, + build_conflict_only_prompt, +) + + +class TestConflictMarkerParsing: + """Tests for git conflict marker parsing.""" + + def test_parse_single_conflict(self): + """Parse a file with a single conflict marker.""" + content = '''def hello(): + print("Hello") + +<<<<<<< HEAD +def foo(): + return "main version" +======= +def foo(): + return "feature version" +>>>>>>> feature-branch + +def goodbye(): + print("Goodbye") +''' + conflicts, _ = parse_conflict_markers(content) + + assert len(conflicts) == 1 + assert conflicts[0]['id'] == 'CONFLICT_1' + assert 'main version' in conflicts[0]['main_lines'] + assert 'feature version' in conflicts[0]['worktree_lines'] + + def test_parse_multiple_conflicts(self): + """Parse a file with multiple conflict markers.""" + content = '''import os +<<<<<<< HEAD +import logging +======= +import json +>>>>>>> feature + +def main(): + pass + +<<<<<<< HEAD +def helper1(): + return 1 +======= +def helper2(): + return 2 +>>>>>>> feature +''' + conflicts, _ = parse_conflict_markers(content) + + assert len(conflicts) == 2 + assert conflicts[0]['id'] == 'CONFLICT_1' + assert conflicts[1]['id'] == 'CONFLICT_2' + assert 'logging' in conflicts[0]['main_lines'] + assert 'json' in conflicts[0]['worktree_lines'] + assert 'helper1' in conflicts[1]['main_lines'] + assert 'helper2' in conflicts[1]['worktree_lines'] + + def test_parse_no_conflicts(self): + """Parse a file with no conflicts returns empty list.""" + content = '''def hello(): + print("Hello") + +def goodbye(): + print("Goodbye") +''' + conflicts, _ = parse_conflict_markers(content) + + assert len(conflicts) == 0 + + def test_parse_conflict_with_context(self): + """Conflict includes surrounding context.""" + content = '''line 1 +line 2 +line 3 +<<<<<<< HEAD +conflict main +======= +conflict feature +>>>>>>> feature +line after 1 +line after 2 +''' + conflicts, _ = parse_conflict_markers(content) + + assert len(conflicts) == 1 + # Should have context before + assert 'line 3' in conflicts[0]['context_before'] + # Should have context after + assert 'line after 1' in conflicts[0]['context_after'] + + def test_parse_multiline_conflict(self): + """Parse conflict with multiple lines on each side.""" + content = '''start +<<<<<<< HEAD +line 1 from main +line 2 from main +line 3 from main +======= +line 1 from feature +line 2 from feature +>>>>>>> feature +end +''' + conflicts, _ = parse_conflict_markers(content) + + assert len(conflicts) == 1 + assert 'line 1 from main' in conflicts[0]['main_lines'] + assert 'line 3 from main' in conflicts[0]['main_lines'] + assert 'line 1 from feature' in conflicts[0]['worktree_lines'] + assert 'line 2 from feature' in conflicts[0]['worktree_lines'] + + +class TestConflictResolutionExtraction: + """Tests for extracting resolved code from AI responses.""" + + def test_extract_single_resolution(self): + """Extract resolution for a single conflict.""" + response = '''Here's the resolved code: + +--- CONFLICT_1 RESOLVED --- +```python +def foo(): + return "merged version" +``` + +This combines both changes. +''' + conflicts = [{'id': 'CONFLICT_1'}] + resolutions = extract_conflict_resolutions(response, conflicts, 'python') + + assert 'CONFLICT_1' in resolutions + assert 'merged version' in resolutions['CONFLICT_1'] + + def test_extract_multiple_resolutions(self): + """Extract resolutions for multiple conflicts.""" + response = '''Resolving all conflicts: + +--- CONFLICT_1 RESOLVED --- +```python +import logging +import json +``` + +--- CONFLICT_2 RESOLVED --- +```python +def helper(): + return "combined" +``` + +Done. +''' + conflicts = [{'id': 'CONFLICT_1'}, {'id': 'CONFLICT_2'}] + resolutions = extract_conflict_resolutions(response, conflicts, 'python') + + assert 'CONFLICT_1' in resolutions + assert 'CONFLICT_2' in resolutions + assert 'logging' in resolutions['CONFLICT_1'] + assert 'json' in resolutions['CONFLICT_1'] + assert 'helper' in resolutions['CONFLICT_2'] + + def test_extract_fallback_single_code_block(self): + """Fallback: extract single code block for single conflict.""" + response = '''Here's the merged code: + +```python +def foo(): + return "merged" +``` +''' + conflicts = [{'id': 'CONFLICT_1'}] + resolutions = extract_conflict_resolutions(response, conflicts, 'python') + + assert 'CONFLICT_1' in resolutions + assert 'merged' in resolutions['CONFLICT_1'] + + def test_extract_case_insensitive(self): + """Resolution markers are case-insensitive.""" + response = '''--- conflict_1 resolved --- +```python +result = "case insensitive" +``` +''' + conflicts = [{'id': 'CONFLICT_1'}] + resolutions = extract_conflict_resolutions(response, conflicts, 'python') + + assert 'CONFLICT_1' in resolutions + + def test_extract_typescript_resolution(self): + """Extract TypeScript resolutions correctly.""" + response = '''--- CONFLICT_1 RESOLVED --- +```typescript +export const config = { + merged: true +}; +``` +''' + conflicts = [{'id': 'CONFLICT_1'}] + resolutions = extract_conflict_resolutions(response, conflicts, 'typescript') + + assert 'CONFLICT_1' in resolutions + assert 'merged: true' in resolutions['CONFLICT_1'] + + def test_extract_no_resolutions(self): + """No resolutions when AI response doesn't match format.""" + response = '''I couldn't resolve these conflicts automatically. +Please review manually. +''' + conflicts = [{'id': 'CONFLICT_1'}] + resolutions = extract_conflict_resolutions(response, conflicts, 'python') + + assert len(resolutions) == 0 + + +class TestReassemblyWithResolutions: + """Tests for reassembling files with resolved conflicts.""" + + def test_reassemble_single_conflict(self): + """Reassemble file with single resolved conflict.""" + original = '''before +<<<<<<< HEAD +main version +======= +feature version +>>>>>>> feature +after +''' + conflicts = [{ + 'id': 'CONFLICT_1', + 'start': original.index('<<<<<<<'), + 'end': original.index('>>>>>>> feature') + len('>>>>>>> feature\n'), + 'main_lines': 'main version', + 'worktree_lines': 'feature version', + }] + resolutions = {'CONFLICT_1': 'merged version'} + + result = reassemble_with_resolutions(original, conflicts, resolutions) + + assert '<<<<<<' not in result + assert '=======' not in result + assert '>>>>>>>' not in result + assert 'merged version' in result + assert 'before' in result + assert 'after' in result + + def test_reassemble_multiple_conflicts(self): + """Reassemble file with multiple resolved conflicts.""" + original = '''start +<<<<<<< HEAD +conflict1 main +======= +conflict1 feature +>>>>>>> feature +middle +<<<<<<< HEAD +conflict2 main +======= +conflict2 feature +>>>>>>> feature +end +''' + # Calculate positions + c1_start = original.index('<<<<<<<') + c1_end_marker = '>>>>>>> feature\n' + c1_end = original.index(c1_end_marker) + len(c1_end_marker) + + remaining = original[c1_end:] + c2_start = c1_end + remaining.index('<<<<<<<') + c2_end = c2_start + remaining[remaining.index('<<<<<<<'):].index(c1_end_marker) + len(c1_end_marker) + + conflicts = [ + { + 'id': 'CONFLICT_1', + 'start': c1_start, + 'end': c1_end, + 'main_lines': 'conflict1 main', + 'worktree_lines': 'conflict1 feature', + }, + { + 'id': 'CONFLICT_2', + 'start': c2_start, + 'end': c2_end, + 'main_lines': 'conflict2 main', + 'worktree_lines': 'conflict2 feature', + }, + ] + resolutions = { + 'CONFLICT_1': 'resolved1', + 'CONFLICT_2': 'resolved2', + } + + result = reassemble_with_resolutions(original, conflicts, resolutions) + + assert '<<<<<<' not in result + assert 'resolved1' in result + assert 'resolved2' in result + assert 'start' in result + assert 'middle' in result + assert 'end' in result + + def test_reassemble_fallback_without_resolution(self): + """Fallback to worktree version when no resolution provided.""" + original = '''before +<<<<<<< HEAD +main version +======= +feature version +>>>>>>> feature +after +''' + conflicts = [{ + 'id': 'CONFLICT_1', + 'start': original.index('<<<<<<<'), + 'end': original.index('>>>>>>> feature') + len('>>>>>>> feature\n'), + 'main_lines': 'main version', + 'worktree_lines': 'feature version', + }] + resolutions = {} # No resolution provided + + result = reassemble_with_resolutions(original, conflicts, resolutions) + + # Should fall back to worktree version + assert 'feature version' in result + assert '<<<<<<' not in result + + +class TestBuildConflictOnlyPrompt: + """Tests for building conflict-only prompts.""" + + def test_build_prompt_single_conflict(self): + """Build prompt for single conflict.""" + conflicts = [{ + 'id': 'CONFLICT_1', + 'main_lines': 'def foo():\n return "main"', + 'worktree_lines': 'def foo():\n return "feature"', + 'context_before': 'import os', + 'context_after': 'def bar():', + }] + + prompt = build_conflict_only_prompt( + file_path='test.py', + conflicts=conflicts, + spec_name='feature-branch', + language='python', + ) + + assert 'test.py' in prompt + assert 'CONFLICT_1' in prompt + assert 'MAIN BRANCH VERSION' in prompt + assert 'FEATURE BRANCH VERSION' in prompt + assert 'return "main"' in prompt + assert 'return "feature"' in prompt + assert 'CONTEXT BEFORE' in prompt + assert 'import os' in prompt + + def test_build_prompt_multiple_conflicts(self): + """Build prompt for multiple conflicts.""" + conflicts = [ + { + 'id': 'CONFLICT_1', + 'main_lines': 'import logging', + 'worktree_lines': 'import json', + 'context_before': '', + 'context_after': '', + }, + { + 'id': 'CONFLICT_2', + 'main_lines': 'helper1()', + 'worktree_lines': 'helper2()', + 'context_before': '', + 'context_after': '', + }, + ] + + prompt = build_conflict_only_prompt( + file_path='test.py', + conflicts=conflicts, + spec_name='feature', + language='python', + ) + + assert 'CONFLICT_1' in prompt + assert 'CONFLICT_2' in prompt + assert '2 conflict(s)' in prompt + + def test_build_prompt_includes_task_intent(self): + """Prompt includes task intent when provided.""" + conflicts = [{ + 'id': 'CONFLICT_1', + 'main_lines': 'old code', + 'worktree_lines': 'new code', + 'context_before': '', + 'context_after': '', + }] + task_intent = { + 'title': 'Add user authentication', + 'description': 'Implement OAuth login flow', + } + + prompt = build_conflict_only_prompt( + file_path='auth.py', + conflicts=conflicts, + spec_name='auth-feature', + language='python', + task_intent=task_intent, + ) + + assert 'Add user authentication' in prompt + assert 'OAuth login flow' in prompt + + def test_build_prompt_typescript(self): + """Build prompt for TypeScript file.""" + conflicts = [{ + 'id': 'CONFLICT_1', + 'main_lines': 'const x: number = 1;', + 'worktree_lines': 'const x: string = "1";', + 'context_before': '', + 'context_after': '', + }] + + prompt = build_conflict_only_prompt( + file_path='index.ts', + conflicts=conflicts, + spec_name='feature', + language='typescript', + ) + + assert 'typescript' in prompt.lower() + assert '```typescript' in prompt + + +class TestConflictOnlyMergeIntegration: + """Integration tests for the full conflict-only merge flow.""" + + def test_full_flow_single_conflict(self): + """Full flow: parse -> extract resolution -> reassemble.""" + # Simulated file with conflict + file_with_conflict = '''import os + +<<<<<<< HEAD +def foo(): + return "from main" +======= +def foo(): + return "from feature" +>>>>>>> feature + +def bar(): + pass +''' + # Step 1: Parse conflicts + conflicts, _ = parse_conflict_markers(file_with_conflict) + assert len(conflicts) == 1 + + # Step 2: Simulate AI response + ai_response = '''--- CONFLICT_1 RESOLVED --- +```python +def foo(): + return "merged: main + feature" +``` +''' + # Step 3: Extract resolutions + resolutions = extract_conflict_resolutions(ai_response, conflicts, 'python') + assert 'CONFLICT_1' in resolutions + + # Step 4: Reassemble + result = reassemble_with_resolutions(file_with_conflict, conflicts, resolutions) + + # Verify result + assert '<<<<<<' not in result + assert 'merged: main + feature' in result + assert 'import os' in result + assert 'def bar():' in result + + def test_full_flow_preserves_structure(self): + """Full flow preserves file structure outside conflicts.""" + file_with_conflict = '''# Header comment +"""Module docstring.""" + +import os +import sys + +<<<<<<< HEAD +CONFIG = {"version": "1.0"} +======= +CONFIG = {"version": "2.0", "new_key": "value"} +>>>>>>> feature + +def main(): + """Main function.""" + print(CONFIG) + +if __name__ == "__main__": + main() +''' + conflicts, _ = parse_conflict_markers(file_with_conflict) + + ai_response = '''--- CONFLICT_1 RESOLVED --- +```python +CONFIG = {"version": "2.0", "new_key": "value", "merged": True} +``` +''' + resolutions = extract_conflict_resolutions(ai_response, conflicts, 'python') + result = reassemble_with_resolutions(file_with_conflict, conflicts, resolutions) + + # All original structure preserved + assert '# Header comment' in result + assert '"""Module docstring."""' in result + assert 'import os' in result + assert 'import sys' in result + assert 'def main():' in result + assert 'if __name__ == "__main__":' in result + # Resolution applied + assert '"merged": True' in result + # No conflict markers + assert '<<<<<<' not in result