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,330 @@
1
+ """Tests for coder agent image attachment handling."""
2
+
3
+ import base64
4
+ from types import SimpleNamespace
5
+ from unittest.mock import AsyncMock, Mock
6
+
7
+ import pytest
8
+
9
+ from kolega_code.agent.baseagent import BaseAgent
10
+ from kolega_code.agent.coder import CoderAgent
11
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
12
+ from kolega_code.llm.models import ImageBlock, Message, TextBlock
13
+ from kolega_code.llm.providers.models import TokenCount
14
+ from kolega_code.agent.prompt_provider import AgentMode
15
+
16
+
17
+ def _deepseek_config() -> AgentConfig:
18
+ model_config = ModelConfig(
19
+ provider=ModelProvider.DEEPSEEK,
20
+ model="deepseek-v4-pro",
21
+ rate_limits=RateLimitConfig(),
22
+ )
23
+ return AgentConfig(
24
+ deepseek_api_key="test-key",
25
+ long_context_config=model_config,
26
+ fast_config=model_config,
27
+ edit_model_config=model_config,
28
+ thinking_config=model_config,
29
+ )
30
+
31
+
32
+ def _image_attachment() -> dict:
33
+ return {
34
+ "type": "image",
35
+ "media_type": "image/png",
36
+ "data": base64.b64encode(b"fake-image-data").decode("utf-8"),
37
+ "filename": "test-image.png",
38
+ }
39
+
40
+
41
+ class _EmptyStream:
42
+ async def __aenter__(self):
43
+ return self
44
+
45
+ async def __aexit__(self, exc_type, exc, tb):
46
+ return False
47
+
48
+ def __aiter__(self):
49
+ return self
50
+
51
+ async def __anext__(self):
52
+ raise StopAsyncIteration
53
+
54
+ async def get_final_message(self):
55
+ return Message("assistant", [TextBlock("done")], stop_reason="end_turn")
56
+
57
+
58
+ def test_deepseek_image_attachment_is_rejected_by_provider_check():
59
+ agent = object.__new__(BaseAgent)
60
+ agent.config = SimpleNamespace(
61
+ long_context_config=SimpleNamespace(provider=ModelProvider.DEEPSEEK.value),
62
+ )
63
+
64
+ assert (
65
+ agent._unsupported_attachment_message([_image_attachment()])
66
+ == BaseAgent.deepseek_image_unsupported_message
67
+ )
68
+
69
+
70
+ def test_deepseek_attachment_check_allows_non_images_and_other_providers():
71
+ agent = object.__new__(BaseAgent)
72
+ agent.config = SimpleNamespace(
73
+ long_context_config=SimpleNamespace(provider=ModelProvider.DEEPSEEK),
74
+ )
75
+ assert agent._unsupported_attachment_message(None) is None
76
+ assert agent._unsupported_attachment_message([{"type": "document", "data": "abc"}]) is None
77
+
78
+ agent.config.long_context_config.provider = ModelProvider.ANTHROPIC
79
+ assert agent._unsupported_attachment_message([_image_attachment()]) is None
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_coder_agent_rejects_deepseek_image_without_llm_call(tmp_path):
84
+ connection_manager = Mock()
85
+ connection_manager.broadcast_event = AsyncMock()
86
+ agent = CoderAgent(
87
+ project_path=tmp_path,
88
+ workspace_id="workspace-123",
89
+ thread_id="thread-123",
90
+ connection_manager=connection_manager,
91
+ config=_deepseek_config(),
92
+ agent_mode=AgentMode.CLI,
93
+ )
94
+ agent.llm = Mock()
95
+
96
+ chunks = [
97
+ chunk
98
+ async for chunk in agent.process_message_stream("What is in this image?", [_image_attachment()])
99
+ ]
100
+
101
+ assert len(chunks) == 1
102
+ assert chunks[0]["type"] == "response"
103
+ assert chunks[0]["content"] == BaseAgent.deepseek_image_unsupported_message
104
+ assert chunks[0]["complete"] is True
105
+ assert agent.history == []
106
+ agent.llm.stream.assert_not_called()
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_coder_agent_does_not_print_context_token_counts(tmp_path, capsys):
111
+ connection_manager = Mock()
112
+ connection_manager.broadcast_event = AsyncMock()
113
+ agent = CoderAgent(
114
+ project_path=tmp_path,
115
+ workspace_id="workspace-123",
116
+ thread_id="thread-123",
117
+ connection_manager=connection_manager,
118
+ config=_deepseek_config(),
119
+ agent_mode=AgentMode.CLI,
120
+ )
121
+ agent.count_current_context = AsyncMock(return_value=TokenCount(input_tokens=42))
122
+ agent.llm = Mock()
123
+ agent.llm.stream = AsyncMock(return_value=_EmptyStream())
124
+
125
+ chunks = [chunk async for chunk in agent.process_message_stream("hello")]
126
+
127
+ assert chunks[-1]["complete"] is True
128
+ assert capsys.readouterr().out == ""
129
+
130
+
131
+ class TestCoderAgentAttachments:
132
+ """Test suite for verifying image attachment handling logic."""
133
+
134
+ def test_single_image_attachment_processing(self):
135
+ """Test that a single image attachment is correctly processed into an ImageBlock."""
136
+ test_image_data = base64.b64encode(b"fake-image-data").decode("utf-8")
137
+ attachment = {
138
+ "type": "image",
139
+ "media_type": "image/png",
140
+ "data": test_image_data,
141
+ "filename": "test-image.png",
142
+ }
143
+
144
+ # Process the attachment as the coder agent would
145
+ image_block = ImageBlock(
146
+ image_type="base64", media_type=attachment.get("media_type", "image/png"), data=attachment["data"]
147
+ )
148
+
149
+ assert image_block.image_type == "base64"
150
+ assert image_block.media_type == "image/png"
151
+ assert image_block.data == test_image_data
152
+
153
+ def test_multiple_image_attachments_processing(self):
154
+ """Test that multiple image attachments are correctly processed."""
155
+ attachments = [
156
+ {
157
+ "type": "image",
158
+ "media_type": "image/png",
159
+ "data": base64.b64encode(b"image1").decode("utf-8"),
160
+ "filename": "image1.png",
161
+ },
162
+ {
163
+ "type": "image",
164
+ "media_type": "image/jpeg",
165
+ "data": base64.b64encode(b"image2").decode("utf-8"),
166
+ "filename": "image2.jpg",
167
+ },
168
+ ]
169
+
170
+ # Process attachments as the coder agent would
171
+ image_blocks = []
172
+ for attachment in attachments:
173
+ if attachment.get("type") == "image":
174
+ image_block = ImageBlock(
175
+ image_type="base64", media_type=attachment.get("media_type", "image/png"), data=attachment["data"]
176
+ )
177
+ image_blocks.append(image_block)
178
+
179
+ assert len(image_blocks) == 2
180
+ assert image_blocks[0].media_type == "image/png"
181
+ assert image_blocks[1].media_type == "image/jpeg"
182
+
183
+ def test_non_image_attachments_filtered(self):
184
+ """Test that non-image attachments are filtered out."""
185
+ attachments = [
186
+ {
187
+ "type": "document",
188
+ "media_type": "application/pdf",
189
+ "data": base64.b64encode(b"pdf-data").decode("utf-8"),
190
+ "filename": "document.pdf",
191
+ },
192
+ {
193
+ "type": "image",
194
+ "media_type": "image/png",
195
+ "data": base64.b64encode(b"image-data").decode("utf-8"),
196
+ "filename": "image.png",
197
+ },
198
+ ]
199
+
200
+ # Process attachments, filtering non-images
201
+ image_blocks = []
202
+ for attachment in attachments:
203
+ if attachment.get("type") == "image":
204
+ image_block = ImageBlock(
205
+ image_type="base64", media_type=attachment.get("media_type", "image/png"), data=attachment["data"]
206
+ )
207
+ image_blocks.append(image_block)
208
+
209
+ assert len(image_blocks) == 1
210
+ assert image_blocks[0].media_type == "image/png"
211
+
212
+ def test_empty_attachments_handling(self):
213
+ """Test that empty or None attachments are handled gracefully."""
214
+ # Test with None
215
+ image_blocks = []
216
+ attachments = None
217
+ if attachments:
218
+ for attachment in attachments:
219
+ if attachment.get("type") == "image":
220
+ image_block = ImageBlock(
221
+ image_type="base64",
222
+ media_type=attachment.get("media_type", "image/png"),
223
+ data=attachment["data"],
224
+ )
225
+ image_blocks.append(image_block)
226
+
227
+ assert len(image_blocks) == 0
228
+
229
+ # Test with empty list
230
+ image_blocks = []
231
+ attachments = []
232
+ for attachment in attachments:
233
+ if attachment.get("type") == "image":
234
+ image_block = ImageBlock(
235
+ image_type="base64", media_type=attachment.get("media_type", "image/png"), data=attachment["data"]
236
+ )
237
+ image_blocks.append(image_block)
238
+
239
+ assert len(image_blocks) == 0
240
+
241
+ def test_message_content_with_attachments(self):
242
+ """Test that message content is correctly structured with text and image blocks."""
243
+ test_message = "What is in this image?"
244
+ test_image_data = base64.b64encode(b"fake-image-data").decode("utf-8")
245
+ attachments = [
246
+ {
247
+ "type": "image",
248
+ "media_type": "image/png",
249
+ "data": test_image_data,
250
+ "filename": "test-image.png",
251
+ }
252
+ ]
253
+
254
+ # Build content blocks as the coder agent would
255
+ content_blocks = [TextBlock(text=test_message)]
256
+
257
+ if attachments:
258
+ for attachment in attachments:
259
+ if attachment.get("type") == "image":
260
+ image_block = ImageBlock(
261
+ image_type="base64",
262
+ media_type=attachment.get("media_type", "image/png"),
263
+ data=attachment["data"],
264
+ )
265
+ content_blocks.append(image_block)
266
+
267
+ assert len(content_blocks) == 2
268
+ assert isinstance(content_blocks[0], TextBlock)
269
+ assert content_blocks[0].text == test_message
270
+ assert isinstance(content_blocks[1], ImageBlock)
271
+ assert content_blocks[1].data == test_image_data
272
+
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_coder_agent_process_message_imports():
276
+ """Test that the coder agent has the necessary imports for image handling."""
277
+ # This test verifies the imports are correct
278
+ from kolega_code.agent.coder import CoderAgent
279
+ from kolega_code.llm.models import ImageBlock, TextBlock
280
+
281
+ # Just verify the imports work
282
+ assert ImageBlock is not None
283
+ assert TextBlock is not None
284
+
285
+
286
+ def test_attachment_blocks_mixes_images_and_files():
287
+ agent = object.__new__(BaseAgent)
288
+ blocks = agent._attachment_blocks(
289
+ [
290
+ _image_attachment(),
291
+ {"type": "file", "path": "src/app.py", "content": "print('hi')"},
292
+ {"type": "unknown", "data": "ignored"},
293
+ ]
294
+ )
295
+
296
+ assert len(blocks) == 2
297
+ assert isinstance(blocks[0], ImageBlock)
298
+ assert isinstance(blocks[1], TextBlock)
299
+ assert blocks[1].text == '<attached-file path="src/app.py">\nprint(\'hi\')\n</attached-file>'
300
+
301
+
302
+ def test_attachment_blocks_handles_none():
303
+ agent = object.__new__(BaseAgent)
304
+ assert agent._attachment_blocks(None) == []
305
+
306
+
307
+ @pytest.mark.asyncio
308
+ async def test_coder_agent_file_attachment_added_to_history(tmp_path):
309
+ connection_manager = Mock()
310
+ connection_manager.broadcast_event = AsyncMock()
311
+ agent = CoderAgent(
312
+ project_path=tmp_path,
313
+ workspace_id="workspace-123",
314
+ thread_id="thread-123",
315
+ connection_manager=connection_manager,
316
+ config=_deepseek_config(),
317
+ agent_mode=AgentMode.CLI,
318
+ )
319
+ agent.count_current_context = AsyncMock(return_value=TokenCount(input_tokens=42))
320
+ agent.llm = Mock()
321
+ agent.llm.stream = AsyncMock(return_value=_EmptyStream())
322
+
323
+ attachments = [{"type": "file", "path": "notes.md", "content": "remember the milk"}]
324
+ chunks = [chunk async for chunk in agent.process_message_stream("see the notes", attachments)]
325
+
326
+ assert chunks[-1]["complete"] is True
327
+ user_message = agent.history[0]
328
+ texts = [block.text for block in user_message.content if isinstance(block, TextBlock)]
329
+ assert texts[0] == "see the notes"
330
+ assert texts[1] == '<attached-file path="notes.md">\nremember the milk\n</attached-file>'
@@ -0,0 +1,61 @@
1
+ from unittest.mock import AsyncMock, Mock
2
+
3
+ from kolega_code.agent.coder import CoderAgent
4
+ from kolega_code.config import AgentConfig, ModelConfig, ModelProvider, RateLimitConfig
5
+ from kolega_code.agent.prompt_provider import AgentMode, AgentType, PromptExtension, PromptProvider
6
+
7
+
8
+ def test_coder_agent_includes_matching_prompt_extensions(tmp_path):
9
+ config = AgentConfig(
10
+ anthropic_api_key="test-key",
11
+ openai_api_key="test-key",
12
+ long_context_config=ModelConfig(
13
+ provider=ModelProvider.ANTHROPIC,
14
+ model="claude-sonnet-4-20250514",
15
+ rate_limits=RateLimitConfig(),
16
+ ),
17
+ fast_config=ModelConfig(
18
+ provider=ModelProvider.ANTHROPIC,
19
+ model="claude-3-haiku-20240307",
20
+ rate_limits=RateLimitConfig(),
21
+ ),
22
+ thinking_config=ModelConfig(
23
+ provider=ModelProvider.ANTHROPIC,
24
+ model="claude-3-7-sonnet-20250219",
25
+ rate_limits=RateLimitConfig(),
26
+ thinking_tokens=1024,
27
+ ),
28
+ )
29
+
30
+ connection_manager = Mock()
31
+ connection_manager.broadcast_event = AsyncMock()
32
+ template_dir = tmp_path / "prompt_templates"
33
+ agents_dir = template_dir / "agents"
34
+ agents_dir.mkdir(parents=True)
35
+ (agents_dir / "coder_code_mode.j2").write_text(
36
+ "{% for extension in prompt_extensions %}{{ extension.title }}\n{{ extension.markdown }}{% endfor %}",
37
+ encoding="utf-8",
38
+ )
39
+
40
+ agent = CoderAgent(
41
+ project_path=tmp_path,
42
+ workspace_id="workspace-123",
43
+ thread_id="thread-123",
44
+ connection_manager=connection_manager,
45
+ config=config,
46
+ agent_mode=AgentMode.CODE,
47
+ prompt_provider=PromptProvider(template_dirs=[template_dir]),
48
+ prompt_extensions=[
49
+ PromptExtension(
50
+ id="host-context",
51
+ title="Host Context",
52
+ markdown="Injected host-specific context.",
53
+ agent_types=[AgentType.CODER],
54
+ modes=[AgentMode.CODE],
55
+ )
56
+ ],
57
+ )
58
+
59
+ prompt = agent.system_prompt.content[0].text
60
+ assert "Host Context" in prompt
61
+ assert "Injected host-specific context." in prompt
@@ -0,0 +1,179 @@
1
+ from unittest.mock import AsyncMock, MagicMock, create_autospec
2
+
3
+ import pytest
4
+
5
+ from kolega_code.llm.models import Message, MessageHistory
6
+ from ..utils.commands import CommandProcessor
7
+
8
+
9
+ class MockAgent:
10
+ """Mock agent class for testing CommandProcessor"""
11
+
12
+ def __init__(self):
13
+ self.history = MessageHistory()
14
+ self.command_processor = CommandProcessor(self)
15
+ self.compress_history = AsyncMock()
16
+ self.count_current_context = AsyncMock()
17
+
18
+
19
+ @pytest.fixture
20
+ def mock_agent():
21
+ """Fixture to create a mock agent for testing"""
22
+ return MockAgent()
23
+
24
+
25
+ @pytest.fixture
26
+ def command_processor(mock_agent):
27
+ """Fixture to create a CommandProcessor instance with a mock agent"""
28
+ return mock_agent.command_processor
29
+
30
+
31
+ @pytest.fixture
32
+ def mock_message():
33
+ """Fixture to create a mock Message object"""
34
+ message = create_autospec(Message)
35
+ message.role = "user"
36
+ message.content = "Test message"
37
+ return message
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_handle_help(command_processor):
42
+ """Test the /help command handler"""
43
+ help_text = await command_processor._handle_help()
44
+
45
+ # Verify help text contains all available commands
46
+ assert "/help" in help_text
47
+ assert "/compress" in help_text
48
+ assert "/clear" in help_text
49
+ assert "/reset" in help_text
50
+ assert "/context" in help_text
51
+
52
+ # Verify help text formatting
53
+ assert help_text.startswith("# Available Commands")
54
+ assert all(line.startswith("- `/") for line in help_text.split("\n")[2:])
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_handle_compress(command_processor, mock_agent):
59
+ """Test the /compress command handler"""
60
+ result = await command_processor._handle_compress()
61
+
62
+ # Verify compress was called and returned expected message
63
+ mock_agent.compress_history.assert_called_once()
64
+ assert result == "Message history compressed."
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_handle_clear(command_processor, mock_agent, mock_message):
69
+ """Test the /clear command handler"""
70
+ # Add some messages to history first
71
+ mock_agent.history.append(mock_message)
72
+ mock_agent.history.append(mock_message)
73
+ assert len(mock_agent.history) == 2
74
+
75
+ result = await command_processor._handle_clear()
76
+
77
+ # Verify history was cleared and returned expected message
78
+ assert len(mock_agent.history) == 0
79
+ assert result == "Message history cleared."
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_handle_reset_alias(command_processor, mock_agent, mock_message):
84
+ """Test the /reset command alias"""
85
+ mock_agent.history.append(mock_message)
86
+ mock_agent.history.append(mock_message)
87
+ assert len(mock_agent.history) == 2
88
+
89
+ result = await command_processor.commands["/reset"]()
90
+
91
+ assert len(mock_agent.history) == 0
92
+ assert result == "Message history cleared."
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_handle_context_empty_history(command_processor):
97
+ """Test the /context command handler with empty history"""
98
+ result = await command_processor._handle_context()
99
+ assert result == "Current context token count: 0"
100
+
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_handle_context_with_history(command_processor, mock_agent, mock_message):
104
+ """Test the /context command handler with non-empty history"""
105
+ # Add mock message to history
106
+ mock_agent.history.append(mock_message)
107
+
108
+ # Mock token count response
109
+ mock_token_count = MagicMock()
110
+ mock_token_count.input_tokens = 100
111
+ mock_agent.count_current_context.return_value = mock_token_count
112
+
113
+ result = await command_processor._handle_context()
114
+
115
+ # Verify token count was called and returned expected message
116
+ mock_agent.count_current_context.assert_called_once()
117
+ assert result == "Current context token count: 100"
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_process_commands_decorator():
122
+ """Test the process_commands decorator functionality"""
123
+
124
+ @CommandProcessor.process_commands
125
+ class TestAgent:
126
+ def __init__(self):
127
+ self.history = MessageHistory()
128
+ self.command_processor = CommandProcessor(self)
129
+
130
+ async def process_message_stream(self, message, attachments=None):
131
+ yield {"type": "response", "content": "Normal processing", "complete": True}
132
+
133
+ agent = TestAgent()
134
+
135
+ # Test command processing
136
+ responses = []
137
+ async for response in agent.process_message_stream("/help"):
138
+ responses.append(response)
139
+
140
+ assert len(responses) == 1
141
+ assert responses[0]["type"] == "response"
142
+ assert "Available Commands" in responses[0]["content"]
143
+ assert responses[0]["complete"] is True
144
+
145
+ # Test normal message processing
146
+ responses = []
147
+ async for response in agent.process_message_stream("normal message"):
148
+ responses.append(response)
149
+
150
+ assert len(responses) == 1
151
+ assert responses[0]["type"] == "response"
152
+ assert responses[0]["content"] == "Normal processing"
153
+ assert responses[0]["complete"] is True
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_process_commands_invalid_command():
158
+ """Test processing an invalid command"""
159
+
160
+ @CommandProcessor.process_commands
161
+ class TestAgent:
162
+ def __init__(self):
163
+ self.history = MessageHistory()
164
+ self.command_processor = CommandProcessor(self)
165
+
166
+ async def process_message_stream(self, message, attachments=None):
167
+ yield {"type": "response", "content": "Normal processing", "complete": True}
168
+
169
+ agent = TestAgent()
170
+
171
+ # Test invalid command
172
+ responses = []
173
+ async for response in agent.process_message_stream("/invalid"):
174
+ responses.append(response)
175
+
176
+ assert len(responses) == 1
177
+ assert responses[0]["type"] == "response"
178
+ assert responses[0]["content"] == "Normal processing"
179
+ assert responses[0]["complete"] is True