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,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()
|