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,455 @@
1
+ import asyncio
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from kolega_code import __version__
8
+ from kolega_code.cli.main import CLI_AGENT_MODE, RESUME_LATEST, _resolve_tui_session, main, parse_args
9
+ from kolega_code.cli.provider_registry import UI_DEFAULT_MODEL, UI_DEFAULT_PROVIDER
10
+ from kolega_code.cli.session_store import SessionStore, SessionStoreError
11
+ from kolega_code.cli.settings import CliSettings, SettingsStore
12
+ from kolega_code.llm.models import Message
13
+
14
+
15
+ def write_skill(root: Path, name: str = "demo-skill") -> None:
16
+ skill_dir = root / ".agents" / "skills" / name
17
+ skill_dir.mkdir(parents=True)
18
+ (skill_dir / "SKILL.md").write_text(
19
+ f"---\nname: {name}\ndescription: Use this demo skill.\n---\n\nFollow demo instructions.\n",
20
+ encoding="utf-8",
21
+ )
22
+
23
+
24
+ def test_parse_default_command_as_tui() -> None:
25
+ args = parse_args(["/tmp/project", "--new"])
26
+
27
+ assert args.command == "tui"
28
+ assert args.project_path == Path("/tmp/project")
29
+ assert args.new is True
30
+ assert args.resume is None
31
+ assert args.mode == CLI_AGENT_MODE
32
+
33
+
34
+ def test_version_flag_prints_package_version(capsys) -> None:
35
+ with pytest.raises(SystemExit) as exc_info:
36
+ parse_args(["--version"])
37
+
38
+ assert exc_info.value.code == 0
39
+ assert f"kolega-code {__version__}" in capsys.readouterr().out
40
+
41
+
42
+ def test_parse_tui_resume_latest() -> None:
43
+ args = parse_args(["/tmp/project", "--resume"])
44
+
45
+ assert args.command == "tui"
46
+ assert args.resume == RESUME_LATEST
47
+
48
+
49
+ def test_parse_tui_resume_specific_thread() -> None:
50
+ args = parse_args(["/tmp/project", "--resume", "thread-123"])
51
+
52
+ assert args.command == "tui"
53
+ assert args.resume == "thread-123"
54
+
55
+
56
+ def test_parse_tui_legacy_session_alias() -> None:
57
+ args = parse_args(["/tmp/project", "--session", "session-123"])
58
+
59
+ assert args.command == "tui"
60
+ assert args.session == "session-123"
61
+
62
+
63
+ def test_parse_ask_subcommand() -> None:
64
+ args = parse_args(["ask", "hello", "--project", "/tmp/project", "--save", "--json"])
65
+
66
+ assert args.command == "ask"
67
+ assert args.prompt == "hello"
68
+ assert args.project == Path("/tmp/project")
69
+ assert args.save is True
70
+ assert args.json is True
71
+ assert args.mode == CLI_AGENT_MODE
72
+
73
+
74
+ def test_parse_sessions_list_subcommand() -> None:
75
+ args = parse_args(["sessions", "list", "--project", "/tmp/project"])
76
+
77
+ assert args.command == "sessions"
78
+ assert args.sessions_command == "list"
79
+ assert args.project == Path("/tmp/project")
80
+
81
+
82
+ def test_ask_skills_lists_discovered_skills_without_api_key(
83
+ tmp_path: Path, capsys, isolated_cli_env: None
84
+ ) -> None:
85
+ project = tmp_path / "project"
86
+ project.mkdir()
87
+ write_skill(project)
88
+
89
+ exit_code = main(["ask", "/skills", "--project", str(project)])
90
+
91
+ assert exit_code == 0
92
+ output = capsys.readouterr().out
93
+ assert "`/demo-skill`" in output
94
+ assert "Use this demo skill." in output
95
+
96
+
97
+ def test_ask_skill_only_prints_activation_without_model_call(
98
+ tmp_path: Path, capsys, isolated_cli_env: None
99
+ ) -> None:
100
+ project = tmp_path / "project"
101
+ project.mkdir()
102
+ write_skill(project)
103
+
104
+ exit_code = main(["ask", "/demo-skill", "--project", str(project)])
105
+
106
+ assert exit_code == 0
107
+ output = capsys.readouterr().out
108
+ assert '<skill_content name="demo-skill">' in output
109
+ assert "Follow demo instructions." in output
110
+
111
+
112
+ def test_ask_skill_with_prompt_activates_before_dispatch(
113
+ tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
114
+ ) -> None:
115
+ from kolega_code.cli import main as main_module
116
+
117
+ class FakeCoderAgent:
118
+ instances = []
119
+
120
+ def __init__(self, **kwargs):
121
+ self.kwargs = kwargs
122
+ self.history = []
123
+ self.messages = []
124
+ self.cleaned = False
125
+ self.__class__.instances.append(self)
126
+
127
+ def append_user_message(self, content):
128
+ self.history.append(Message(role="user", content=content))
129
+
130
+ def restore_message_history(self, history):
131
+ self.history = [Message.from_dict(item) for item in history]
132
+
133
+ def dump_message_history(self):
134
+ return [message.to_dict() for message in self.history]
135
+
136
+ async def process_message_stream(self, message):
137
+ self.messages.append(message)
138
+ yield {"type": "response", "content": "ok", "complete": True, "uuid": "response-1"}
139
+
140
+ async def cleanup(self):
141
+ self.cleaned = True
142
+
143
+ project = tmp_path / "project"
144
+ project.mkdir()
145
+ write_skill(project)
146
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
147
+ monkeypatch.setattr(main_module, "CoderAgent", FakeCoderAgent)
148
+
149
+ exit_code = main_module.main(["ask", "/demo-skill do the task", "--project", str(project)])
150
+
151
+ assert exit_code == 0
152
+ output = capsys.readouterr().out
153
+ assert "ok" in output
154
+ agent = FakeCoderAgent.instances[0]
155
+ assert agent.messages == ["do the task"]
156
+ assert '<skill_content name="demo-skill">' in agent.history[0].get_text_content()
157
+ assert any(extension.name == "cli-agent-skills" for extension in agent.kwargs["tool_extensions"])
158
+
159
+
160
+ def test_doctor_uses_stored_kimi_settings(tmp_path: Path, capsys, isolated_cli_env: None) -> None:
161
+ project = tmp_path / "project"
162
+ project.mkdir()
163
+ state_dir = tmp_path / "state"
164
+ settings = CliSettings(active_provider=UI_DEFAULT_PROVIDER, active_model=UI_DEFAULT_MODEL)
165
+ settings.set_api_key(UI_DEFAULT_PROVIDER, "moonshot-key")
166
+ SettingsStore(state_dir).save(settings)
167
+
168
+ exit_code = main(["doctor", "--project", str(project), "--state-dir", str(state_dir)])
169
+
170
+ assert exit_code == 0
171
+ output = capsys.readouterr().out
172
+ assert f"Stored active model: {UI_DEFAULT_PROVIDER}/{UI_DEFAULT_MODEL}" in output
173
+ assert "present in local settings" in output
174
+ assert "moonshot-key" not in output
175
+
176
+
177
+ def test_tui_default_creates_new_session_even_when_latest_exists(tmp_path: Path) -> None:
178
+ project = tmp_path / "project"
179
+ project.mkdir()
180
+ store = SessionStore(tmp_path / "state")
181
+ existing = store.create(project, "code", {})
182
+
183
+ session = _resolve_tui_session(store, project, {}, resume=None, legacy_session_id=None)
184
+
185
+ assert session.session_id != existing.session_id
186
+ assert session.thread_id != existing.thread_id
187
+ assert session.mode == CLI_AGENT_MODE
188
+
189
+
190
+ def test_tui_resume_without_id_loads_latest_project_session(tmp_path: Path) -> None:
191
+ project = tmp_path / "project"
192
+ project.mkdir()
193
+ store = SessionStore(tmp_path / "state")
194
+ store.create(project, "code", {}, title="older")
195
+ newer = store.create(project, "code", {}, title="newer")
196
+
197
+ session = _resolve_tui_session(store, project, {}, resume=RESUME_LATEST, legacy_session_id=None)
198
+
199
+ assert session.session_id == newer.session_id
200
+ assert session.mode == CLI_AGENT_MODE
201
+ assert store.load(newer.session_id).mode == CLI_AGENT_MODE
202
+
203
+
204
+ def test_tui_resume_specific_session_id(tmp_path: Path) -> None:
205
+ project = tmp_path / "project"
206
+ project.mkdir()
207
+ store = SessionStore(tmp_path / "state")
208
+ existing = store.create(project, "code", {})
209
+
210
+ session = _resolve_tui_session(store, project, {}, resume=existing.session_id, legacy_session_id=None)
211
+
212
+ assert session.session_id == existing.session_id
213
+ assert session.mode == CLI_AGENT_MODE
214
+ assert store.load(existing.session_id).mode == CLI_AGENT_MODE
215
+
216
+
217
+ def test_tui_resume_specific_thread_id(tmp_path: Path) -> None:
218
+ project = tmp_path / "project"
219
+ project.mkdir()
220
+ store = SessionStore(tmp_path / "state")
221
+ existing = store.create(project, "code", {})
222
+
223
+ session = _resolve_tui_session(store, project, {}, resume=existing.thread_id, legacy_session_id=None)
224
+
225
+ assert session.session_id == existing.session_id
226
+ assert session.mode == CLI_AGENT_MODE
227
+
228
+
229
+ def test_tui_resume_missing_id_raises(tmp_path: Path) -> None:
230
+ project = tmp_path / "project"
231
+ project.mkdir()
232
+ store = SessionStore(tmp_path / "state")
233
+
234
+ with pytest.raises(SessionStoreError):
235
+ _resolve_tui_session(store, project, {}, resume="missing-thread", legacy_session_id=None)
236
+
237
+
238
+ def test_tui_resume_project_mismatch_raises(tmp_path: Path) -> None:
239
+ project = tmp_path / "project"
240
+ other_project = tmp_path / "other"
241
+ project.mkdir()
242
+ other_project.mkdir()
243
+ store = SessionStore(tmp_path / "state")
244
+ existing = store.create(other_project, "code", {})
245
+
246
+ with pytest.raises(SessionStoreError, match="belongs to project"):
247
+ _resolve_tui_session(store, project, {}, resume=existing.thread_id, legacy_session_id=None)
248
+
249
+
250
+ def test_tui_legacy_session_alias_loads_specific_session(tmp_path: Path) -> None:
251
+ project = tmp_path / "project"
252
+ project.mkdir()
253
+ store = SessionStore(tmp_path / "state")
254
+ existing = store.create(project, "code", {})
255
+
256
+ session = _resolve_tui_session(
257
+ store,
258
+ project,
259
+ {},
260
+ resume=None,
261
+ legacy_session_id=existing.session_id,
262
+ )
263
+
264
+ assert session.session_id == existing.session_id
265
+ assert session.mode == CLI_AGENT_MODE
266
+
267
+
268
+ def _sub_agent_test_event():
269
+ from kolega_code.events import AgentEvent
270
+
271
+ return AgentEvent(
272
+ event_type="chat_message",
273
+ sender="general-agent",
274
+ content={"status": "GENERATING", "message": "Starting general-agent task"},
275
+ sub_agent_info={
276
+ "agent_id": "agent-1",
277
+ "agent_name": "general-agent",
278
+ "task": "do sub-task",
279
+ "parent_tool_call_id": "exec-1",
280
+ "conversation_id": None,
281
+ "depth": 1,
282
+ },
283
+ )
284
+
285
+
286
+ class _SubAgentEventCoderAgent:
287
+ """Fake CoderAgent that broadcasts a sub-agent event mid-stream."""
288
+
289
+ instances = []
290
+
291
+ def __init__(self, **kwargs):
292
+ self.kwargs = kwargs
293
+ self.history = []
294
+ self.__class__.instances.append(self)
295
+
296
+ def append_user_message(self, content):
297
+ self.history.append(content)
298
+
299
+ def restore_message_history(self, history):
300
+ return None
301
+
302
+ def dump_message_history(self):
303
+ return []
304
+
305
+ async def process_message_stream(self, message):
306
+ yield {"type": "response", "content": "first ", "complete": False, "uuid": "response-1"}
307
+ manager = self.kwargs["connection_manager"]
308
+ await manager.broadcast_event(_sub_agent_test_event(), "ws", "thread")
309
+ # Give the event pump a chance to run before the final chunk
310
+ for _ in range(5):
311
+ await asyncio.sleep(0)
312
+ yield {"type": "response", "content": "second", "complete": True, "uuid": "response-1"}
313
+
314
+ async def cleanup(self):
315
+ return None
316
+
317
+
318
+ def test_ask_json_interleaves_sub_agent_events(
319
+ tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
320
+ ) -> None:
321
+ from kolega_code.cli import main as main_module
322
+
323
+ _SubAgentEventCoderAgent.instances = []
324
+ project = tmp_path / "project"
325
+ project.mkdir()
326
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
327
+ monkeypatch.setattr(main_module, "CoderAgent", _SubAgentEventCoderAgent)
328
+
329
+ exit_code = main_module.main(["ask", "do the task", "--project", str(project), "--json"])
330
+
331
+ assert exit_code == 0
332
+ lines = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()]
333
+ kinds = [line["kind"] for line in lines]
334
+ event_index = kinds.index("event")
335
+ final_chunk_index = max(i for i, line in enumerate(lines) if line["kind"] == "chunk")
336
+ assert event_index < final_chunk_index, "sub-agent event should interleave before the final chunk"
337
+ event_line = lines[event_index]
338
+ assert event_line["data"]["sub_agent_info"]["agent_name"] == "general-agent"
339
+
340
+
341
+ def test_ask_plain_writes_sub_agent_lifecycle_to_stderr(
342
+ tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
343
+ ) -> None:
344
+ from kolega_code.cli import main as main_module
345
+
346
+ _SubAgentEventCoderAgent.instances = []
347
+ project = tmp_path / "project"
348
+ project.mkdir()
349
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
350
+ monkeypatch.setattr(main_module, "CoderAgent", _SubAgentEventCoderAgent)
351
+
352
+ exit_code = main_module.main(["ask", "do the task", "--project", str(project)])
353
+
354
+ assert exit_code == 0
355
+ captured = capsys.readouterr()
356
+ assert captured.out.strip() == "first second"
357
+ from kolega_code.cli import theme
358
+
359
+ sep = theme.g(theme.Glyph.BULLET_SEP)
360
+ glyph = theme.g(theme.Glyph.SUB_AGENT)
361
+ assert f"{glyph} general-agent {sep} generating {sep} Starting general-agent task" in captured.err
362
+
363
+
364
+ def test_ask_prompt_with_file_mention_attaches_content(
365
+ tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
366
+ ) -> None:
367
+ from kolega_code.cli import main as main_module
368
+
369
+ class FakeCoderAgent:
370
+ instances = []
371
+
372
+ def __init__(self, **kwargs):
373
+ self.kwargs = kwargs
374
+ self.history = []
375
+ self.messages = []
376
+ self.attachments = []
377
+ self.__class__.instances.append(self)
378
+
379
+ def append_user_message(self, content):
380
+ self.history.append(Message(role="user", content=content))
381
+
382
+ def restore_message_history(self, history):
383
+ self.history = [Message.from_dict(item) for item in history]
384
+
385
+ def dump_message_history(self):
386
+ return [message.to_dict() for message in self.history]
387
+
388
+ async def process_message_stream(self, message, attachments=None):
389
+ self.messages.append(message)
390
+ self.attachments.append(attachments)
391
+ yield {"type": "response", "content": "ok", "complete": True, "uuid": "response-1"}
392
+
393
+ async def cleanup(self):
394
+ return None
395
+
396
+ project = tmp_path / "project"
397
+ project.mkdir()
398
+ (project / "notes.md").write_text("remember the milk\n", encoding="utf-8")
399
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
400
+ monkeypatch.setattr(main_module, "CoderAgent", FakeCoderAgent)
401
+
402
+ exit_code = main_module.main(["ask", "summarize @notes.md", "--project", str(project)])
403
+
404
+ assert exit_code == 0
405
+ assert "ok" in capsys.readouterr().out
406
+ agent = FakeCoderAgent.instances[0]
407
+ assert agent.messages == ["summarize @notes.md"]
408
+ attachments = agent.attachments[0]
409
+ assert attachments is not None and len(attachments) == 1
410
+ assert attachments[0]["type"] == "file"
411
+ assert attachments[0]["path"] == "notes.md"
412
+ assert attachments[0]["content"] == "remember the milk\n"
413
+
414
+
415
+ def test_ask_prompt_with_unresolved_mention_warns_on_stderr(
416
+ tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
417
+ ) -> None:
418
+ from kolega_code.cli import main as main_module
419
+
420
+ class FakeCoderAgent:
421
+ instances = []
422
+
423
+ def __init__(self, **kwargs):
424
+ self.kwargs = kwargs
425
+ self.history = []
426
+ self.attachments = []
427
+ self.__class__.instances.append(self)
428
+
429
+ def append_user_message(self, content):
430
+ self.history.append(Message(role="user", content=content))
431
+
432
+ def restore_message_history(self, history):
433
+ self.history = [Message.from_dict(item) for item in history]
434
+
435
+ def dump_message_history(self):
436
+ return [message.to_dict() for message in self.history]
437
+
438
+ async def process_message_stream(self, message, attachments=None):
439
+ self.attachments.append(attachments)
440
+ yield {"type": "response", "content": "ok", "complete": True, "uuid": "response-1"}
441
+
442
+ async def cleanup(self):
443
+ return None
444
+
445
+ project = tmp_path / "project"
446
+ project.mkdir()
447
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
448
+ monkeypatch.setattr(main_module, "CoderAgent", FakeCoderAgent)
449
+
450
+ exit_code = main_module.main(["ask", "summarize @missing.md", "--project", str(project)])
451
+
452
+ assert exit_code == 0
453
+ captured = capsys.readouterr()
454
+ assert "@missing.md not found" in captured.err
455
+ assert FakeCoderAgent.instances[0].attachments == [None]
@@ -0,0 +1,108 @@
1
+ """Tests for @ mention parsing and file attachment expansion."""
2
+
3
+ from pathlib import Path
4
+
5
+ from kolega_code.cli.mentions import (
6
+ MAX_ATTACHMENT_LINES,
7
+ MAX_DIR_ENTRIES,
8
+ build_file_attachments,
9
+ parse_mentions,
10
+ )
11
+
12
+
13
+ def test_parse_mentions_requires_start_or_whitespace() -> None:
14
+ assert parse_mentions("email user@example.com please") == []
15
+ assert [m.path for m in parse_mentions("@a.py and @b.py")] == ["a.py", "b.py"]
16
+ assert [m.path for m in parse_mentions("look at @src/main.py")] == ["src/main.py"]
17
+
18
+
19
+ def test_parse_mentions_strips_trailing_punctuation() -> None:
20
+ assert [m.path for m in parse_mentions("see @src/main.py, and @README.md.")] == ["src/main.py", "README.md"]
21
+ assert [m.path for m in parse_mentions("(@notes.txt)")] == [] # @ after ( is not a mention
22
+ assert [m.path for m in parse_mentions("read @notes.txt)")] == ["notes.txt"]
23
+
24
+
25
+ def test_parse_mentions_supports_quoted_paths() -> None:
26
+ assert [m.path for m in parse_mentions('open @"my docs/plan.md" now')] == ["my docs/plan.md"]
27
+
28
+
29
+ def test_build_file_attachments_reads_existing_file(tmp_path: Path) -> None:
30
+ (tmp_path / "hello.py").write_text("print('hi')\n", encoding="utf-8")
31
+ attachments, unresolved = build_file_attachments("check @hello.py", tmp_path)
32
+ assert unresolved == []
33
+ assert len(attachments) == 1
34
+ assert attachments[0]["type"] == "file"
35
+ assert attachments[0]["path"] == "hello.py"
36
+ assert attachments[0]["content"] == "print('hi')\n"
37
+ assert attachments[0]["truncated"] is False
38
+ assert attachments[0]["is_dir"] is False
39
+
40
+
41
+ def test_build_file_attachments_nonexistent_path_is_unresolved(tmp_path: Path) -> None:
42
+ attachments, unresolved = build_file_attachments("check @missing.py", tmp_path)
43
+ assert attachments == []
44
+ assert unresolved == ["missing.py"]
45
+
46
+
47
+ def test_build_file_attachments_dedupes_repeat_mentions(tmp_path: Path) -> None:
48
+ (tmp_path / "a.txt").write_text("x", encoding="utf-8")
49
+ attachments, _ = build_file_attachments("@a.txt and @a.txt again", tmp_path)
50
+ assert len(attachments) == 1
51
+
52
+
53
+ def test_build_file_attachments_truncates_long_files(tmp_path: Path) -> None:
54
+ (tmp_path / "big.txt").write_text("line\n" * (MAX_ATTACHMENT_LINES + 50), encoding="utf-8")
55
+ attachments, _ = build_file_attachments("@big.txt", tmp_path)
56
+ assert attachments[0]["truncated"] is True
57
+ assert "[truncated: showing first" in attachments[0]["content"]
58
+
59
+
60
+ def test_build_file_attachments_binary_file_gets_stub(tmp_path: Path) -> None:
61
+ (tmp_path / "blob.dat").write_bytes(b"\x00\x01\x02binary")
62
+ attachments, _ = build_file_attachments("@blob.dat", tmp_path)
63
+ assert attachments[0]["content"] == "[binary file - content not attached]"
64
+
65
+
66
+ def test_build_file_attachments_allows_gitignored_files(tmp_path: Path) -> None:
67
+ (tmp_path / ".gitignore").write_text("secret.txt\n", encoding="utf-8")
68
+ (tmp_path / "secret.txt").write_text("hidden", encoding="utf-8")
69
+ attachments, unresolved = build_file_attachments("@secret.txt", tmp_path)
70
+ assert unresolved == []
71
+ assert attachments[0]["content"] == "hidden"
72
+
73
+
74
+ def test_build_file_attachments_directory_shallow_listing(tmp_path: Path) -> None:
75
+ pkg = tmp_path / "pkg"
76
+ (pkg / "sub").mkdir(parents=True)
77
+ (pkg / "mod.py").write_text("", encoding="utf-8")
78
+ attachments, _ = build_file_attachments("@pkg/", tmp_path)
79
+ assert attachments[0]["is_dir"] is True
80
+ assert attachments[0]["path"] == "pkg"
81
+ assert "sub/" in attachments[0]["content"]
82
+ assert "mod.py" in attachments[0]["content"]
83
+
84
+
85
+ def test_build_file_attachments_directory_listing_is_capped(tmp_path: Path) -> None:
86
+ crowd = tmp_path / "crowd"
87
+ crowd.mkdir()
88
+ for index in range(MAX_DIR_ENTRIES + 10):
89
+ (crowd / f"f{index:04}.txt").write_text("", encoding="utf-8")
90
+ attachments, _ = build_file_attachments("@crowd", tmp_path)
91
+ assert f"[truncated: showing first {MAX_DIR_ENTRIES} of {MAX_DIR_ENTRIES + 10} entries]" in attachments[0]["content"]
92
+
93
+
94
+ def test_build_file_attachments_rejects_paths_outside_root(tmp_path: Path) -> None:
95
+ project = tmp_path / "project"
96
+ project.mkdir()
97
+ outside = tmp_path / "outside.txt"
98
+ outside.write_text("nope", encoding="utf-8")
99
+ attachments, unresolved = build_file_attachments(f"@{outside}", project)
100
+ assert attachments == []
101
+ assert unresolved == [str(outside)]
102
+
103
+
104
+ def test_build_file_attachments_accepts_absolute_path_inside_root(tmp_path: Path) -> None:
105
+ (tmp_path / "inside.txt").write_text("yes", encoding="utf-8")
106
+ attachments, unresolved = build_file_attachments(f"@{tmp_path / 'inside.txt'}", tmp_path)
107
+ assert unresolved == []
108
+ assert attachments[0]["path"] == "inside.txt"
@@ -0,0 +1,67 @@
1
+ from pathlib import Path
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from kolega_code.cli.session_store import SessionStore, SessionStoreError, default_state_dir
7
+
8
+
9
+ def test_default_state_dir_honors_env() -> None:
10
+ assert default_state_dir({"KOLEGA_CODE_STATE_DIR": "/tmp/kolega-test"}) == Path("/tmp/kolega-test")
11
+
12
+
13
+ def test_session_store_create_load_list_export_delete(tmp_path: Path) -> None:
14
+ project = tmp_path / "project"
15
+ project.mkdir()
16
+ store = SessionStore(tmp_path / "state")
17
+
18
+ record = store.create(project, "code", {"long_model": "claude-opus-4-7"}, title="Project")
19
+ record.history = [{"role": "user", "content": []}]
20
+ record.task_list_markdown = "- [ ] inspect\n- [x] plan"
21
+ record.latest_plan_markdown = "# Plan\n\nImplement it."
22
+ record.interaction_mode = "plan"
23
+ store.save(record)
24
+
25
+ loaded = store.load(record.session_id)
26
+ assert loaded.project_path == str(project.resolve())
27
+ assert loaded.history == [{"role": "user", "content": []}]
28
+ assert loaded.task_list_markdown == "- [ ] inspect\n- [x] plan"
29
+ assert loaded.latest_plan_markdown == "# Plan\n\nImplement it."
30
+ assert loaded.interaction_mode == "plan"
31
+ assert store.latest_for_project(project).session_id == record.session_id
32
+ exported = store.export(record.session_id)
33
+ assert record.session_id in exported
34
+ assert "task_list_markdown" in exported
35
+ assert "latest_plan_markdown" in exported
36
+ assert "interaction_mode" in exported
37
+
38
+ store.delete(record.session_id)
39
+ with pytest.raises(SessionStoreError):
40
+ store.load(record.session_id)
41
+
42
+
43
+ def test_session_store_loads_old_sessions_without_planning_state(tmp_path: Path) -> None:
44
+ project = tmp_path / "project"
45
+ project.mkdir()
46
+ store = SessionStore(tmp_path / "state")
47
+
48
+ record = store.create(project, "code", {})
49
+ payload = record.to_dict()
50
+ payload.pop("task_list_markdown")
51
+ payload.pop("latest_plan_markdown")
52
+ payload.pop("interaction_mode")
53
+ store.path_for(record.session_id).write_text(json.dumps(payload), encoding="utf-8")
54
+
55
+ loaded = store.load(record.session_id)
56
+
57
+ assert loaded.task_list_markdown == ""
58
+ assert loaded.latest_plan_markdown == ""
59
+ assert loaded.interaction_mode == "build"
60
+
61
+
62
+ def test_session_store_ignores_corrupt_files_when_listing(tmp_path: Path) -> None:
63
+ store = SessionStore(tmp_path / "state")
64
+ store.ensure_dirs()
65
+ (store.sessions_dir / "bad.json").write_text("{not json", encoding="utf-8")
66
+
67
+ assert store.list() == []
@@ -0,0 +1,62 @@
1
+ import os
2
+ import stat
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from kolega_code.cli.provider_registry import (
8
+ DEEPSEEK_DEFAULT_MODEL,
9
+ UI_DEFAULT_MODEL,
10
+ UI_DEFAULT_PROVIDER,
11
+ get_ui_model,
12
+ ui_model_options,
13
+ ui_provider_options,
14
+ )
15
+ from kolega_code.cli.settings import CliSettings, SettingsStore, SettingsStoreError
16
+
17
+
18
+ def test_settings_store_round_trip_and_file_permissions(tmp_path: Path) -> None:
19
+ store = SettingsStore(tmp_path)
20
+ settings = CliSettings(active_provider=UI_DEFAULT_PROVIDER, active_model=UI_DEFAULT_MODEL)
21
+ settings.set_api_key(UI_DEFAULT_PROVIDER, "secret-key")
22
+
23
+ store.save(settings)
24
+
25
+ loaded = store.load()
26
+ assert loaded.active_provider == UI_DEFAULT_PROVIDER
27
+ assert loaded.active_model == UI_DEFAULT_MODEL
28
+ assert loaded.get_api_key(UI_DEFAULT_PROVIDER) == "secret-key"
29
+
30
+ if os.name != "nt":
31
+ assert stat.S_IMODE(store.path.stat().st_mode) == 0o600
32
+
33
+
34
+ def test_settings_store_missing_file_returns_empty_settings(tmp_path: Path) -> None:
35
+ settings = SettingsStore(tmp_path).load()
36
+
37
+ assert settings.active_provider is None
38
+ assert settings.active_model is None
39
+ assert settings.api_keys == {}
40
+
41
+
42
+ def test_settings_store_rejects_corrupt_json(tmp_path: Path) -> None:
43
+ store = SettingsStore(tmp_path)
44
+ store.root.mkdir(parents=True, exist_ok=True)
45
+ store.path.write_text("{bad json", encoding="utf-8")
46
+
47
+ with pytest.raises(SettingsStoreError):
48
+ store.load()
49
+
50
+
51
+ def test_ui_provider_registry_supports_kimi_and_deepseek() -> None:
52
+ assert ui_provider_options() == [("Moonshot AI", UI_DEFAULT_PROVIDER), ("DeepSeek AI", "deepseek")]
53
+ assert ui_model_options(UI_DEFAULT_PROVIDER) == [("Kimi K2.6", UI_DEFAULT_MODEL)]
54
+ assert ui_model_options("deepseek") == [("DeepSeek V4 Pro", DEEPSEEK_DEFAULT_MODEL)]
55
+
56
+ model = get_ui_model(UI_DEFAULT_PROVIDER, UI_DEFAULT_MODEL)
57
+ assert model is not None
58
+ assert model.api_key_env == "MOONSHOT_API_KEY"
59
+
60
+ deepseek_model = get_ui_model("deepseek", DEEPSEEK_DEFAULT_MODEL)
61
+ assert deepseek_model is not None
62
+ assert deepseek_model.api_key_env == "DEEPSEEK_API_KEY"