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,356 @@
1
+ """Tests for the unified AgentTool dispatch mechanism."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+ from unittest.mock import AsyncMock, Mock, MagicMock, patch
6
+ import uuid
7
+ import builtins
8
+
9
+ from kolega_code.agent.tool_backend.agent_tool import AgentTool
10
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
11
+ from kolega_code.events import AgentEvent
12
+
13
+
14
+ @pytest.fixture
15
+ def mock_config():
16
+ """Create a mock agent configuration."""
17
+ return AgentConfig(
18
+ anthropic_api_key="test-key",
19
+ openai_api_key="test-key",
20
+ long_context_config=ModelConfig(
21
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()
22
+ ),
23
+ fast_config=ModelConfig(provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()),
24
+ thinking_config=ModelConfig(
25
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig(), thinking_tokens=1024
26
+ ),
27
+ )
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_connection_manager():
32
+ """Create a mock connection manager."""
33
+ manager = AsyncMock()
34
+ manager.broadcast_event = AsyncMock()
35
+ return manager
36
+
37
+
38
+ class MockSubAgentRecorder:
39
+ def __init__(self):
40
+ self.start_conversation = AsyncMock(return_value="test-conversation-id")
41
+ self.record_message = AsyncMock()
42
+ self.complete_conversation = AsyncMock()
43
+ self.fail_conversation = AsyncMock()
44
+ self.interrupt_conversation = AsyncMock()
45
+
46
+
47
+ @pytest.fixture
48
+ def mock_caller():
49
+ """Create a mock caller agent."""
50
+ caller = Mock()
51
+ caller.agent_name = "test-caller"
52
+ caller.log_info = AsyncMock()
53
+ caller.current_tool_call_id = "test-tool-call-id" # Set a test tool call ID for sub-agent creation
54
+ caller.sub_agent = False # Caller is not a sub-agent
55
+ caller.protected_files = ["custom.lock"]
56
+ caller.workspace_env_var_descriptions = {"API_TOKEN": "Token for external API"}
57
+ caller.workspace_memories = []
58
+ caller.prompt_extensions = []
59
+ caller.tool_extensions = []
60
+ caller.usage_recorder = None
61
+ caller.sub_agent_recorder = MockSubAgentRecorder()
62
+ return caller
63
+
64
+
65
+ @pytest.fixture
66
+ def agent_tool(tmp_path, mock_connection_manager, mock_config, mock_caller):
67
+ """Create an AgentTool instance for testing."""
68
+ return AgentTool(
69
+ project_path=tmp_path,
70
+ workspace_id="test-workspace",
71
+ thread_id=str(uuid.uuid4()),
72
+ connection_manager=mock_connection_manager,
73
+ config=mock_config,
74
+ caller=mock_caller,
75
+ )
76
+
77
+
78
+ class MockAgent:
79
+ """Mock agent class for testing."""
80
+
81
+ agent_name = "mock-agent"
82
+ default_stream_messages = []
83
+ last_instance = None
84
+
85
+ def __init__(self, *args, **kwargs):
86
+ self.init_kwargs = kwargs
87
+ self.recap_agent_outcome = AsyncMock(return_value="Mock agent completed")
88
+ self._stream_messages = list(self.default_stream_messages)
89
+ self._message_history = []
90
+ self.total_tokens_used = 0 # Add token count attribute
91
+ MockAgent.last_instance = self
92
+
93
+ def setup_streaming(self, messages):
94
+ """Setup the process_message_stream to return an async generator."""
95
+ self._stream_messages = messages
96
+
97
+ @classmethod
98
+ def configure_streaming(cls, messages):
99
+ """Configure default messages for the next instantiated agent."""
100
+ cls.default_stream_messages = messages
101
+
102
+ async def process_message_stream(self, task):
103
+ """Mock implementation of process_message_stream that returns an async generator."""
104
+ # Add the task as a user message to history
105
+ self._message_history.append({"role": "user", "content": [{"type": "text", "text": task}]})
106
+
107
+ # Simulate assistant responses
108
+ for msg in self._stream_messages:
109
+ if msg.get("complete", False):
110
+ # Add completed message to history
111
+ self._message_history.append(
112
+ {"role": "assistant", "content": [{"type": "text", "text": msg.get("content", "")}]}
113
+ )
114
+ yield msg
115
+
116
+ def dump_message_history(self):
117
+ """Mock implementation of dump_message_history."""
118
+ return self._message_history
119
+
120
+ async def cleanup(self):
121
+ """Mock implementation of cleanup."""
122
+ pass
123
+
124
+
125
+ class MockInvestigationAgent(MockAgent):
126
+ """Mock investigation agent."""
127
+
128
+ agent_name = "investigation-agent"
129
+
130
+
131
+ class MockBrowserAgent(MockAgent):
132
+ """Mock browser agent."""
133
+
134
+ agent_name = "browser-agent"
135
+
136
+
137
+ class MockCodingAgent(MockAgent):
138
+ """Mock coding agent."""
139
+
140
+ agent_name = "coding-agent"
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ class TestAgentTool:
145
+ """Test suite for AgentTool."""
146
+
147
+ async def test_dispatch_agent_standard_flow(self, agent_tool, mock_connection_manager, mock_caller):
148
+ """Test standard agent dispatch flow with consistent status messages."""
149
+ # Save original import before patching
150
+ original_import = builtins.__import__
151
+
152
+ # Mock the dynamic import
153
+ with patch.object(builtins, "__import__") as mock_import:
154
+ mock_module = MagicMock()
155
+ mock_module.MockAgent = MockAgent
156
+
157
+ def mock_import_func(name, *args, **kwargs):
158
+ if name == "test.module":
159
+ return mock_module
160
+ return original_import(name, *args, **kwargs)
161
+
162
+ mock_import.side_effect = mock_import_func
163
+
164
+ # Configure mock agent with streaming response
165
+ MockAgent.configure_streaming(
166
+ [
167
+ {"content": "Processing...", "complete": False, "uuid": str(uuid.uuid4())},
168
+ {"content": "Done.", "complete": True, "uuid": str(uuid.uuid4())},
169
+ ]
170
+ )
171
+ MockAgent.last_instance = None
172
+
173
+ # Dispatch the agent
174
+ result = await agent_tool._dispatch_agent(agent_class_import="test.module.MockAgent", task="Test task")
175
+
176
+ # Verify result
177
+ assert result == "Mock agent completed"
178
+
179
+ # Verify start status was sent
180
+ start_event_calls = [
181
+ call
182
+ for call in mock_connection_manager.broadcast_event.call_args_list
183
+ if call[0][0].content.get("status") == "GENERATING"
184
+ ]
185
+ assert len(start_event_calls) == 1
186
+ assert start_event_calls[0][0][0].content["message"] == "Starting mock-agent task"
187
+
188
+ # Verify completion status was sent
189
+ end_event_calls = [
190
+ call
191
+ for call in mock_connection_manager.broadcast_event.call_args_list
192
+ if call[0][0].content.get("status") == "STOPPED"
193
+ ]
194
+ assert len(end_event_calls) == 1
195
+ assert end_event_calls[0][0][0].content["message"] == "Completed mock-agent task"
196
+
197
+ assert mock_caller.sub_agent_recorder.start_conversation.await_count == 1
198
+ assert mock_caller.sub_agent_recorder.record_message.await_count >= 1
199
+ assert mock_caller.sub_agent_recorder.complete_conversation.await_count == 1
200
+
201
+ # Verify agent was created and cleaned up
202
+ assert str(uuid.uuid4()) not in agent_tool.agents
203
+ # Ensure protected files were forwarded to the sub-agent
204
+ assert MockAgent.last_instance is not None
205
+ assert MockAgent.last_instance.init_kwargs.get("protected_files") == mock_caller.protected_files
206
+ assert (
207
+ MockAgent.last_instance.init_kwargs.get("workspace_env_var_descriptions")
208
+ == mock_caller.workspace_env_var_descriptions
209
+ )
210
+ MockAgent.configure_streaming([])
211
+
212
+ async def test_dispatch_agent_uses_execution_id_for_sub_agent_conversation(
213
+ self, agent_tool, mock_connection_manager, mock_caller
214
+ ):
215
+ """Sub-agent records must use internal execution IDs, not provider tool IDs."""
216
+ original_import = builtins.__import__
217
+ mock_caller.current_provider_tool_call_id = "dispatch_investigation_agent_0"
218
+ mock_caller.current_tool_execution_id = "tool_exec_unique_123"
219
+ mock_caller.current_tool_call_id = "tool_exec_unique_123"
220
+
221
+ with patch.object(builtins, "__import__") as mock_import:
222
+ mock_module = MagicMock()
223
+ mock_module.MockAgent = MockAgent
224
+
225
+ def mock_import_func(name, *args, **kwargs):
226
+ if name == "test.module":
227
+ return mock_module
228
+ return original_import(name, *args, **kwargs)
229
+
230
+ mock_import.side_effect = mock_import_func
231
+ MockAgent.configure_streaming(
232
+ [{"content": "Done.", "complete": True, "uuid": str(uuid.uuid4()), "type": "response"}]
233
+ )
234
+ MockAgent.last_instance = None
235
+
236
+ await agent_tool._dispatch_agent(agent_class_import="test.module.MockAgent", task="Test task")
237
+
238
+ conversation_payload = mock_caller.sub_agent_recorder.start_conversation.call_args.args[0]
239
+ assert conversation_payload["parent_tool_call_id"] == "tool_exec_unique_123"
240
+ assert conversation_payload["parent_tool_call_id"] != mock_caller.current_provider_tool_call_id
241
+ assert MockAgent.last_instance.parent_tool_call_id == "tool_exec_unique_123"
242
+
243
+ sub_agent_events = [
244
+ call.args[0]
245
+ for call in mock_connection_manager.broadcast_event.call_args_list
246
+ if call.args[0].sub_agent_info
247
+ ]
248
+ assert sub_agent_events
249
+ assert sub_agent_events[0].sub_agent_info["parent_tool_call_id"] == "tool_exec_unique_123"
250
+ MockAgent.configure_streaming([])
251
+
252
+ async def test_all_agents_get_consistent_status_messages(self, agent_tool):
253
+ """Test that all agent types get consistent start/end/error messages."""
254
+ # This is a meta-test to ensure our simplification goal is achieved
255
+
256
+ # Test data for different agent types
257
+ test_agents = [
258
+ ("investigation", agent_tool.dispatch_investigation_agent, "Investigate code"),
259
+ ("browser", agent_tool.dispatch_browser_agent, "Browse website"),
260
+ ("coding", agent_tool.dispatch_coding_agent, "Write code"),
261
+ ]
262
+
263
+ for agent_type, dispatch_method, task in test_agents:
264
+ # Mock the underlying dispatch to verify it gets called with consistent pattern
265
+ with patch.object(agent_tool, "_dispatch_agent") as mock_dispatch:
266
+ mock_dispatch.return_value = f"{agent_type} completed"
267
+
268
+ result = await dispatch_method(task)
269
+
270
+ # Verify the dispatch was called with the task
271
+ assert mock_dispatch.called
272
+ call_args = mock_dispatch.call_args
273
+ assert call_args[1]["task"] == task
274
+
275
+ async def test_dispatch_investigation_agent(self, agent_tool):
276
+ """Test investigation agent dispatch."""
277
+ with patch.object(agent_tool, "_dispatch_agent") as mock_dispatch:
278
+ mock_dispatch.return_value = "Investigation completed"
279
+
280
+ result = await agent_tool.dispatch_investigation_agent("Investigate this code")
281
+
282
+ mock_dispatch.assert_called_once_with(
283
+ agent_class_import="kolega_code.agent.investigationagent.InvestigationAgent",
284
+ task="Investigate this code",
285
+ )
286
+ assert result == "Investigation completed"
287
+
288
+ async def test_dispatch_browser_agent(self, agent_tool):
289
+ """Test browser agent dispatch."""
290
+ with patch.object(agent_tool, "_dispatch_agent") as mock_dispatch:
291
+ mock_dispatch.return_value = "Browser task completed"
292
+
293
+ result = await agent_tool.dispatch_browser_agent("Browse the web")
294
+
295
+ mock_dispatch.assert_called_once_with(
296
+ agent_class_import="kolega_code.agent.browseragent.BrowserAgent",
297
+ task="Browse the web",
298
+ )
299
+ assert result == "Browser task completed"
300
+
301
+ async def test_dispatch_coding_agent(self, agent_tool):
302
+ """Test coding agent dispatch."""
303
+ with patch.object(agent_tool, "_dispatch_agent") as mock_dispatch:
304
+ mock_dispatch.return_value = "Coding completed"
305
+
306
+ result = await agent_tool.dispatch_coding_agent("Write some code")
307
+
308
+ mock_dispatch.assert_called_once_with(
309
+ agent_class_import="kolega_code.agent.coder.CoderAgent",
310
+ task="Write some code",
311
+ )
312
+ assert result == "Coding completed"
313
+
314
+ async def test_agent_name_consistency(self, agent_tool):
315
+ """Test that all agent names follow kebab-case convention."""
316
+ # This test verifies our agent name assumptions
317
+ test_agents = [
318
+ ("kolega_code.agent.investigationagent.InvestigationAgent", "investigation-agent"),
319
+ ("kolega_code.agent.browseragent.BrowserAgent", "browser-agent"),
320
+ ("kolega_code.agent.coder.CoderAgent", "coding-agent"),
321
+ ]
322
+
323
+ original_import = builtins.__import__
324
+
325
+ for class_import, expected_name in test_agents:
326
+ with patch.object(builtins, "__import__") as mock_import:
327
+ # Create appropriate mock class based on the import
328
+ if "InvestigationAgent" in class_import:
329
+ mock_class = MockInvestigationAgent
330
+ elif "BrowserAgent" in class_import:
331
+ mock_class = MockBrowserAgent
332
+ elif "CoderAgent" in class_import:
333
+ mock_class = MockCodingAgent
334
+ else:
335
+ mock_class = MockAgent
336
+
337
+ mock_module = MagicMock()
338
+ setattr(mock_module, class_import.split(".")[-1], mock_class)
339
+
340
+ def mock_import_func(name, *args, **kwargs):
341
+ module_base = class_import.rsplit(".", 1)[0]
342
+ if name == module_base:
343
+ return mock_module
344
+ return original_import(name, *args, **kwargs)
345
+
346
+ mock_import.side_effect = mock_import_func
347
+
348
+ # Just verify we can get the agent name
349
+ module_path, class_name = class_import.rsplit(".", 1)
350
+ agent_class = getattr(mock_module, class_name)
351
+ assert agent_class.agent_name == expected_name
352
+
353
+ async def _create_mock_stream(self, messages):
354
+ """Helper to create async generator for mock message stream."""
355
+ for msg in messages:
356
+ yield msg
@@ -0,0 +1,147 @@
1
+ from pathlib import Path
2
+ from unittest.mock import Mock, patch
3
+ import uuid
4
+
5
+ import pytest
6
+
7
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
8
+ from kolega_code.agent.tool_backend.base_tool import BaseTool
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_connection_manager():
13
+ return Mock()
14
+
15
+
16
+ @pytest.fixture
17
+ def project_path(tmp_path):
18
+ return tmp_path
19
+
20
+
21
+ @pytest.fixture
22
+ def agent_config():
23
+ return AgentConfig(
24
+ anthropic_api_key="test_key",
25
+ openai_api_key="test-key",
26
+ long_context_config=ModelConfig(
27
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()
28
+ ),
29
+ fast_config=ModelConfig(provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()),
30
+ thinking_config=ModelConfig(
31
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig(), thinking_tokens=1024
32
+ ),
33
+ )
34
+
35
+
36
+ @pytest.fixture
37
+ def mock_base_agent():
38
+ return Mock()
39
+
40
+
41
+ @pytest.fixture
42
+ def base_tool(project_path, mock_connection_manager, agent_config, mock_base_agent):
43
+ return BaseTool(
44
+ project_path, "test_workspace", str(uuid.uuid4()), mock_connection_manager, agent_config, mock_base_agent
45
+ )
46
+
47
+
48
+ class TestBaseTool:
49
+ def test_initialization(self, base_tool, project_path):
50
+ assert base_tool.workspace_id == "test_workspace"
51
+ assert base_tool.project_path == project_path
52
+ assert base_tool.connection_manager is not None
53
+ assert base_tool.caller is not None
54
+
55
+ def test_initialization_with_string_path(self, mock_connection_manager, agent_config, mock_base_agent):
56
+ tool = BaseTool(
57
+ "/test/path", "test_workspace", str(uuid.uuid4()), mock_connection_manager, agent_config, mock_base_agent
58
+ )
59
+ assert isinstance(tool.project_path, Path)
60
+ assert str(tool.project_path) == "/test/path"
61
+ assert tool.caller is not None
62
+
63
+ @pytest.mark.parametrize(
64
+ "extension,expected",
65
+ [
66
+ (".pyc", True),
67
+ (".jpg", True),
68
+ (".txt", False),
69
+ (".py", False),
70
+ (".md", False),
71
+ ],
72
+ )
73
+ def test_is_binary_file_by_extension(self, base_tool, tmp_path, extension, expected):
74
+ test_file = tmp_path / f"test{extension}"
75
+ test_file.touch()
76
+ assert base_tool._is_binary_file(test_file) == expected
77
+
78
+ def test_is_binary_file_by_content(self, base_tool, tmp_path):
79
+ # Create a binary file
80
+ binary_file = tmp_path / "test.bin"
81
+ with open(binary_file, "wb") as f:
82
+ f.write(b"\x00\x01\x02\x03")
83
+ assert base_tool._is_binary_file(binary_file) is True
84
+
85
+ # Create a text file
86
+ text_file = tmp_path / "test.txt"
87
+ with open(text_file, "w") as f:
88
+ f.write("Hello, World!")
89
+ assert base_tool._is_binary_file(text_file) is False
90
+
91
+ @pytest.mark.parametrize(
92
+ "directory,expected",
93
+ [
94
+ (".git", True),
95
+ ("__pycache__", True),
96
+ ("src", False),
97
+ ("tests", False),
98
+ ],
99
+ )
100
+ def test_should_exclude_file_by_directory(self, base_tool, tmp_path, directory, expected):
101
+ test_file = tmp_path / directory / "test.txt"
102
+ test_file.parent.mkdir(exist_ok=True)
103
+ test_file.touch()
104
+ assert base_tool._should_exclude_file(test_file) == expected
105
+
106
+ def test_should_exclude_large_file(self, base_tool, tmp_path):
107
+ test_file = tmp_path / "large.txt"
108
+ with open(test_file, "w") as f:
109
+ f.write("x" * (11 * 1024 * 1024)) # 11MB file
110
+ assert base_tool._should_exclude_file(test_file) is True
111
+
112
+ @patch("pathspec.PathSpec.from_lines")
113
+ def test_is_gitignored(self, mock_pathspec, base_tool, tmp_path):
114
+ # Setup mock gitignore
115
+ gitignore_path = tmp_path / ".gitignore"
116
+ gitignore_path.write_text("*.pyc\n__pycache__\n")
117
+
118
+ # Create a mock pathspec that matches certain patterns
119
+ mock_spec = Mock()
120
+ mock_spec.match_file.side_effect = lambda path: path.endswith(".pyc")
121
+ mock_pathspec.return_value = mock_spec
122
+
123
+ # Test ignored file
124
+ ignored_file = tmp_path / "test.pyc"
125
+ ignored_file.touch()
126
+ assert base_tool._is_gitignored(ignored_file) is True
127
+
128
+ # Test non-ignored file
129
+ non_ignored_file = tmp_path / "test.py"
130
+ non_ignored_file.touch()
131
+ assert base_tool._is_gitignored(non_ignored_file) is False
132
+
133
+ def test_load_gitignore_patterns_no_file(self, base_tool, tmp_path):
134
+ base_tool._load_gitignore_patterns()
135
+ assert base_tool._gitignore_spec is None
136
+
137
+ @patch("pathspec.PathSpec.from_lines")
138
+ def test_load_gitignore_patterns_with_file(self, mock_pathspec, base_tool, tmp_path):
139
+ gitignore_path = tmp_path / ".gitignore"
140
+ gitignore_path.write_text("*.pyc\n__pycache__\n")
141
+
142
+ mock_spec = Mock()
143
+ mock_pathspec.return_value = mock_spec
144
+
145
+ base_tool._load_gitignore_patterns()
146
+ assert base_tool._gitignore_spec is not None
147
+ mock_pathspec.assert_called_once()