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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
|