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