kolega-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,141 @@
1
+ from unittest.mock import AsyncMock, Mock, patch
2
+
3
+ import pytest
4
+ import uuid
5
+
6
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
7
+ from kolega_code.agent.tool_backend.replace_lines_tool import ReplaceLinesTool
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_connection_manager():
12
+ return AsyncMock()
13
+
14
+
15
+ @pytest.fixture
16
+ def project_path(tmp_path):
17
+ return tmp_path
18
+
19
+
20
+ @pytest.fixture
21
+ def agent_config():
22
+ return AgentConfig(
23
+ anthropic_api_key="test_key",
24
+ openai_api_key="test-key",
25
+ long_context_config=ModelConfig(
26
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()
27
+ ),
28
+ fast_config=ModelConfig(provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()),
29
+ thinking_config=ModelConfig(
30
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig(), thinking_tokens=1024
31
+ ),
32
+ )
33
+
34
+
35
+ @pytest.fixture
36
+ def mock_base_agent():
37
+ mock = Mock()
38
+ mock.agent_name = "test_agent"
39
+ return mock
40
+
41
+
42
+ @pytest.fixture
43
+ def replace_lines_tool(project_path, mock_connection_manager, agent_config, mock_base_agent):
44
+ return ReplaceLinesTool(
45
+ project_path, "test_workspace", str(uuid.uuid4()), mock_connection_manager, agent_config, mock_base_agent
46
+ )
47
+
48
+
49
+ @pytest.fixture
50
+ def sample_file(project_path):
51
+ file_path = project_path / "test.txt"
52
+ file_path.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5")
53
+ return file_path
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ class TestReplaceLinesTool:
58
+ async def test_replace_lines_success(self, replace_lines_tool, sample_file):
59
+ new_content = "New Line 2\nNew Line 3"
60
+ result = await replace_lines_tool.replace_lines("test.txt", 2, 3, new_content)
61
+
62
+ expected_result = "Replaced lines 2-3 in file test.txt\n\nReplaced:\n```\nLine 2\nLine 3\n```\nwith:\n```\nNew Line 2\nNew Line 3\n```"
63
+ assert result == expected_result
64
+ assert sample_file.read_text() == "Line 1\nNew Line 2\nNew Line 3\nLine 4\nLine 5"
65
+
66
+ async def test_replace_lines_single_line(self, replace_lines_tool, sample_file):
67
+ new_content = "New Line 1"
68
+ result = await replace_lines_tool.replace_lines("test.txt", 1, 1, new_content)
69
+
70
+ expected_result = (
71
+ "Replaced lines 1-1 in file test.txt\n\nReplaced:\n```\nLine 1\n```\nwith:\n```\nNew Line 1\n```"
72
+ )
73
+ assert result == expected_result
74
+ assert sample_file.read_text() == "New Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
75
+
76
+ async def test_replace_lines_at_end(self, replace_lines_tool, sample_file):
77
+ new_content = "New Line 5"
78
+ result = await replace_lines_tool.replace_lines("test.txt", 5, 5, new_content)
79
+
80
+ # Use direct string comparison to avoid newline issues
81
+ # Note: No trailing newline for the last line of the file
82
+ expected_result = "Replaced lines 5-5 in file test.txt\n\nReplaced:\n```\nLine 5```\nwith:\n```\nNew Line 5```"
83
+ assert result == expected_result
84
+ assert sample_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nNew Line 5"
85
+
86
+ async def test_replace_lines_file_not_found(self, replace_lines_tool):
87
+ with pytest.raises(FileNotFoundError) as exc_info:
88
+ await replace_lines_tool.replace_lines("nonexistent.txt", 1, 1, "Content")
89
+ assert str(exc_info.value) == "File not found: nonexistent.txt"
90
+
91
+ async def test_replace_lines_invalid_start_line(self, replace_lines_tool, sample_file):
92
+ with pytest.raises(ValueError) as exc_info:
93
+ await replace_lines_tool.replace_lines("test.txt", 0, 1, "Content")
94
+ assert str(exc_info.value) == "Invalid start_line: 0. Line numbers must be 1-indexed."
95
+
96
+ async def test_replace_lines_invalid_end_line(self, replace_lines_tool, sample_file):
97
+ with pytest.raises(ValueError) as exc_info:
98
+ await replace_lines_tool.replace_lines("test.txt", 3, 2, "Content")
99
+ assert (
100
+ str(exc_info.value) == "Invalid line range: end_line (2) must be greater than or equal to start_line (3)."
101
+ )
102
+
103
+ async def test_replace_lines_start_line_exceeds_file_length(self, replace_lines_tool, sample_file):
104
+ with pytest.raises(ValueError) as exc_info:
105
+ await replace_lines_tool.replace_lines("test.txt", 6, 6, "Content")
106
+ assert str(exc_info.value) == "Invalid start_line: 6. File only has 5 lines."
107
+
108
+ @patch("pathlib.Path.write_text")
109
+ async def test_replace_lines_permission_error(self, mock_write_text, replace_lines_tool, sample_file):
110
+ mock_write_text.side_effect = PermissionError("Permission denied")
111
+
112
+ with pytest.raises(PermissionError) as exc_info:
113
+ await replace_lines_tool.replace_lines("test.txt", 1, 1, "Content")
114
+ assert str(exc_info.value) == "Permission denied"
115
+
116
+ async def test_replace_lines_with_empty_content(self, replace_lines_tool, sample_file):
117
+ result = await replace_lines_tool.replace_lines("test.txt", 2, 2, "")
118
+
119
+ expected_result = "Replaced lines 2-2 in file test.txt\n\nReplaced:\n```\nLine 2\n```\nwith:\n```\n\n```"
120
+ assert result == expected_result
121
+ assert sample_file.read_text() == "Line 1\n\nLine 3\nLine 4\nLine 5"
122
+
123
+ async def test_replace_lines_preserve_newline(self, replace_lines_tool, project_path):
124
+ # Create a file with a trailing newline
125
+ file_path = project_path / "newline.txt"
126
+ file_path.write_text("Line 1\nLine 2\n")
127
+
128
+ new_content = "New Line 1\nNew Line 2"
129
+ result = await replace_lines_tool.replace_lines("newline.txt", 1, 2, new_content)
130
+
131
+ expected_result = "Replaced lines 1-2 in file newline.txt\n\nReplaced:\n```\nLine 1\nLine 2\n```\nwith:\n```\nNew Line 1\nNew Line 2\n```"
132
+ assert result == expected_result
133
+ assert file_path.read_text() == "New Line 1\nNew Line 2\n"
134
+
135
+ async def test_replace_lines_with_multiple_newlines(self, replace_lines_tool, sample_file):
136
+ new_content = "New Line 2\n\nNew Line 4"
137
+ result = await replace_lines_tool.replace_lines("test.txt", 2, 3, new_content)
138
+
139
+ expected_result = "Replaced lines 2-3 in file test.txt\n\nReplaced:\n```\nLine 2\nLine 3\n```\nwith:\n```\nNew Line 2\n\nNew Line 4\n```"
140
+ assert result == expected_result
141
+ assert sample_file.read_text() == "Line 1\nNew Line 2\n\nNew Line 4\nLine 4\nLine 5"
@@ -0,0 +1,174 @@
1
+ from unittest.mock import AsyncMock, Mock, patch
2
+
3
+ import pytest
4
+ import uuid
5
+
6
+ from kolega_code.agent.tool_backend.search_and_replace_tool import SearchAndReplaceTool
7
+
8
+
9
+ @pytest.fixture
10
+ def project_path(tmp_path):
11
+ return tmp_path
12
+
13
+
14
+ @pytest.fixture
15
+ def sample_file(project_path):
16
+ file_path = project_path / "test.txt"
17
+ file_path.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5")
18
+ return file_path
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_connection_manager():
23
+ return AsyncMock()
24
+
25
+
26
+ @pytest.fixture
27
+ def agent_config():
28
+ return {}
29
+
30
+
31
+ @pytest.fixture
32
+ def mock_base_agent():
33
+ mock = Mock()
34
+ mock.agent_name = "test_agent"
35
+ return mock
36
+
37
+
38
+ @pytest.fixture
39
+ def search_and_replace_tool(project_path, mock_connection_manager, agent_config, mock_base_agent):
40
+ return SearchAndReplaceTool(
41
+ project_path, "test_workspace", str(uuid.uuid4()), mock_connection_manager, agent_config, mock_base_agent
42
+ )
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ class TestSearchAndReplaceTool:
47
+ async def test_search_and_replace_success(self, search_and_replace_tool, sample_file):
48
+ blocks = """<<<<<<< SEARCH
49
+ Line 2
50
+ Line 3
51
+ =======
52
+ New Line 2
53
+ New Line 3
54
+ >>>>>>> REPLACE"""
55
+
56
+ result = await search_and_replace_tool.search_and_replace("test.txt", blocks)
57
+
58
+ expected_result = (
59
+ "Search and replace in file test.txt\n\n"
60
+ "Replaced:\n"
61
+ "```\nLine 2\nLine 3\n```\n"
62
+ "with:\n"
63
+ "```\nNew Line 2\nNew Line 3\n```"
64
+ )
65
+ assert result == expected_result
66
+ assert sample_file.read_text() == "Line 1\nNew Line 2\nNew Line 3\nLine 4\nLine 5"
67
+
68
+ async def test_search_and_replace_no_match(self, search_and_replace_tool, sample_file):
69
+ blocks = """<<<<<<< SEARCH
70
+ Line X
71
+ Line Y
72
+ =======
73
+ New Line X
74
+ New Line Y
75
+ >>>>>>> REPLACE"""
76
+
77
+ with pytest.raises(ValueError) as exc_info:
78
+ await search_and_replace_tool.search_and_replace("test.txt", blocks)
79
+
80
+ assert "does not match any content in the file" in str(exc_info.value)
81
+
82
+ async def test_search_and_replace_multiple_matches(self, search_and_replace_tool, project_path):
83
+ file_path = project_path / "duplicate.txt"
84
+ file_path.write_text("Line A\nLine B\nLine C\nLine A\nLine B\nLine C")
85
+
86
+ blocks = """<<<<<<< SEARCH
87
+ Line A
88
+ Line B
89
+ =======
90
+ New Line A
91
+ New Line B
92
+ >>>>>>> REPLACE"""
93
+
94
+ with pytest.raises(ValueError) as exc_info:
95
+ await search_and_replace_tool.search_and_replace("duplicate.txt", blocks)
96
+
97
+ assert "matched 2 occurrences" in str(exc_info.value)
98
+
99
+ async def test_search_and_replace_no_blocks(self, search_and_replace_tool, sample_file):
100
+ with pytest.raises(ValueError) as exc_info:
101
+ await search_and_replace_tool.search_and_replace("test.txt", "Invalid blocks format")
102
+
103
+ assert "No valid search and replace blocks found" in str(exc_info.value)
104
+
105
+ async def test_search_and_replace_multiple_blocks(self, search_and_replace_tool, sample_file):
106
+ blocks = """<<<<<<< SEARCH
107
+ Line 1
108
+ =======
109
+ New Line 1
110
+ >>>>>>> REPLACE
111
+ <<<<<<< SEARCH
112
+ Line 2
113
+ =======
114
+ New Line 2
115
+ >>>>>>> REPLACE"""
116
+
117
+ with pytest.raises(ValueError) as exc_info:
118
+ await search_and_replace_tool.search_and_replace("test.txt", blocks)
119
+
120
+ assert "Multiple search and replace blocks provided" in str(exc_info.value)
121
+
122
+ async def test_search_and_replace_empty_search(self, search_and_replace_tool, sample_file):
123
+ blocks = """<<<<<<< SEARCH
124
+
125
+ =======
126
+ New Content
127
+ >>>>>>> REPLACE"""
128
+
129
+ with pytest.raises(ValueError) as exc_info:
130
+ await search_and_replace_tool.search_and_replace("test.txt", blocks)
131
+
132
+ assert "Empty search block" in str(exc_info.value)
133
+
134
+ async def test_search_and_replace_file_not_found(self, search_and_replace_tool):
135
+ blocks = """<<<<<<< SEARCH
136
+ Line 1
137
+ =======
138
+ New Line 1
139
+ >>>>>>> REPLACE"""
140
+
141
+ with pytest.raises(FileNotFoundError) as exc_info:
142
+ await search_and_replace_tool.search_and_replace("nonexistent.txt", blocks)
143
+
144
+ assert "File not found: nonexistent.txt" in str(exc_info.value)
145
+
146
+ async def test_search_and_replace_no_changes(self, search_and_replace_tool, sample_file):
147
+ blocks = """<<<<<<< SEARCH
148
+ Line 2
149
+ Line 3
150
+ =======
151
+ Line 2
152
+ Line 3
153
+ >>>>>>> REPLACE"""
154
+
155
+ result = await search_and_replace_tool.search_and_replace("test.txt", blocks)
156
+
157
+ assert "No changes made" in result
158
+ assert sample_file.read_text() == "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
159
+
160
+ async def test_search_and_replace_permission_error(self, search_and_replace_tool, sample_file):
161
+ blocks = """<<<<<<< SEARCH
162
+ Line 2
163
+ Line 3
164
+ =======
165
+ New Line 2
166
+ New Line 3
167
+ >>>>>>> REPLACE"""
168
+
169
+ # Create a test that we can't write to
170
+ with patch("pathlib.Path.open", side_effect=PermissionError("Permission denied when writing to file")):
171
+ with pytest.raises(PermissionError) as exc_info:
172
+ await search_and_replace_tool.search_and_replace("test.txt", blocks)
173
+
174
+ assert "Permission denied" in str(exc_info.value)
@@ -0,0 +1,228 @@
1
+ from unittest.mock import AsyncMock, Mock
2
+
3
+ import pytest
4
+ import uuid
5
+
6
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
7
+ from kolega_code.agent.tool_backend.search_codebase_tool import SearchCodebaseTool
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_connection_manager():
12
+ return AsyncMock()
13
+
14
+
15
+ @pytest.fixture
16
+ def project_path(tmp_path):
17
+ return tmp_path
18
+
19
+
20
+ @pytest.fixture
21
+ def agent_config():
22
+ return AgentConfig(
23
+ anthropic_api_key="test_key",
24
+ openai_api_key="test-key",
25
+ long_context_config=ModelConfig(
26
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()
27
+ ),
28
+ fast_config=ModelConfig(provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()),
29
+ thinking_config=ModelConfig(
30
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig(), thinking_tokens=1024
31
+ ),
32
+ )
33
+
34
+
35
+ @pytest.fixture
36
+ def mock_base_agent():
37
+ mock = Mock()
38
+ mock.agent_name = "test_agent"
39
+ return mock
40
+
41
+
42
+ @pytest.fixture
43
+ def search_codebase_tool(project_path, mock_connection_manager, agent_config, mock_base_agent):
44
+ return SearchCodebaseTool(
45
+ project_path, "test_workspace", str(uuid.uuid4()), mock_connection_manager, agent_config, mock_base_agent
46
+ )
47
+
48
+
49
+ @pytest.fixture
50
+ def sample_files(project_path):
51
+ # Create a directory structure with various files
52
+ (project_path / "src").mkdir()
53
+ (project_path / "tests").mkdir()
54
+
55
+ # Create Python files
56
+ (project_path / "src" / "main.py").write_text('def main():\n print("Hello World")\n')
57
+ (project_path / "src" / "utils.py").write_text('def helper():\n return "Helper function"\n')
58
+ (project_path / "tests" / "test_main.py").write_text("def test_main():\n assert True\n")
59
+
60
+ # Create a binary file
61
+ (project_path / "src" / "data.bin").write_bytes(b"\x00\x01\x02\x03")
62
+
63
+ # Create a file in excluded directory
64
+ (project_path / ".git").mkdir()
65
+ (project_path / ".git" / "config").write_text("git config content")
66
+
67
+ # Create a large file
68
+ (project_path / "src" / "large.txt").write_text("x" * (11 * 1024 * 1024))
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ class TestSearchCodebaseTool:
73
+ async def test_search_codebase_basic(self, search_codebase_tool, sample_files):
74
+ result = await search_codebase_tool.search_codebase("print")
75
+
76
+ assert "Search Results for 'print'" in result
77
+ assert "src/main.py" in result
78
+ assert 'Line 2: print("Hello World")' in result
79
+
80
+ async def test_search_codebase_case_insensitive(self, search_codebase_tool, sample_files):
81
+ result = await search_codebase_tool.search_codebase("PRINT")
82
+
83
+ assert "Search Results for 'PRINT'" in result
84
+ assert "src/main.py" in result
85
+ assert 'Line 2: print("Hello World")' in result
86
+
87
+ async def test_search_codebase_case_sensitive(self, search_codebase_tool, sample_files):
88
+ result = await search_codebase_tool.search_codebase("PRINT", case_sensitive=True)
89
+
90
+ assert "No matches found for pattern 'PRINT'" in result
91
+
92
+ async def test_search_codebase_file_pattern(self, search_codebase_tool, sample_files):
93
+ result = await search_codebase_tool.search_codebase("def", file_pattern="*.py")
94
+
95
+ assert "Search Results for 'def'" in result
96
+ assert "src/main.py" in result
97
+ assert "src/utils.py" in result
98
+ assert "tests/test_main.py" in result
99
+
100
+ async def test_search_codebase_no_matches(self, search_codebase_tool, sample_files):
101
+ result = await search_codebase_tool.search_codebase("nonexistent_pattern")
102
+
103
+ assert "No matches found for pattern 'nonexistent_pattern'" in result
104
+
105
+ async def test_search_codebase_invalid_regex(self, search_codebase_tool, sample_files):
106
+ result = await search_codebase_tool.search_codebase("[", literal=False)
107
+
108
+ assert "Error: Invalid regular expression" in result
109
+
110
+ async def test_search_codebase_excludes_binary_files(self, search_codebase_tool, sample_files):
111
+ result = await search_codebase_tool.search_codebase("\x00")
112
+
113
+ assert "No matches found for pattern" in result
114
+
115
+ async def test_search_codebase_excludes_git_files(self, search_codebase_tool, sample_files):
116
+ result = await search_codebase_tool.search_codebase("git config")
117
+
118
+ assert "No matches found for pattern" in result
119
+
120
+ async def test_search_codebase_excludes_large_files(self, search_codebase_tool, sample_files):
121
+ result = await search_codebase_tool.search_codebase("x")
122
+
123
+ assert "large.txt" not in result
124
+
125
+ async def test_search_codebase_multiple_matches(self, search_codebase_tool, sample_files):
126
+ result = await search_codebase_tool.search_codebase("def")
127
+
128
+ assert "Search Results for 'def'" in result
129
+ assert "src/main.py" in result
130
+ assert "src/utils.py" in result
131
+ assert "tests/test_main.py" in result
132
+ assert "Line 1: def main():" in result
133
+ assert "Line 1: def helper():" in result
134
+ assert "Line 1: def test_main():" in result
135
+
136
+ async def test_search_codebase_with_context(self, search_codebase_tool, project_path):
137
+ # Create a file with multiple matches
138
+ file_path = project_path / "test.py"
139
+ file_path.write_text('def first():\n print("First")\n\ndef second():\n print("Second")\n')
140
+
141
+ result = await search_codebase_tool.search_codebase("print")
142
+
143
+ assert "test.py" in result
144
+ assert 'Line 2: print("First")' in result
145
+ assert 'Line 5: print("Second")' in result
146
+
147
+ async def test_search_codebase_result_limit(self, search_codebase_tool, project_path):
148
+ # Create a file with more than 5 matches (the per-file display limit)
149
+ file_path = project_path / "test.py"
150
+ file_path.write_text("\n".join(f'print("Line {i}")' for i in range(200)))
151
+
152
+ result = await search_codebase_tool.search_codebase("print")
153
+
154
+ # Check that we show 5 lines and then indicate there are more
155
+ assert 'Line 1: print("Line 0")' in result
156
+ assert 'Line 5: print("Line 4")' in result
157
+ assert "... and 195 more matches" in result
158
+ assert "(200 matches)" in result
159
+
160
+ async def test_search_codebase_with_special_characters(self, search_codebase_tool, project_path):
161
+ # Create a file with special characters
162
+ file_path = project_path / "special.py"
163
+ file_path.write_text('def special():\n print("Special chars: !@#$%^&*()")\n')
164
+
165
+ result = await search_codebase_tool.search_codebase("!@#")
166
+
167
+ assert "special.py" in result
168
+ assert 'Line 2: print("Special chars: !@#$%^&*()")' in result
169
+
170
+ async def test_search_codebase_literal_mode(self, search_codebase_tool, project_path):
171
+ """Test literal search mode with regex special characters"""
172
+ # Create files with patterns that would be invalid regex
173
+ file_path = project_path / "code.py"
174
+ file_path.write_text("""
175
+ def process_array(arr):
176
+ value = arr[0]) # Unbalanced parenthesis
177
+ return value
178
+
179
+ def func():
180
+ print("Testing [](){}")
181
+ """)
182
+
183
+ # Test 1: Search for unbalanced parenthesis - should work in literal mode
184
+ result = await search_codebase_tool.search_codebase("])", literal=True)
185
+ assert "code.py" in result
186
+ assert "Line 3: value = arr[0])" in result
187
+
188
+ # Test 2: Same pattern should fail in regex mode
189
+ result = await search_codebase_tool.search_codebase("])", literal=False)
190
+ assert "Error: Invalid regular expression" in result
191
+
192
+ # Test 3: Search for pattern with special chars in literal mode
193
+ result = await search_codebase_tool.search_codebase("[](){}", literal=True)
194
+ assert "code.py" in result
195
+ assert 'Line 7: print("Testing [](){}")' in result
196
+
197
+ # Test 4: Verify default is literal=True
198
+ result = await search_codebase_tool.search_codebase("])")
199
+ assert "code.py" in result
200
+ assert "Line 3: value = arr[0])" in result
201
+
202
+ async def test_search_codebase_long_line_truncation(self, search_codebase_tool, project_path):
203
+ """Test that long lines are truncated to 200 characters"""
204
+ # Create a file with a very long line (minified JSON style)
205
+ long_line = '{"key":"' + "x" * 500 + '","match":"FINDME"}'
206
+ file_path = project_path / "minified.json"
207
+ file_path.write_text(long_line)
208
+
209
+ result = await search_codebase_tool.search_codebase("FINDME")
210
+
211
+ assert "minified.json" in result
212
+ # The line should be truncated to 200 characters with "..."
213
+ assert "..." in result
214
+ # Verify the full long line is NOT in the output
215
+ assert "x" * 500 not in result
216
+ # The truncated line should be approximately 200 characters (plus "Line N: " prefix and "...")
217
+ lines = result.split("\n")
218
+ for line in lines:
219
+ if "Line 1:" in line and "minified.json" not in line:
220
+ # Extract just the content part after "Line N: "
221
+ content_match = line.split("Line 1: ", 1)
222
+ if len(content_match) > 1:
223
+ content = content_match[1]
224
+ # Should be exactly 200 chars + "..." = 203 chars
225
+ assert len(content) == 203, f"Expected 203 chars, got {len(content)}"
226
+ assert content.endswith("...")
227
+ break
228
+