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,482 @@
1
+ import os
2
+ import asyncio
3
+ from unittest.mock import AsyncMock, Mock, patch
4
+
5
+ import pytest
6
+ import uuid
7
+
8
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
9
+ from kolega_code.events import AgentEvent
10
+ from kolega_code.agent.tool_backend.terminal_tool import TerminalTool
11
+
12
+ # Check if running in CI environment
13
+ SKIP_IN_CI = bool(os.getenv("CI")) or bool(os.getenv("GITLAB_CI"))
14
+
15
+
16
+ @pytest.fixture
17
+ def mock_connection_manager():
18
+ return AsyncMock()
19
+
20
+
21
+ @pytest.fixture
22
+ def project_path(tmp_path):
23
+ return tmp_path
24
+
25
+
26
+ @pytest.fixture
27
+ def agent_config():
28
+ return AgentConfig(
29
+ anthropic_api_key="test_key",
30
+ openai_api_key="test_key",
31
+ long_context_config=ModelConfig(
32
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()
33
+ ),
34
+ fast_config=ModelConfig(provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig()),
35
+ thinking_config=ModelConfig(
36
+ provider=ModelProvider.ANTHROPIC, model="test-model", rate_limits=RateLimitConfig(), thinking_tokens=1024
37
+ ),
38
+ )
39
+
40
+
41
+ @pytest.fixture
42
+ def mock_base_agent():
43
+ mock = Mock()
44
+ mock.agent_name = "test_agent"
45
+ return mock
46
+
47
+
48
+ @pytest.fixture
49
+ def terminal_tool(project_path, mock_connection_manager, agent_config, mock_base_agent):
50
+ tool = TerminalTool(
51
+ project_path, "test_workspace", str(uuid.uuid4()), mock_connection_manager, agent_config, mock_base_agent
52
+ )
53
+ # Set initialized=True to prevent auto-initialization during tests
54
+ tool.initialized = True
55
+ return tool
56
+
57
+
58
+ class TestTerminalTool:
59
+ @pytest.mark.asyncio
60
+ async def test_execute_terminal_command_success(self, terminal_tool, mock_connection_manager):
61
+ # Mock the subprocess
62
+ mock_process = AsyncMock()
63
+ mock_process.communicate = AsyncMock(return_value=(b"stdout", b""))
64
+
65
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
66
+ result = await terminal_tool.execute_terminal_command('echo "Hello World"')
67
+
68
+ assert result == "stdout"
69
+
70
+ # Verify connection manager was called correctly
71
+ mock_connection_manager.broadcast_event.assert_called()
72
+ calls = mock_connection_manager.broadcast_event.call_args_list
73
+
74
+ # Check log message broadcast (first call)
75
+ assert isinstance(calls[0][0][0], AgentEvent)
76
+ assert calls[0][0][0].event_type == "log_message"
77
+ assert calls[0][0][0].content["text"] == 'Executing command: echo "Hello World"'
78
+ assert calls[0][0][0].content["level"] == "info"
79
+ assert calls[0][0][1] == "test_workspace"
80
+
81
+ # Check command broadcast (second call)
82
+ assert isinstance(calls[1][0][0], AgentEvent)
83
+ assert calls[1][0][0].event_type == "terminal_command"
84
+ assert calls[1][0][0].content["command"] == 'echo "Hello World"'
85
+ assert calls[1][0][1] == "test_workspace"
86
+
87
+ # Check output broadcast (third call)
88
+ assert isinstance(calls[2][0][0], AgentEvent)
89
+ assert calls[2][0][0].event_type == "terminal_output"
90
+ assert calls[2][0][0].content["output"] == "stdout"
91
+ assert calls[2][0][1] == "test_workspace"
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_execute_terminal_command_with_stderr(self, terminal_tool, mock_connection_manager):
95
+ # Mock the subprocess
96
+ mock_process = AsyncMock()
97
+ mock_process.communicate = AsyncMock(return_value=(b"stdout", b"stderr"))
98
+
99
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
100
+ result = await terminal_tool.execute_terminal_command("test command")
101
+
102
+ assert result == "stdoutstderr"
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_execute_terminal_command_timeout(self, terminal_tool, mock_connection_manager):
106
+ # Mock the subprocess to simulate a timeout
107
+ mock_process = AsyncMock()
108
+ mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError())
109
+ mock_process.terminate = Mock()
110
+
111
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
112
+ result = await terminal_tool.execute_terminal_command("slow command")
113
+
114
+ assert result == "Command timed out after 15 seconds"
115
+ mock_process.terminate.assert_called_once()
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_execute_terminal_command_process_error(self, terminal_tool, mock_connection_manager):
119
+ # Mock the subprocess to raise an exception
120
+ mock_process = AsyncMock()
121
+ mock_process.communicate = AsyncMock(side_effect=Exception("Process error"))
122
+
123
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
124
+ result = await terminal_tool.execute_terminal_command("error command")
125
+
126
+ assert "Command execution failed: Process error" in result
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_execute_terminal_command_terminate_error(self, terminal_tool, mock_connection_manager):
130
+ # Mock the subprocess to simulate a timeout and raise error on terminate
131
+ mock_process = AsyncMock()
132
+ mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError())
133
+ mock_process.terminate = Mock(side_effect=Exception("Terminate error"))
134
+
135
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
136
+ result = await terminal_tool.execute_terminal_command("slow command")
137
+
138
+ assert result == "Command timed out after 15 seconds"
139
+ mock_process.terminate.assert_called_once()
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_execute_terminal_command_empty_output(self, terminal_tool, mock_connection_manager):
143
+ # Mock the subprocess with empty output
144
+ mock_process = AsyncMock()
145
+ mock_process.communicate = AsyncMock(return_value=(b"", b""))
146
+
147
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
148
+ result = await terminal_tool.execute_terminal_command("empty command")
149
+
150
+ assert result == ""
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_execute_terminal_command_unicode_output(self, terminal_tool, mock_connection_manager):
154
+ # Mock the subprocess with unicode output
155
+ mock_process = AsyncMock()
156
+ mock_process.communicate = AsyncMock(return_value=(b"Hello \xe2\x9c\xa8", b""))
157
+
158
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process):
159
+ result = await terminal_tool.execute_terminal_command("unicode command")
160
+
161
+ assert result == "Hello ✨"
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_execute_terminal_command_working_directory(self, terminal_tool, mock_connection_manager):
165
+ # Mock the subprocess
166
+ mock_process = AsyncMock()
167
+ mock_process.communicate = AsyncMock(return_value=(b"stdout", b""))
168
+
169
+ with patch("asyncio.create_subprocess_shell", return_value=mock_process) as mock_create:
170
+ await terminal_tool.execute_terminal_command("pwd")
171
+
172
+ # Verify the command was executed in the correct directory
173
+ mock_create.assert_called_once()
174
+ assert mock_create.call_args[1]["cwd"] == str(terminal_tool.project_path)
175
+
176
+
177
+ class TestTerminalToolCommandTracking:
178
+ """Tests for the command tracking functionality"""
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_run_command_tracked_success(self, terminal_tool, mock_connection_manager):
182
+ # Mock the terminal manager
183
+ mock_terminal_manager = AsyncMock()
184
+ mock_terminal_manager.send_command_tracked = AsyncMock(return_value="terminal_1_1")
185
+ terminal_tool.terminal_manager = mock_terminal_manager
186
+
187
+ result = await terminal_tool.run_command_tracked("terminal_1", "echo test", "Test command")
188
+
189
+ assert result == "terminal_1_1"
190
+ mock_terminal_manager.send_command_tracked.assert_called_once_with(
191
+ "terminal_1", "echo test", "Test command", timeout=0
192
+ )
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_run_command_tracked_failure(self, terminal_tool, mock_connection_manager):
196
+ # Mock the terminal manager to return None (failure)
197
+ mock_terminal_manager = AsyncMock()
198
+ mock_terminal_manager.send_command_tracked = AsyncMock(return_value=None)
199
+ terminal_tool.terminal_manager = mock_terminal_manager
200
+
201
+ result = await terminal_tool.run_command_tracked("terminal_1", "echo test", "Test command")
202
+
203
+ assert "Failed to start command `echo test` in terminal terminal_1" in result
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_run_command_tracked_terminal_not_found(self, terminal_tool, mock_connection_manager):
207
+ # Mock the terminal manager to raise KeyError
208
+ mock_terminal_manager = AsyncMock()
209
+ mock_terminal_manager.send_command_tracked = AsyncMock(
210
+ side_effect=KeyError("Terminal with ID invalid not found")
211
+ )
212
+ terminal_tool.terminal_manager = mock_terminal_manager
213
+
214
+ result = await terminal_tool.run_command_tracked("invalid", "echo test", "Test command")
215
+
216
+ assert "Terminal with ID invalid not found" in result
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_send_terminal_input_success(self, terminal_tool, mock_connection_manager):
220
+ mock_terminal_manager = AsyncMock()
221
+ mock_terminal_manager.send_input = AsyncMock(return_value=True)
222
+ terminal_tool.terminal_manager = mock_terminal_manager
223
+
224
+ result = await terminal_tool.send_terminal_input("terminal_1", "Ada", submit=True, command_id="cmd_1")
225
+
226
+ assert result == "Sent input to terminal terminal_1 for command cmd_1 and submitted it."
227
+ assert "Ada" not in result
228
+ mock_terminal_manager.send_input.assert_awaited_once_with(
229
+ "terminal_1", "Ada", submit=True, command_id="cmd_1"
230
+ )
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_send_terminal_input_returns_readable_value_error(self, terminal_tool, mock_connection_manager):
234
+ mock_terminal_manager = AsyncMock()
235
+ mock_terminal_manager.send_input = AsyncMock(side_effect=ValueError("No active command is running"))
236
+ terminal_tool.terminal_manager = mock_terminal_manager
237
+
238
+ result = await terminal_tool.send_terminal_input("terminal_1", "Ada")
239
+
240
+ assert result == "No active command is running"
241
+
242
+ @pytest.mark.asyncio
243
+ async def test_check_command_status_running(self, terminal_tool, mock_connection_manager):
244
+ # Mock the terminal manager
245
+ mock_terminal_manager = Mock()
246
+ mock_terminal_manager.get_command_status = Mock(
247
+ return_value={"status": "running", "command": "sleep 10", "duration": 5.2, "child_pids": [1234, 5678]}
248
+ )
249
+ terminal_tool.terminal_manager = mock_terminal_manager
250
+
251
+ result = await terminal_tool.check_command_status("terminal_1", "cmd_1")
252
+
253
+ assert "🔄 Command still running in terminal terminal_1 after 5.2s (2 child processes)" in result
254
+ assert "Command: sleep 10" in result
255
+
256
+ @pytest.mark.asyncio
257
+ async def test_check_command_status_completed(self, terminal_tool, mock_connection_manager):
258
+ # Mock the terminal manager
259
+ mock_terminal_manager = Mock()
260
+ mock_terminal_manager.get_command_status = Mock(
261
+ return_value={
262
+ "status": "completed",
263
+ "command": "echo test",
264
+ "duration": 1.5,
265
+ "return_code": 0,
266
+ "child_pids": [],
267
+ }
268
+ )
269
+ terminal_tool.terminal_manager = mock_terminal_manager
270
+
271
+ result = await terminal_tool.check_command_status("terminal_1", "cmd_1")
272
+
273
+ assert "✅ Command completed in 1.5s with exit code 0" in result
274
+ assert "Command: echo test" in result
275
+
276
+ @pytest.mark.asyncio
277
+ async def test_check_command_status_terminated(self, terminal_tool, mock_connection_manager):
278
+ # Mock the terminal manager
279
+ mock_terminal_manager = Mock()
280
+ mock_terminal_manager.get_command_status = Mock(
281
+ return_value={
282
+ "status": "terminated",
283
+ "command": "invalid_command",
284
+ "duration": 0.8,
285
+ "return_code": -1,
286
+ "child_pids": [],
287
+ }
288
+ )
289
+ terminal_tool.terminal_manager = mock_terminal_manager
290
+
291
+ result = await terminal_tool.check_command_status("terminal_1", "cmd_1")
292
+
293
+ assert "❌ Command terminated after 0.8s" in result
294
+ assert "Command: invalid_command" in result
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_check_command_status_not_found(self, terminal_tool, mock_connection_manager):
298
+ # Mock the terminal manager
299
+ mock_terminal_manager = Mock()
300
+ mock_terminal_manager.get_command_status = Mock(return_value={"status": "not_found"})
301
+ terminal_tool.terminal_manager = mock_terminal_manager
302
+
303
+ result = await terminal_tool.check_command_status("terminal_1", "invalid_cmd")
304
+
305
+ assert "❌ Command ID invalid_cmd not found" in result
306
+
307
+ @pytest.mark.asyncio
308
+ async def test_check_command_status_monitor_timeout(self, terminal_tool, mock_connection_manager):
309
+ mock_terminal_manager = Mock()
310
+ mock_terminal_manager.get_command_status = Mock(
311
+ return_value={
312
+ "status": "monitor_timeout",
313
+ "command": "sleep 999",
314
+ "duration": 300.5,
315
+ "return_code": None,
316
+ "child_pids": [],
317
+ }
318
+ )
319
+ terminal_tool.terminal_manager = mock_terminal_manager
320
+
321
+ result = await terminal_tool.check_command_status("terminal_1", "cmd_1")
322
+
323
+ assert "Command monitoring stopped after 300.5s" in result
324
+ assert "command may still be running in terminal terminal_1" in result
325
+ assert "Command: sleep 999" in result
326
+ assert 'check_command_status("terminal_1", "cmd_1")' in result
327
+
328
+ @pytest.mark.asyncio
329
+ async def test_check_terminal_status_with_active_commands(self, terminal_tool, mock_connection_manager):
330
+ # Mock the terminal manager
331
+ mock_terminal_manager = AsyncMock()
332
+ mock_terminal_manager.get_terminal_status = AsyncMock(
333
+ return_value={
334
+ "running": True,
335
+ "ready_for_commands": False,
336
+ "active_commands": {
337
+ "terminal_1_1": {"command": "sleep 30", "duration": 15.3},
338
+ "terminal_1_2": {"command": "npm test", "duration": 45.7},
339
+ },
340
+ "last_command": "npm test",
341
+ }
342
+ )
343
+ terminal_tool.terminal_manager = mock_terminal_manager
344
+
345
+ result = await terminal_tool.check_terminal_status("terminal_1")
346
+
347
+ assert "# Terminal terminal_1 Status" in result
348
+ assert "**Running:** Yes" in result
349
+ assert "**Ready for new commands:** No" in result
350
+ assert "`terminal_1_1`: sleep 30 (running 15.3s)" in result
351
+ assert "`terminal_1_2`: npm test (running 45.7s)" in result
352
+
353
+ @pytest.mark.asyncio
354
+ async def test_check_terminal_status_no_active_commands(self, terminal_tool, mock_connection_manager):
355
+ # Mock the terminal manager
356
+ mock_terminal_manager = AsyncMock()
357
+ mock_terminal_manager.get_terminal_status = AsyncMock(
358
+ return_value={"running": True, "ready_for_commands": True, "active_commands": {}, "last_command": None}
359
+ )
360
+ terminal_tool.terminal_manager = mock_terminal_manager
361
+
362
+ result = await terminal_tool.check_terminal_status("terminal_1")
363
+
364
+ assert "**Ready for new commands:** Yes" in result
365
+ assert "**Active Commands:** None" in result
366
+
367
+ @pytest.mark.asyncio
368
+ async def test_wait_for_command_completion_success(self, terminal_tool, mock_connection_manager):
369
+ # Mock the terminal manager to return completed status
370
+ mock_terminal_manager = Mock()
371
+ mock_terminal_manager.get_command_status = Mock(
372
+ return_value={"status": "completed", "command": "echo test", "duration": 2.1, "return_code": 0}
373
+ )
374
+ terminal_tool.terminal_manager = mock_terminal_manager
375
+
376
+ result = await terminal_tool.wait_for_command_completion("terminal_1", "cmd_1", timeout=5)
377
+
378
+ assert "✅ Command completed in 2.1s with exit code 0" in result
379
+
380
+ def test_normalize_wait_timeout(self, terminal_tool):
381
+ with patch.object(TerminalTool, "DEFAULT_COMMAND_WAIT_TIMEOUT_SECONDS", 120), patch.object(
382
+ TerminalTool, "MAX_COMMAND_WAIT_TIMEOUT_SECONDS", 300
383
+ ):
384
+ assert terminal_tool._normalize_command_wait_timeout(None) == 120
385
+ assert terminal_tool._normalize_command_wait_timeout(0) == 120
386
+ assert terminal_tool._normalize_command_wait_timeout(-10) == 120
387
+ assert terminal_tool._normalize_command_wait_timeout("not-a-number") == 120
388
+ assert terminal_tool._normalize_command_wait_timeout(30) == 30
389
+ assert terminal_tool._normalize_command_wait_timeout(999) == 300
390
+
391
+ @pytest.mark.asyncio
392
+ async def test_wait_for_command_completion_timeout(self, terminal_tool, mock_connection_manager):
393
+ # Mock the terminal manager to always return running status
394
+ mock_terminal_manager = Mock()
395
+ mock_terminal_manager.get_command_status = Mock(
396
+ return_value={"status": "running", "command": "sleep 100", "duration": 10.0}
397
+ )
398
+ terminal_tool.terminal_manager = mock_terminal_manager
399
+
400
+ with patch("kolega_code.agent.tool_backend.terminal_tool.time.time", side_effect=[0, 0, 0, 2]), patch(
401
+ "kolega_code.agent.tool_backend.terminal_tool.asyncio.sleep", new_callable=AsyncMock
402
+ ):
403
+ result = await terminal_tool.wait_for_command_completion("terminal_1", "cmd_1", timeout=1)
404
+
405
+ assert "⏰ Timeout: Command cmd_1 is still running in terminal terminal_1 after 1 seconds" in result
406
+ assert 'check_command_status("terminal_1", "cmd_1")' in result
407
+
408
+ @pytest.mark.asyncio
409
+ async def test_wait_for_command_completion_none_timeout_uses_default(self, terminal_tool, mock_connection_manager):
410
+ mock_terminal_manager = Mock()
411
+ mock_terminal_manager.get_command_status = Mock(
412
+ return_value={"status": "running", "command": "sleep 100", "duration": 10.0}
413
+ )
414
+ terminal_tool.terminal_manager = mock_terminal_manager
415
+
416
+ with patch.object(TerminalTool, "DEFAULT_COMMAND_WAIT_TIMEOUT_SECONDS", 1), patch(
417
+ "kolega_code.agent.tool_backend.terminal_tool.time.time", side_effect=[0, 0, 0, 2]
418
+ ), patch("kolega_code.agent.tool_backend.terminal_tool.asyncio.sleep", new_callable=AsyncMock):
419
+ result = await terminal_tool.wait_for_command_completion("terminal_1", "cmd_1", timeout=None)
420
+
421
+ assert "after 1 seconds" in result
422
+
423
+ @pytest.mark.asyncio
424
+ async def test_wait_for_command_completion_timeout_is_clamped(self, terminal_tool, mock_connection_manager):
425
+ mock_terminal_manager = Mock()
426
+ mock_terminal_manager.get_command_status = Mock(
427
+ return_value={"status": "running", "command": "sleep 100", "duration": 10.0}
428
+ )
429
+ terminal_tool.terminal_manager = mock_terminal_manager
430
+
431
+ with patch.object(TerminalTool, "MAX_COMMAND_WAIT_TIMEOUT_SECONDS", 2), patch(
432
+ "kolega_code.agent.tool_backend.terminal_tool.time.time", side_effect=[0, 0, 0, 3]
433
+ ), patch("kolega_code.agent.tool_backend.terminal_tool.asyncio.sleep", new_callable=AsyncMock):
434
+ result = await terminal_tool.wait_for_command_completion("terminal_1", "cmd_1", timeout=999)
435
+
436
+ assert "after 2 seconds" in result
437
+
438
+ @pytest.mark.asyncio
439
+ async def test_wait_for_command_completion_terminal_not_found(self, terminal_tool, mock_connection_manager):
440
+ # Mock the terminal manager to raise KeyError
441
+ mock_terminal_manager = Mock()
442
+ mock_terminal_manager.get_command_status = Mock(side_effect=KeyError("Terminal with ID invalid not found"))
443
+ terminal_tool.terminal_manager = mock_terminal_manager
444
+
445
+ result = await terminal_tool.wait_for_command_completion("invalid", "cmd_1", timeout=5)
446
+
447
+ assert "Terminal with ID invalid not found" in result
448
+
449
+ @pytest.mark.asyncio
450
+ async def test_read_terminal_with_offset_skips_compression(self, terminal_tool, mock_connection_manager):
451
+ """Test that read_terminal with offset > 0 skips compression even for large output."""
452
+ # Create a large output that would normally trigger compression
453
+ large_output = "A" * 5000 # Exceeds the 4000 character threshold
454
+ terminal_tool.terminal_manager.read_output = Mock(return_value=large_output)
455
+ terminal_tool.terminal_manager.get_last_command = AsyncMock(return_value="test command")
456
+ terminal_tool.terminal_manager.get_last_command_purpose = AsyncMock(return_value="test purpose")
457
+
458
+ # Test with offset = 0 (should compress)
459
+ result_no_offset = await terminal_tool.read_terminal("test_terminal", num_chars=5000, offset=0)
460
+ assert "OUTPUT COMPRESSED" in result_no_offset
461
+ assert "offset parameter" in result_no_offset
462
+
463
+ # Test with offset > 0 (should not compress)
464
+ result_with_offset = await terminal_tool.read_terminal("test_terminal", num_chars=1000, offset=100)
465
+ assert "OUTPUT COMPRESSED" not in result_with_offset
466
+ assert result_with_offset.startswith("```\n")
467
+ assert result_with_offset.endswith("```\n")
468
+
469
+ @pytest.mark.asyncio
470
+ async def test_read_terminal_offset_parameter_passed_through(self, terminal_tool, mock_connection_manager):
471
+ """Test that the offset parameter is correctly passed through to the terminal manager."""
472
+ terminal_tool.terminal_manager.read_output = Mock(return_value="test output")
473
+
474
+ # Test with various offset values
475
+ await terminal_tool.read_terminal("test_terminal", num_chars=500, offset=0)
476
+ terminal_tool.terminal_manager.read_output.assert_called_with("test_terminal", num_chars=500, offset=0)
477
+
478
+ await terminal_tool.read_terminal("test_terminal", num_chars=200, offset=50)
479
+ terminal_tool.terminal_manager.read_output.assert_called_with("test_terminal", num_chars=200, offset=50)
480
+
481
+ await terminal_tool.read_terminal("test_terminal", num_chars=1000, offset=100)
482
+ terminal_tool.terminal_manager.read_output.assert_called_with("test_terminal", num_chars=1000, offset=100)
@@ -0,0 +1,189 @@
1
+ """Integration tests for the think_hard tool."""
2
+
3
+ import pytest
4
+ from unittest.mock import AsyncMock, Mock, patch
5
+ from pathlib import Path
6
+
7
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
8
+ from kolega_code.events import AgentConnectionManager
9
+ from kolega_code.agent.tool_backend.think_hard_tool import ThinkHardTool
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_config():
14
+ """Create a mock agent configuration."""
15
+ return AgentConfig(
16
+ anthropic_api_key="test-key",
17
+ openai_api_key="test-openai-key", # Required for edit_model_config
18
+ thinking_config=ModelConfig(
19
+ provider=ModelProvider.ANTHROPIC,
20
+ model="claude-3-7-sonnet-20250131",
21
+ rate_limits=RateLimitConfig(requests_per_minute=10, tokens_per_minute=100000, max_retries=3),
22
+ thinking_tokens=5000,
23
+ ),
24
+ )
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_connection_manager():
29
+ """Create a mock connection manager."""
30
+ return AsyncMock(spec=AgentConnectionManager)
31
+
32
+
33
+ @pytest.fixture
34
+ def mock_caller():
35
+ """Create a mock caller (base agent)."""
36
+ mock = Mock()
37
+ mock.agent_name = "test_agent"
38
+ mock.user_id = "user-123"
39
+ mock.user_email = "user@example.com"
40
+ return mock
41
+
42
+
43
+ @pytest.fixture
44
+ def think_hard_tool(mock_config, mock_connection_manager, mock_caller):
45
+ """Create a ThinkHardTool instance with mocked dependencies."""
46
+ tool = ThinkHardTool(
47
+ project_path="/test/path",
48
+ workspace_id="test_workspace",
49
+ thread_id="test_thread",
50
+ connection_manager=mock_connection_manager,
51
+ config=mock_config,
52
+ caller=mock_caller,
53
+ )
54
+ return tool
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_think_hard_tool_initialization(think_hard_tool):
59
+ """Test that the think_hard tool initializes correctly."""
60
+ assert think_hard_tool.project_path == Path("/test/path")
61
+ assert think_hard_tool.workspace_id == "test_workspace"
62
+ assert think_hard_tool.thread_id == "test_thread"
63
+ assert think_hard_tool.config.thinking_config.thinking_tokens == 5000
64
+ assert think_hard_tool.config.thinking_config.provider == ModelProvider.ANTHROPIC
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_think_hard_method_exists(think_hard_tool):
69
+ """Test that the think_hard method exists and is callable."""
70
+ assert hasattr(think_hard_tool, "think_hard")
71
+ assert callable(think_hard_tool.think_hard)
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_think_hard_returns_string(think_hard_tool):
76
+ """Test that think_hard returns a string response."""
77
+ # Mock the logging methods
78
+ think_hard_tool.log_info = AsyncMock()
79
+ think_hard_tool.log_error = AsyncMock()
80
+
81
+ # This would require actual API key to test fully, so we'll mock the LLMClient
82
+ with patch("kolega_code.agent.tool_backend.think_hard_tool.LLMClient") as mock_llm_class:
83
+ with patch("kolega_code.agent.tool_backend.think_hard_tool.get_model_specs") as mock_get_specs:
84
+ # Mock model specs
85
+ mock_get_specs.return_value = {"max_completion_tokens": 8192}
86
+
87
+ mock_llm_instance = mock_llm_class.return_value
88
+
89
+ # Create a simple mock stream that returns without actual API call
90
+ class SimpleStreamMock:
91
+ def __init__(self):
92
+ self.chunks = [] # No chunks to iterate over
93
+ self.chunk_index = 0
94
+
95
+ async def __aenter__(self):
96
+ return self
97
+
98
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
99
+ return None
100
+
101
+ def __aiter__(self):
102
+ """Make the stream async iterable."""
103
+ return self
104
+
105
+ async def __anext__(self):
106
+ """Return chunks for async iteration."""
107
+ if self.chunk_index >= len(self.chunks):
108
+ raise StopAsyncIteration
109
+ chunk = self.chunks[self.chunk_index]
110
+ self.chunk_index += 1
111
+ return chunk
112
+
113
+ async def get_final_message(self):
114
+ from kolega_code.llm.models import Message, TextBlock
115
+
116
+ return Message(role="assistant", content=[TextBlock(text="Test response")])
117
+
118
+ # stream method returns a coroutine that returns the mock stream
119
+ async def stream_coroutine(*args, **kwargs):
120
+ return SimpleStreamMock()
121
+
122
+ mock_llm_instance.stream = stream_coroutine
123
+
124
+ # Call think_hard
125
+ result = await think_hard_tool.think_hard("Test problem")
126
+
127
+ # Verify the result is a string
128
+ assert isinstance(result, str)
129
+ assert "# Final Analysis" in result
130
+ assert "Test response" in result
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_think_hard_logging(think_hard_tool):
135
+ """Test that think_hard logs appropriate messages."""
136
+ # Mock the logging methods
137
+ think_hard_tool.log_info = AsyncMock()
138
+ think_hard_tool.log_error = AsyncMock()
139
+
140
+ with patch("kolega_code.agent.tool_backend.think_hard_tool.LLMClient") as mock_llm_class:
141
+ with patch("kolega_code.agent.tool_backend.think_hard_tool.get_model_specs") as mock_get_specs:
142
+ # Mock model specs
143
+ mock_get_specs.return_value = {"max_completion_tokens": 8192}
144
+
145
+ mock_llm_instance = mock_llm_class.return_value
146
+
147
+ # Create a simple mock stream
148
+ class SimpleStreamMock:
149
+ def __init__(self):
150
+ self.chunks = [] # No chunks to iterate over
151
+ self.chunk_index = 0
152
+
153
+ async def __aenter__(self):
154
+ return self
155
+
156
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
157
+ return None
158
+
159
+ def __aiter__(self):
160
+ """Make the stream async iterable."""
161
+ return self
162
+
163
+ async def __anext__(self):
164
+ """Return chunks for async iteration."""
165
+ if self.chunk_index >= len(self.chunks):
166
+ raise StopAsyncIteration
167
+ chunk = self.chunks[self.chunk_index]
168
+ self.chunk_index += 1
169
+ return chunk
170
+
171
+ async def get_final_message(self):
172
+ from kolega_code.llm.models import Message, TextBlock
173
+
174
+ return Message(role="assistant", content=[TextBlock(text="Response")])
175
+
176
+ # stream method returns a coroutine that returns the mock stream
177
+ async def stream_coroutine(*args, **kwargs):
178
+ return SimpleStreamMock()
179
+
180
+ mock_llm_instance.stream = stream_coroutine
181
+
182
+ problem = "This is a very long problem statement that should be truncated in the log message"
183
+ await think_hard_tool.think_hard(problem)
184
+
185
+ # Verify logging was called
186
+ think_hard_tool.log_info.assert_called_once()
187
+ log_call = think_hard_tool.log_info.call_args[0][0]
188
+ assert "Thinking hard about:" in log_call
189
+ assert problem[:100] in log_call