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,4251 @@
1
+ from pathlib import Path
2
+ import asyncio
3
+
4
+ import pytest
5
+
6
+ from kolega_code.config import ModelProvider
7
+ from kolega_code.llm.models import Message, TextBlock, ToolCall, ToolResult
8
+ from kolega_code.events import AgentEvent
9
+ from kolega_code.agent.prompt_provider import AgentMode
10
+ from kolega_code.cli.config import build_agent_config, config_summary
11
+ from kolega_code.cli.provider_registry import DEEPSEEK_DEFAULT_MODEL, UI_DEFAULT_MODEL, UI_DEFAULT_PROVIDER
12
+ from kolega_code.cli.session_store import SessionStore
13
+ from kolega_code.cli.settings import CliSettings, SettingsStore
14
+
15
+
16
+ def extension_by_name(extensions, name: str):
17
+ return next(
18
+ extension
19
+ for extension in extensions
20
+ if getattr(extension, "name", None) == name or getattr(extension, "id", None) == name
21
+ )
22
+
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_textual_app_mounts_with_fake_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
26
+ pytest.importorskip("textual")
27
+
28
+ from textual.containers import VerticalScroll
29
+ from textual.widgets import Collapsible, Header, Markdown
30
+
31
+ from kolega_code.cli import app as app_module
32
+ from kolega_code.cli.app import KolegaCodeApp
33
+
34
+ class FakeCoderAgent:
35
+ def __init__(self, **kwargs):
36
+ self.kwargs = kwargs
37
+ self.history_restored = False
38
+
39
+ def restore_message_history(self, history):
40
+ self.history_restored = bool(history)
41
+
42
+ def dump_message_history(self):
43
+ return []
44
+
45
+ async def cleanup(self):
46
+ return None
47
+
48
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
49
+
50
+ project = tmp_path / "project"
51
+ project.mkdir()
52
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
53
+ store = SessionStore(tmp_path / "state")
54
+ session = store.create(project, "code", config_summary(config))
55
+
56
+ app = KolegaCodeApp(
57
+ project_path=project,
58
+ config=config,
59
+ mode="code",
60
+ store=store,
61
+ session=session,
62
+ )
63
+
64
+ async with app.run_test():
65
+ assert isinstance(app.agent, FakeCoderAgent)
66
+ assert app.mode == AgentMode.CLI.value
67
+ assert app.interaction_mode == "build"
68
+ assert app.session.mode == AgentMode.CLI.value
69
+ assert app.agent.kwargs["agent_mode"] == AgentMode.CLI
70
+ assert list(app.query(Header)) == []
71
+ assert app.query_one("#conversation") is not None
72
+ assert app.query_one("#composer") is not None
73
+ assert app.query_one("#planning_pane") is not None
74
+ assert app.query_one("#planning_form", VerticalScroll) is not None
75
+ assert app.query_one("#planning_plan", Collapsible).collapsed is False
76
+ assert app.query_one("#planning_task_list", Collapsible).collapsed is False
77
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "No plan captured yet."
78
+ assert app.query_one("#planning_task_list_markdown", Markdown).source == "No task list has been set."
79
+ assert app.conversation_entries[0].kind == "startup"
80
+ startup = app.conversation_entries[0].content
81
+ assert "____ _" in startup
82
+ assert f"Project: {project}" in startup
83
+ assert f"Session: {session.session_id[:8]}" in startup
84
+ assert "Mode: cli" in startup
85
+ assert "Interaction: build" in startup
86
+ expected_model = f"{config.long_context_config.provider.value}/{config.long_context_config.model}"
87
+ assert f"Model: {expected_model}" in startup
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_textual_app_status_tab_is_default_dashboard(
92
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
93
+ ) -> None:
94
+ pytest.importorskip("textual")
95
+
96
+ from textual.widgets import Static, TabbedContent
97
+
98
+ from kolega_code.cli import app as app_module
99
+ from kolega_code.cli.app import KolegaCodeApp
100
+
101
+ class FakeCoderAgent:
102
+ def __init__(self, **kwargs):
103
+ self.kwargs = kwargs
104
+
105
+ def restore_message_history(self, history):
106
+ return None
107
+
108
+ def dump_message_history(self):
109
+ return []
110
+
111
+ async def cleanup(self):
112
+ return None
113
+
114
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
115
+
116
+ project = tmp_path / "project"
117
+ project.mkdir()
118
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
119
+ store = SessionStore(tmp_path / "state")
120
+ session = store.create(project, "code", config_summary(config))
121
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
122
+
123
+ async with app.run_test():
124
+ assert app.query_one("#events", TabbedContent).active == "status_pane"
125
+ dashboard_widget = app.query_one("#status_dashboard", Static)
126
+ dashboard = str(dashboard_widget.render())
127
+
128
+ assert "Status" in dashboard
129
+ assert f"{config.long_context_config.provider.value}/{config.long_context_config.model}" in dashboard
130
+ assert "Build" in dashboard
131
+ assert "Idle" in dashboard
132
+ assert "Waiting for first context count" in dashboard
133
+ assert dashboard_widget.styles.border == app.query_one("#logs").styles.border
134
+ assert list(app.query("#status")) == []
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_textual_app_context_usage_updates_status_without_raw_json(
139
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
140
+ ) -> None:
141
+ pytest.importorskip("textual")
142
+
143
+ from textual.widgets import Static
144
+
145
+ from kolega_code.cli import app as app_module
146
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
147
+
148
+ class FakeCoderAgent:
149
+ def __init__(self, **kwargs):
150
+ self.kwargs = kwargs
151
+
152
+ def restore_message_history(self, history):
153
+ return None
154
+
155
+ def dump_message_history(self):
156
+ return []
157
+
158
+ async def cleanup(self):
159
+ return None
160
+
161
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
162
+
163
+ project = tmp_path / "project"
164
+ project.mkdir()
165
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
166
+ store = SessionStore(tmp_path / "state")
167
+ session = store.create(project, "code", config_summary(config))
168
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
169
+
170
+ async with app.run_test():
171
+ composer = app.query_one("#composer", ChatComposer)
172
+ app._render_event(
173
+ AgentEvent(
174
+ event_type="llm_context_update",
175
+ sender="coder",
176
+ content={
177
+ "input_tokens": 123456,
178
+ "max_tokens": 200000,
179
+ "usage_percentage": 61.7,
180
+ "alert_level": "info",
181
+ "message": "Context is getting large.",
182
+ "compression_threshold": 80.0,
183
+ },
184
+ )
185
+ )
186
+ dashboard = str(app.query_one("#status_dashboard", Static).render())
187
+
188
+ assert "61.7%" in dashboard
189
+ assert "123,456 / 200,000" in dashboard
190
+ assert "Compresses at 80%" in dashboard
191
+ assert "Context is getting large." in dashboard
192
+ assert "input_tokens" not in dashboard
193
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
194
+
195
+ app._render_event(AgentEvent(event_type="status_update", sender="coder", content={"input_tokens": 5}))
196
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
197
+
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_textual_app_status_dashboard_tracks_interaction_mode(
201
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
202
+ ) -> None:
203
+ pytest.importorskip("textual")
204
+
205
+ from textual.widgets import Static
206
+
207
+ from kolega_code.cli import app as app_module
208
+ from kolega_code.cli.app import KolegaCodeApp
209
+
210
+ class FakeAgent:
211
+ def __init__(self, **kwargs):
212
+ self.kwargs = kwargs
213
+
214
+ def restore_message_history(self, history):
215
+ return None
216
+
217
+ def dump_message_history(self):
218
+ return []
219
+
220
+ async def cleanup(self):
221
+ return None
222
+
223
+ monkeypatch.setattr(app_module, "CoderAgent", FakeAgent)
224
+ monkeypatch.setattr(app_module, "PlanningAgent", FakeAgent)
225
+
226
+ project = tmp_path / "project"
227
+ project.mkdir()
228
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
229
+ store = SessionStore(tmp_path / "state")
230
+ session = store.create(project, "code", config_summary(config))
231
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
232
+
233
+ async with app.run_test():
234
+ await app._set_interaction_mode("plan")
235
+ dashboard = str(app.query_one("#status_dashboard", Static).render())
236
+ assert "Plan" in dashboard
237
+
238
+ await app._set_interaction_mode("build")
239
+ dashboard = str(app.query_one("#status_dashboard", Static).render())
240
+ assert "Build" in dashboard
241
+
242
+
243
+ @pytest.mark.asyncio
244
+ async def test_textual_app_turn_status_formats_error_duration(
245
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
246
+ ) -> None:
247
+ pytest.importorskip("textual")
248
+
249
+ from textual.widgets import Static
250
+
251
+ from kolega_code.cli import app as app_module
252
+ from kolega_code.cli.app import KolegaCodeApp, TurnState
253
+
254
+ class FakeCoderAgent:
255
+ def __init__(self, **kwargs):
256
+ self.kwargs = kwargs
257
+
258
+ def restore_message_history(self, history):
259
+ return None
260
+
261
+ def dump_message_history(self):
262
+ return []
263
+
264
+ async def cleanup(self):
265
+ return None
266
+
267
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
268
+
269
+ project = tmp_path / "project"
270
+ project.mkdir()
271
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
272
+ store = SessionStore(tmp_path / "state")
273
+ session = store.create(project, "code", config_summary(config))
274
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
275
+ now = 0.0
276
+ monkeypatch.setattr(app, "_now", lambda: now)
277
+
278
+ async with app.run_test():
279
+ app._begin_turn_progress()
280
+ now = 83.0
281
+ app._finish_turn_progress("Stopped due to an error: boom", TurnState.ERROR)
282
+
283
+ assert "Errored after 1m 23s" in str(app.query_one("#turn_status", Static).render())
284
+
285
+
286
+ def test_turn_state_styles_do_not_depend_on_content_text() -> None:
287
+ pytest.importorskip("textual")
288
+
289
+ from kolega_code.cli.app import TURN_STATE_STYLES, TurnState
290
+
291
+ assert TURN_STATE_STYLES[TurnState.ERROR] == "red"
292
+ assert TURN_STATE_STYLES[TurnState.STOPPED] == "yellow"
293
+ assert TURN_STATE_STYLES[TurnState.STOPPING] == "yellow"
294
+ assert TURN_STATE_STYLES[TurnState.IDLE] == "green"
295
+ assert TURN_STATE_STYLES.get(TurnState.GENERATING) is None # falls back to accent
296
+
297
+
298
+ @pytest.mark.asyncio
299
+ async def test_progress_entry_tone_drives_styling_not_prose(
300
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
301
+ ) -> None:
302
+ pytest.importorskip("textual")
303
+
304
+ from kolega_code.cli import app as app_module
305
+ from kolega_code.cli.app import ConversationEntry, KolegaCodeApp, TurnState
306
+
307
+ class FakeCoderAgent:
308
+ def __init__(self, **kwargs):
309
+ self.kwargs = kwargs
310
+
311
+ def restore_message_history(self, history):
312
+ return None
313
+
314
+ def dump_message_history(self):
315
+ return []
316
+
317
+ async def cleanup(self):
318
+ return None
319
+
320
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
321
+
322
+ project = tmp_path / "project"
323
+ project.mkdir()
324
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
325
+ store = SessionStore(tmp_path / "state")
326
+ session = store.create(project, "code", config_summary(config))
327
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
328
+
329
+ async with app.run_test():
330
+ # Prose mentioning "error" with a warning tone must not render as an error
331
+ warning_entry = ConversationEntry(
332
+ kind="progress", content="Stopped before the error handler ran", complete=True, tone="warning"
333
+ )
334
+ rendered = app._format_conversation_entry(warning_entry)
335
+ assert "[yellow]" in str(rendered)
336
+ assert "[red]" not in str(rendered)
337
+
338
+ error_entry = ConversationEntry(kind="progress", content="All good otherwise", complete=True, tone="error")
339
+ rendered = app._format_conversation_entry(error_entry)
340
+ assert "[red]" in str(rendered)
341
+
342
+ # Explicit state drives the dashboard, not content keywords
343
+ app._turn_active = True
344
+ app._begin_turn_progress()
345
+ app._finish_turn_progress("Wrapped up without issue", TurnState.STOPPED)
346
+ assert app._status_state.turn_state is TurnState.STOPPED
347
+
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_textual_app_keeps_command_c_for_screen_copy(
351
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
352
+ ) -> None:
353
+ pytest.importorskip("textual")
354
+
355
+ from textual.widgets import Markdown
356
+
357
+ from kolega_code.cli import app as app_module
358
+ from kolega_code.cli.app import KolegaCodeApp
359
+
360
+ class FakeCoderAgent:
361
+ def __init__(self, **kwargs):
362
+ self.kwargs = kwargs
363
+
364
+ def restore_message_history(self, history):
365
+ return None
366
+
367
+ def dump_message_history(self):
368
+ return []
369
+
370
+ async def cleanup(self):
371
+ return None
372
+
373
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
374
+
375
+ project = tmp_path / "project"
376
+ project.mkdir()
377
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
378
+ store = SessionStore(tmp_path / "state")
379
+ session = store.create(project, "code", config_summary(config))
380
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
381
+
382
+ async with app.run_test():
383
+ cancel_binding = next(binding for binding in app.BINDINGS if binding.action == "cancel_generation")
384
+ assert cancel_binding.key == "ctrl+c"
385
+ assert all("super+c" not in binding.key for binding in app.BINDINGS)
386
+
387
+
388
+ @pytest.mark.asyncio
389
+ async def test_textual_app_shift_tab_toggles_between_build_and_plan_agents(
390
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
391
+ ) -> None:
392
+ pytest.importorskip("textual")
393
+
394
+ from textual.widgets import Markdown
395
+
396
+ from kolega_code.cli import app as app_module
397
+ from kolega_code.cli.app import BUILD_INTERACTION_MODE, PLAN_INTERACTION_MODE, KolegaCodeApp, PendingQuestion
398
+
399
+ class FakeBaseAgent:
400
+ def __init__(self, **kwargs):
401
+ self.kwargs = kwargs
402
+ self.history = []
403
+ self.cleaned = False
404
+
405
+ def restore_message_history(self, history):
406
+ self.history = list(history)
407
+
408
+ def dump_message_history(self):
409
+ return self.history
410
+
411
+ async def cleanup(self):
412
+ self.cleaned = True
413
+
414
+ class FakeCoderAgent(FakeBaseAgent):
415
+ pass
416
+
417
+ class FakePlanningAgent(FakeBaseAgent):
418
+ pass
419
+
420
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
421
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
422
+
423
+ project = tmp_path / "project"
424
+ project.mkdir()
425
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
426
+ store = SessionStore(tmp_path / "state")
427
+ session = store.create(project, "code", config_summary(config))
428
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
429
+
430
+ async with app.run_test() as pilot:
431
+ toggle_binding = next(binding for binding in app.BINDINGS if binding.action == "toggle_interaction_mode")
432
+ assert toggle_binding.key == "shift+tab"
433
+ assert toggle_binding.key_display == "Shift+Tab"
434
+ assert toggle_binding.priority is True
435
+
436
+ assert isinstance(app.agent, FakeCoderAgent)
437
+ assert app.interaction_mode == BUILD_INTERACTION_MODE
438
+
439
+ await pilot.press("shift+tab")
440
+
441
+ assert app.interaction_mode == PLAN_INTERACTION_MODE
442
+ assert isinstance(app.agent, FakePlanningAgent)
443
+ startup = app.conversation_entries[0].content
444
+ assert "Interaction: plan" in startup
445
+
446
+ app._latest_plan = "# Plan\n\nDo it."
447
+ app._plan_decision_active = False
448
+ app._set_plan_actions_visible(True)
449
+ question_future = asyncio.get_running_loop().create_future()
450
+ app._pending_question = PendingQuestion(
451
+ question="Choose?",
452
+ options=["A", "B"],
453
+ future=question_future,
454
+ )
455
+ app._set_question_actions_visible(True)
456
+
457
+ await pilot.press("shift+tab")
458
+
459
+ assert app.interaction_mode == BUILD_INTERACTION_MODE
460
+ assert isinstance(app.agent, FakeCoderAgent)
461
+ assert app._latest_plan == "# Plan\n\nDo it."
462
+ assert app._plan_decision_active is False
463
+ assert app._pending_question is None
464
+ assert question_future.cancelled()
465
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "# Plan\n\nDo it."
466
+ assert app.query_one("#plan_actions").display is False
467
+ assert app.query_one("#question_actions").display is False
468
+ loaded = store.load(session.session_id)
469
+ assert loaded.latest_plan_markdown == "# Plan\n\nDo it."
470
+ assert loaded.interaction_mode == BUILD_INTERACTION_MODE
471
+
472
+
473
+ @pytest.mark.asyncio
474
+ async def test_textual_app_restores_saved_plan_and_interaction_mode(
475
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
476
+ ) -> None:
477
+ pytest.importorskip("textual")
478
+
479
+ from textual.widgets import Markdown
480
+
481
+ from kolega_code.cli import app as app_module
482
+ from kolega_code.cli.app import ActionList, ChatComposer, KolegaCodeApp
483
+
484
+ class FakeBaseAgent:
485
+ def __init__(self, **kwargs):
486
+ self.kwargs = kwargs
487
+ self.history = []
488
+
489
+ def restore_message_history(self, history):
490
+ self.history = list(history)
491
+
492
+ def dump_message_history(self):
493
+ return self.history
494
+
495
+ async def cleanup(self):
496
+ return None
497
+
498
+ class FakeCoderAgent(FakeBaseAgent):
499
+ pass
500
+
501
+ class FakePlanningAgent(FakeBaseAgent):
502
+ pass
503
+
504
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
505
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
506
+
507
+ saved_plan = "# Saved plan\n\nUse the restored plan."
508
+ saved_history = [Message(role="assistant", content=[TextBlock("saved response")]).to_dict()]
509
+ project = tmp_path / "project"
510
+ project.mkdir()
511
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
512
+ store = SessionStore(tmp_path / "state")
513
+ session = store.create(project, "code", config_summary(config))
514
+ session.history = saved_history
515
+ session.latest_plan_markdown = saved_plan
516
+ session.interaction_mode = "plan"
517
+ store.save(session)
518
+
519
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
520
+
521
+ async with app.run_test():
522
+ assert app.interaction_mode == "plan"
523
+ assert isinstance(app.agent, FakePlanningAgent)
524
+ assert app._latest_plan == saved_plan
525
+ assert app._plan_decision_active is False
526
+ assert app.query_one("#planning_plan_markdown", Markdown).source == saved_plan
527
+ plan_actions = app.query_one("#plan_actions", ActionList)
528
+ assert plan_actions.display is True
529
+ assert [option.id for option in plan_actions.options] == ["implement_plan"]
530
+ assert app.query_one("#composer", ChatComposer).disabled is False
531
+
532
+
533
+ @pytest.mark.asyncio
534
+ async def test_textual_app_restores_saved_plan_in_build_mode_without_plan_actions(
535
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
536
+ ) -> None:
537
+ pytest.importorskip("textual")
538
+
539
+ from textual.widgets import Markdown
540
+
541
+ from kolega_code.cli import app as app_module
542
+ from kolega_code.cli.app import KolegaCodeApp
543
+
544
+ class FakeCoderAgent:
545
+ def __init__(self, **kwargs):
546
+ self.kwargs = kwargs
547
+
548
+ def restore_message_history(self, history):
549
+ return None
550
+
551
+ def dump_message_history(self):
552
+ return []
553
+
554
+ async def cleanup(self):
555
+ return None
556
+
557
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
558
+
559
+ saved_plan = "# Saved plan\n\nKeep this visible."
560
+ project = tmp_path / "project"
561
+ project.mkdir()
562
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
563
+ store = SessionStore(tmp_path / "state")
564
+ session = store.create(project, "code", config_summary(config))
565
+ session.latest_plan_markdown = saved_plan
566
+ session.interaction_mode = "build"
567
+ store.save(session)
568
+
569
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
570
+
571
+ async with app.run_test():
572
+ assert app.interaction_mode == "build"
573
+ assert isinstance(app.agent, FakeCoderAgent)
574
+ assert app._latest_plan == saved_plan
575
+ assert app.query_one("#planning_plan_markdown", Markdown).source == saved_plan
576
+ assert app.query_one("#plan_actions").display is False
577
+
578
+
579
+ @pytest.mark.asyncio
580
+ async def test_textual_app_invalid_saved_interaction_mode_falls_back_to_build(
581
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
582
+ ) -> None:
583
+ pytest.importorskip("textual")
584
+
585
+ from kolega_code.cli import app as app_module
586
+ from kolega_code.cli.app import BUILD_INTERACTION_MODE, KolegaCodeApp
587
+
588
+ class FakeCoderAgent:
589
+ def __init__(self, **kwargs):
590
+ self.kwargs = kwargs
591
+
592
+ def restore_message_history(self, history):
593
+ return None
594
+
595
+ def dump_message_history(self):
596
+ return []
597
+
598
+ async def cleanup(self):
599
+ return None
600
+
601
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
602
+
603
+ project = tmp_path / "project"
604
+ project.mkdir()
605
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
606
+ store = SessionStore(tmp_path / "state")
607
+ session = store.create(project, "code", config_summary(config))
608
+ session.interaction_mode = "invalid"
609
+ store.save(session)
610
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
611
+
612
+ async with app.run_test():
613
+ assert app.interaction_mode == BUILD_INTERACTION_MODE
614
+ assert app.session.interaction_mode == BUILD_INTERACTION_MODE
615
+ assert isinstance(app.agent, FakeCoderAgent)
616
+
617
+
618
+ @pytest.mark.asyncio
619
+ async def test_textual_app_passes_shared_task_list_tools_to_build_and_plan_agents(
620
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
621
+ ) -> None:
622
+ pytest.importorskip("textual")
623
+
624
+ from textual.widgets import Markdown
625
+
626
+ from kolega_code.cli import app as app_module
627
+ from kolega_code.cli.app import KolegaCodeApp
628
+
629
+ class FakeBaseAgent:
630
+ def __init__(self, **kwargs):
631
+ self.kwargs = kwargs
632
+ self.history = []
633
+
634
+ def restore_message_history(self, history):
635
+ self.history = list(history)
636
+
637
+ def dump_message_history(self):
638
+ return self.history
639
+
640
+ async def cleanup(self):
641
+ return None
642
+
643
+ class FakeCoderAgent(FakeBaseAgent):
644
+ pass
645
+
646
+ class FakePlanningAgent(FakeBaseAgent):
647
+ pass
648
+
649
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
650
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
651
+
652
+ project = tmp_path / "project"
653
+ project.mkdir()
654
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
655
+ store = SessionStore(tmp_path / "state")
656
+ session = store.create(project, "code", config_summary(config))
657
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
658
+
659
+ async with app.run_test() as pilot:
660
+ assert isinstance(app.agent, FakeCoderAgent)
661
+ build_tools = extension_by_name(app.agent.kwargs["tool_extensions"], "cli-shared-task-list").tools
662
+ assert {"get_task_list", "update_task_list"} == set(build_tools)
663
+ assert all("ask_user_choice" not in extension.tools for extension in app.agent.kwargs["tool_extensions"])
664
+ build_task_list_prompt = app.agent.kwargs["prompt_extensions"][0].markdown
665
+ assert "After each meaningful task is completed" in build_task_list_prompt
666
+ assert "Do not wait until every TODO is complete" in build_task_list_prompt
667
+ update_task_list_doc = build_tools["update_task_list"].__doc__ or ""
668
+ assert "progress is visible incrementally" in update_task_list_doc
669
+ assert "do not wait" in update_task_list_doc.lower()
670
+
671
+ assert await build_tools["get_task_list"]() == "No task list has been set."
672
+ assert await build_tools["update_task_list"]("- [ ] inspect\n- [x] plan") == "Task list updated."
673
+ assert app.session.task_list_markdown == "- [ ] inspect\n- [x] plan"
674
+ assert app.query_one("#planning_task_list_markdown", Markdown).source == "- [ ] inspect\n- [x] plan"
675
+ assert store.load(session.session_id).task_list_markdown == "- [ ] inspect\n- [x] plan"
676
+
677
+ await pilot.press("shift+tab")
678
+
679
+ assert isinstance(app.agent, FakePlanningAgent)
680
+ plan_tools = extension_by_name(app.agent.kwargs["tool_extensions"], "cli-shared-task-list").tools
681
+ question_tools = extension_by_name(app.agent.kwargs["tool_extensions"], "cli-planning-questions").tools
682
+ prompt_markdown = "\n".join(extension.markdown for extension in app.agent.kwargs["prompt_extensions"])
683
+ assert await plan_tools["get_task_list"]() == "- [ ] inspect\n- [x] plan"
684
+ assert await plan_tools["update_task_list"]("- [x] inspect\n- [x] plan") == "Task list updated."
685
+ assert {"ask_user_choice"} == set(question_tools)
686
+ assert "multiple-choice" in prompt_markdown
687
+ assert app.session.task_list_markdown == "- [x] inspect\n- [x] plan"
688
+ assert app.query_one("#planning_task_list_markdown", Markdown).source == "- [x] inspect\n- [x] plan"
689
+
690
+
691
+ @pytest.mark.asyncio
692
+ async def test_textual_app_passes_skill_extensions_to_build_and_plan_agents(
693
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
694
+ ) -> None:
695
+ pytest.importorskip("textual")
696
+
697
+ from kolega_code.cli import app as app_module
698
+ from kolega_code.cli.app import KolegaCodeApp
699
+
700
+ class FakeBaseAgent:
701
+ def __init__(self, **kwargs):
702
+ self.kwargs = kwargs
703
+ self.history = []
704
+
705
+ def restore_message_history(self, history):
706
+ self.history = list(history)
707
+
708
+ def dump_message_history(self):
709
+ return self.history
710
+
711
+ async def cleanup(self):
712
+ return None
713
+
714
+ class FakeCoderAgent(FakeBaseAgent):
715
+ pass
716
+
717
+ class FakePlanningAgent(FakeBaseAgent):
718
+ pass
719
+
720
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
721
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
722
+
723
+ project = tmp_path / "project"
724
+ skill_dir = project / ".agents" / "skills" / "demo-skill"
725
+ skill_dir.mkdir(parents=True)
726
+ (skill_dir / "SKILL.md").write_text(
727
+ "---\nname: demo-skill\ndescription: Use this demo skill.\n---\n\nFollow demo instructions.\n",
728
+ encoding="utf-8",
729
+ )
730
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
731
+ store = SessionStore(tmp_path / "state")
732
+ session = store.create(project, "code", config_summary(config))
733
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
734
+
735
+ async with app.run_test() as pilot:
736
+ skill_prompt = extension_by_name(app.agent.kwargs["prompt_extensions"], "cli-agent-skills")
737
+ skill_tools = extension_by_name(app.agent.kwargs["tool_extensions"], "cli-agent-skills").tools
738
+
739
+ assert "demo-skill" in skill_prompt.markdown
740
+ assert {"list_skills", "activate_skill", "read_skill_resource"} == set(skill_tools)
741
+ assert "demo-skill" in await skill_tools["list_skills"]()
742
+
743
+ await pilot.press("shift+tab")
744
+
745
+ planning_skill_tools = extension_by_name(app.agent.kwargs["tool_extensions"], "cli-agent-skills")
746
+ assert "activate_skill" in planning_skill_tools.tools
747
+
748
+
749
+ @pytest.mark.asyncio
750
+ async def test_textual_app_skill_slash_commands_list_and_activate(
751
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
752
+ ) -> None:
753
+ pytest.importorskip("textual")
754
+
755
+ from kolega_code.cli import app as app_module
756
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
757
+
758
+ class FakeCoderAgent:
759
+ def __init__(self, **kwargs):
760
+ self.kwargs = kwargs
761
+ self.history = []
762
+
763
+ def append_user_message(self, content):
764
+ self.history.append(Message(role="user", content=content))
765
+
766
+ def restore_message_history(self, history):
767
+ self.history = [Message.from_dict(item) for item in history]
768
+
769
+ def dump_message_history(self):
770
+ return [message.to_dict() for message in self.history]
771
+
772
+ async def cleanup(self):
773
+ return None
774
+
775
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
776
+
777
+ project = tmp_path / "project"
778
+ skill_dir = project / ".agents" / "skills" / "demo-skill"
779
+ skill_dir.mkdir(parents=True)
780
+ (skill_dir / "SKILL.md").write_text(
781
+ "---\nname: demo-skill\ndescription: Use this demo skill.\n---\n\nFollow demo instructions.\n",
782
+ encoding="utf-8",
783
+ )
784
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
785
+ store = SessionStore(tmp_path / "state")
786
+ session = store.create(project, "code", config_summary(config))
787
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
788
+
789
+ async with app.run_test():
790
+ composer = app.query_one("#composer", ChatComposer)
791
+ composer.load_text("/skills")
792
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
793
+
794
+ assert app.conversation_entries[-1].kind == "system"
795
+ assert "`/demo-skill`" in app.conversation_entries[-1].content
796
+
797
+ composer.load_text("/demo-skill")
798
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
799
+
800
+ assert app.conversation_entries[-1].kind == "skill"
801
+ assert '<skill_content name="demo-skill">' in app.agent.history[-1].get_text_content()
802
+ assert '<skill_content name="demo-skill">' in store.load(session.session_id).history[-1]["content"][0]["text"]
803
+
804
+
805
+ @pytest.mark.asyncio
806
+ async def test_textual_app_skill_slash_command_with_prompt_starts_turn(
807
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
808
+ ) -> None:
809
+ pytest.importorskip("textual")
810
+
811
+ from kolega_code.cli import app as app_module
812
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
813
+
814
+ class FakeCoderAgent:
815
+ def __init__(self, **kwargs):
816
+ self.kwargs = kwargs
817
+ self.history = []
818
+ self.messages = []
819
+
820
+ def append_user_message(self, content):
821
+ self.history.append(Message(role="user", content=content))
822
+
823
+ def restore_message_history(self, history):
824
+ self.history = [Message.from_dict(item) for item in history]
825
+
826
+ def dump_message_history(self):
827
+ return [message.to_dict() for message in self.history]
828
+
829
+ async def cleanup(self):
830
+ return None
831
+
832
+ async def process_message_stream(self, message):
833
+ self.messages.append(message)
834
+ yield {"type": "response", "content": "done", "complete": True, "uuid": "response-1"}
835
+
836
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
837
+
838
+ project = tmp_path / "project"
839
+ skill_dir = project / ".agents" / "skills" / "demo-skill"
840
+ skill_dir.mkdir(parents=True)
841
+ (skill_dir / "SKILL.md").write_text(
842
+ "---\nname: demo-skill\ndescription: Use this demo skill.\n---\n\nFollow demo instructions.\n",
843
+ encoding="utf-8",
844
+ )
845
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
846
+ store = SessionStore(tmp_path / "state")
847
+ session = store.create(project, "code", config_summary(config))
848
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
849
+
850
+ async with app.run_test() as pilot:
851
+ composer = app.query_one("#composer", ChatComposer)
852
+ composer.load_text("/demo-skill Build the feature")
853
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
854
+ await pilot.pause()
855
+
856
+ assert app.agent.messages == ["Build the feature"]
857
+ assert any(entry.kind == "skill" for entry in app.conversation_entries)
858
+ assert any(entry.kind == "user" and entry.content == "Build the feature" for entry in app.conversation_entries)
859
+
860
+
861
+ @pytest.mark.asyncio
862
+ async def test_textual_app_planning_question_tool_accepts_option_list_answer(
863
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
864
+ ) -> None:
865
+ pytest.importorskip("textual")
866
+
867
+ from textual.widgets import OptionList
868
+
869
+ from kolega_code.cli import app as app_module
870
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ActionList, ChatComposer, KolegaCodeApp
871
+
872
+ class FakeBaseAgent:
873
+ def __init__(self, **kwargs):
874
+ self.kwargs = kwargs
875
+ self.history = []
876
+
877
+ def restore_message_history(self, history):
878
+ self.history = list(history)
879
+
880
+ def dump_message_history(self):
881
+ return self.history
882
+
883
+ async def cleanup(self):
884
+ return None
885
+
886
+ class FakeCoderAgent(FakeBaseAgent):
887
+ pass
888
+
889
+ class FakePlanningAgent(FakeBaseAgent):
890
+ pass
891
+
892
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
893
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
894
+
895
+ project = tmp_path / "project"
896
+ project.mkdir()
897
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
898
+ store = SessionStore(tmp_path / "state")
899
+ session = store.create(project, "code", config_summary(config))
900
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
901
+
902
+ async with app.run_test() as pilot:
903
+ await app.action_toggle_interaction_mode()
904
+ ask_user_choice = extension_by_name(
905
+ app.agent.kwargs["tool_extensions"], "cli-planning-questions"
906
+ ).tools["ask_user_choice"]
907
+
908
+ app._turn_active = True
909
+ answer_task = asyncio.create_task(
910
+ ask_user_choice("Which approach should we use?", ["Keep state local", "Persist it"])
911
+ )
912
+ await pilot.pause()
913
+
914
+ assert app._pending_question is not None
915
+ assert app.query_one("#composer", ChatComposer).disabled is False
916
+ question_actions = app.query_one("#question_actions", ActionList)
917
+ assert question_actions.display is True
918
+ assert app.focused is question_actions
919
+ assert question_actions.highlighted == 0
920
+ assert question_actions.get_option("question_option_0").prompt == "1. Keep state local"
921
+ assert app.conversation_entries[-1].kind == "question"
922
+
923
+ selected = question_actions.get_option("question_option_1")
924
+ await app.on_option_list_option_selected(OptionList.OptionSelected(question_actions, selected, 1))
925
+
926
+ assert await answer_task == "Persist it"
927
+ assert app._pending_question is None
928
+ assert app.query_one("#question_actions").display is False
929
+ assert app.query_one("#composer", ChatComposer).disabled is True
930
+ assert app.query_one("#composer", ChatComposer).placeholder == COMPOSER_PLACEHOLDER
931
+ assert app.conversation_entries[-1].kind == "user"
932
+ assert app.conversation_entries[-1].content == "Persist it"
933
+ app._turn_active = False
934
+
935
+
936
+ @pytest.mark.asyncio
937
+ async def test_textual_app_planning_question_supports_arrow_and_digit_selection(
938
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
939
+ ) -> None:
940
+ pytest.importorskip("textual")
941
+
942
+ from kolega_code.cli import app as app_module
943
+ from kolega_code.cli.app import ActionList, KolegaCodeApp
944
+
945
+ class FakeBaseAgent:
946
+ def __init__(self, **kwargs):
947
+ self.kwargs = kwargs
948
+ self.history = []
949
+
950
+ def restore_message_history(self, history):
951
+ self.history = list(history)
952
+
953
+ def dump_message_history(self):
954
+ return self.history
955
+
956
+ async def cleanup(self):
957
+ return None
958
+
959
+ class FakeCoderAgent(FakeBaseAgent):
960
+ pass
961
+
962
+ class FakePlanningAgent(FakeBaseAgent):
963
+ pass
964
+
965
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
966
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
967
+
968
+ project = tmp_path / "project"
969
+ project.mkdir()
970
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
971
+ store = SessionStore(tmp_path / "state")
972
+ session = store.create(project, "code", config_summary(config))
973
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
974
+
975
+ async with app.run_test() as pilot:
976
+ await app.action_toggle_interaction_mode()
977
+ ask_user_choice = extension_by_name(
978
+ app.agent.kwargs["tool_extensions"], "cli-planning-questions"
979
+ ).tools["ask_user_choice"]
980
+
981
+ options = ["Alpha", "Beta", "Gamma", "Delta"]
982
+ answer_task = asyncio.create_task(ask_user_choice("Pick one of four?", options))
983
+ await pilot.pause()
984
+
985
+ question_actions = app.query_one("#question_actions", ActionList)
986
+ assert question_actions.option_count == 4
987
+ assert app.focused is question_actions
988
+
989
+ await pilot.press("down", "down", "enter")
990
+ assert await answer_task == "Gamma"
991
+ assert question_actions.display is False
992
+
993
+ answer_task = asyncio.create_task(ask_user_choice("Pick again?", options))
994
+ await pilot.pause()
995
+
996
+ assert app.focused is app.query_one("#question_actions", ActionList)
997
+ await pilot.press("4")
998
+ assert await answer_task == "Delta"
999
+
1000
+
1001
+ @pytest.mark.asyncio
1002
+ async def test_textual_app_planning_question_tool_accepts_custom_text_answer(
1003
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1004
+ ) -> None:
1005
+ pytest.importorskip("textual")
1006
+
1007
+ from kolega_code.cli import app as app_module
1008
+ from kolega_code.cli.app import ActionList, ChatComposer, KolegaCodeApp
1009
+
1010
+ class FakeBaseAgent:
1011
+ def __init__(self, **kwargs):
1012
+ self.kwargs = kwargs
1013
+ self.history = []
1014
+
1015
+ def restore_message_history(self, history):
1016
+ self.history = list(history)
1017
+
1018
+ def dump_message_history(self):
1019
+ return self.history
1020
+
1021
+ async def cleanup(self):
1022
+ return None
1023
+
1024
+ class FakeCoderAgent(FakeBaseAgent):
1025
+ pass
1026
+
1027
+ class FakePlanningAgent(FakeBaseAgent):
1028
+ pass
1029
+
1030
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1031
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
1032
+
1033
+ project = tmp_path / "project"
1034
+ project.mkdir()
1035
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1036
+ store = SessionStore(tmp_path / "state")
1037
+ session = store.create(project, "code", config_summary(config))
1038
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1039
+
1040
+ async with app.run_test() as pilot:
1041
+ await app.action_toggle_interaction_mode()
1042
+ ask_user_choice = extension_by_name(
1043
+ app.agent.kwargs["tool_extensions"], "cli-planning-questions"
1044
+ ).tools["ask_user_choice"]
1045
+
1046
+ answer_task = asyncio.create_task(
1047
+ ask_user_choice("Which scope?", ["Small fix", "Full workflow"])
1048
+ )
1049
+ await pilot.pause()
1050
+
1051
+ question_actions = app.query_one("#question_actions", ActionList)
1052
+ assert question_actions.get_option("question_option_0").prompt == "1. Small fix"
1053
+
1054
+ composer = app.query_one("#composer", ChatComposer)
1055
+ composer.load_text("Start with the small fix, but keep the API extensible.")
1056
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
1057
+
1058
+ assert await answer_task == "Start with the small fix, but keep the API extensible."
1059
+ assert composer.text == ""
1060
+ assert app._pending_question is None
1061
+ assert question_actions.display is False
1062
+ assert question_actions.option_count == 0
1063
+ assert app.conversation_entries[-1].content == "Start with the small fix, but keep the API extensible."
1064
+
1065
+
1066
+ @pytest.mark.asyncio
1067
+ async def test_textual_app_blocks_mode_toggle_during_active_turn(
1068
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1069
+ ) -> None:
1070
+ pytest.importorskip("textual")
1071
+
1072
+ from textual.widgets import Static
1073
+
1074
+ from kolega_code.cli import app as app_module
1075
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
1076
+
1077
+ class FakeCoderAgent:
1078
+ def __init__(self, **kwargs):
1079
+ self.kwargs = kwargs
1080
+
1081
+ def restore_message_history(self, history):
1082
+ return None
1083
+
1084
+ def dump_message_history(self):
1085
+ return []
1086
+
1087
+ async def cleanup(self):
1088
+ return None
1089
+
1090
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1091
+
1092
+ project = tmp_path / "project"
1093
+ project.mkdir()
1094
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1095
+ store = SessionStore(tmp_path / "state")
1096
+ session = store.create(project, "code", config_summary(config))
1097
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1098
+
1099
+ async with app.run_test():
1100
+ app._turn_active = True
1101
+
1102
+ await app.action_toggle_interaction_mode()
1103
+
1104
+ assert app.interaction_mode == "build"
1105
+ assert app.query_one("#composer", ChatComposer).placeholder == COMPOSER_PLACEHOLDER
1106
+ hint = app.query_one("#composer_hint", Static)
1107
+ assert hint.display is True
1108
+ assert "Stop the current turn before switching modes." in str(hint.render())
1109
+
1110
+
1111
+ @pytest.mark.asyncio
1112
+ async def test_textual_app_shows_plan_decision_when_planning_agent_writes_plan(
1113
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1114
+ ) -> None:
1115
+ pytest.importorskip("textual")
1116
+
1117
+ from kolega_code.cli import app as app_module
1118
+ from textual.widgets import Markdown
1119
+
1120
+ from kolega_code.cli.app import ActionList, ChatComposer, KolegaCodeApp
1121
+
1122
+ class FakeCoderAgent:
1123
+ def __init__(self, **kwargs):
1124
+ self.kwargs = kwargs
1125
+
1126
+ def restore_message_history(self, history):
1127
+ return None
1128
+
1129
+ def dump_message_history(self):
1130
+ return []
1131
+
1132
+ async def cleanup(self):
1133
+ return None
1134
+
1135
+ class FakePlanningAgent(FakeCoderAgent):
1136
+ def __init__(self, **kwargs):
1137
+ super().__init__(**kwargs)
1138
+ self.completed_plan = "# Plan\n\n" + "\n".join(
1139
+ f"- Step {index}: keep the planning sidebar readable."
1140
+ for index in range(1, 26)
1141
+ )
1142
+
1143
+ async def process_message_stream(self, message):
1144
+ yield {"type": "response", "content": "I have a plan.", "complete": True, "uuid": "response-1"}
1145
+
1146
+ def consume_completed_plan(self):
1147
+ plan = self.completed_plan
1148
+ self.completed_plan = None
1149
+ return plan
1150
+
1151
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1152
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
1153
+
1154
+ project = tmp_path / "project"
1155
+ project.mkdir()
1156
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1157
+ store = SessionStore(tmp_path / "state")
1158
+ session = store.create(project, "code", config_summary(config))
1159
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1160
+
1161
+ async with app.run_test():
1162
+ await app.action_toggle_interaction_mode()
1163
+ await app._process_message("plan this")
1164
+
1165
+ initial_plan = app.agent.completed_plan or app._latest_plan
1166
+ assert app._plan_decision_active is True
1167
+ assert app._latest_plan == initial_plan
1168
+ assert app.query_one("#composer", ChatComposer).disabled is True
1169
+ assert app.query_one("#composer", ChatComposer).placeholder == "Plan ready. Choose Implement plan or Discuss further."
1170
+ plan_actions = app.query_one("#plan_actions", ActionList)
1171
+ assert plan_actions.display is True
1172
+ assert [option.id for option in plan_actions.options] == ["implement_plan", "discuss_plan"]
1173
+ assert app.focused is plan_actions
1174
+ assert app.query_one("#planning_plan_markdown", Markdown).source == initial_plan
1175
+ assert "Step 25" in app.query_one("#planning_plan_markdown", Markdown).source
1176
+ assert app.conversation_entries[-1].kind == "plan"
1177
+ loaded = store.load(session.session_id)
1178
+ assert loaded.latest_plan_markdown == initial_plan
1179
+ assert loaded.interaction_mode == "plan"
1180
+
1181
+ app._discuss_pending_plan()
1182
+
1183
+ assert app._plan_decision_active is False
1184
+ assert app._latest_plan is None
1185
+ assert app.query_one("#composer", ChatComposer).disabled is False
1186
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "No plan captured yet."
1187
+ assert plan_actions.display is False
1188
+ assert plan_actions.option_count == 0
1189
+ assert store.load(session.session_id).latest_plan_markdown == ""
1190
+
1191
+ app.agent.completed_plan = "# Revised plan\n\nBuild planning mode carefully."
1192
+ app._capture_completed_plan()
1193
+
1194
+ assert app._plan_decision_active is True
1195
+ assert app._latest_plan == "# Revised plan\n\nBuild planning mode carefully."
1196
+ assert app.query_one("#composer", ChatComposer).disabled is True
1197
+ assert plan_actions.display is True
1198
+ assert [option.id for option in plan_actions.options] == ["implement_plan", "discuss_plan"]
1199
+ assert (
1200
+ app.query_one("#planning_plan_markdown", Markdown).source
1201
+ == "# Revised plan\n\nBuild planning mode carefully."
1202
+ )
1203
+ assert store.load(session.session_id).latest_plan_markdown == "# Revised plan\n\nBuild planning mode carefully."
1204
+
1205
+
1206
+ @pytest.mark.asyncio
1207
+ async def test_textual_app_implement_plan_switches_to_build_and_sends_plan(
1208
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1209
+ ) -> None:
1210
+ pytest.importorskip("textual")
1211
+
1212
+ from textual.widgets import Markdown
1213
+
1214
+ from kolega_code.cli import app as app_module
1215
+ from kolega_code.cli.app import KolegaCodeApp
1216
+
1217
+ class FakeCoderAgent:
1218
+ def __init__(self, **kwargs):
1219
+ self.kwargs = kwargs
1220
+ self.messages: list[str] = []
1221
+ self.history = []
1222
+
1223
+ def restore_message_history(self, history):
1224
+ self.history = list(history)
1225
+
1226
+ def dump_message_history(self):
1227
+ return self.history
1228
+
1229
+ async def cleanup(self):
1230
+ return None
1231
+
1232
+ async def process_message_stream(self, message):
1233
+ self.messages.append(message)
1234
+ yield {"type": "response", "content": "implemented", "complete": True, "uuid": "response-1"}
1235
+
1236
+ class FakePlanningAgent(FakeCoderAgent):
1237
+ pass
1238
+
1239
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1240
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
1241
+
1242
+ project = tmp_path / "project"
1243
+ project.mkdir()
1244
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1245
+ store = SessionStore(tmp_path / "state")
1246
+ session = store.create(project, "code", config_summary(config))
1247
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1248
+
1249
+ async with app.run_test():
1250
+ await app.action_toggle_interaction_mode()
1251
+ app._latest_plan = "# Plan\n\nBuild it."
1252
+ app._plan_decision_active = True
1253
+
1254
+ await app._implement_pending_plan()
1255
+ assert app.agent_worker is not None
1256
+ await app.agent_worker.wait()
1257
+
1258
+ assert app.interaction_mode == "build"
1259
+ assert isinstance(app.agent, FakeCoderAgent)
1260
+ assert app.agent.messages
1261
+ assert "# Plan\n\nBuild it." in app.agent.messages[-1]
1262
+ assert app._plan_decision_active is False
1263
+ assert app._latest_plan == "# Plan\n\nBuild it."
1264
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "# Plan\n\nBuild it."
1265
+ assert app.query_one("#plan_actions").display is False
1266
+ loaded = store.load(session.session_id)
1267
+ assert loaded.latest_plan_markdown == "# Plan\n\nBuild it."
1268
+ assert loaded.interaction_mode == "build"
1269
+
1270
+
1271
+ @pytest.mark.asyncio
1272
+ async def test_textual_app_discuss_plan_clears_old_plan_until_new_plan_is_written(
1273
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1274
+ ) -> None:
1275
+ pytest.importorskip("textual")
1276
+
1277
+ from textual.widgets import Markdown
1278
+
1279
+ from kolega_code.cli import app as app_module
1280
+ from kolega_code.cli.app import KolegaCodeApp
1281
+
1282
+ class FakeCoderAgent:
1283
+ def __init__(self, **kwargs):
1284
+ self.kwargs = kwargs
1285
+ self.messages: list[str] = []
1286
+ self.history = []
1287
+
1288
+ def restore_message_history(self, history):
1289
+ self.history = list(history)
1290
+
1291
+ def dump_message_history(self):
1292
+ return self.history
1293
+
1294
+ async def cleanup(self):
1295
+ return None
1296
+
1297
+ async def process_message_stream(self, message):
1298
+ self.messages.append(message)
1299
+ yield {"type": "response", "content": "implemented", "complete": True, "uuid": "response-1"}
1300
+
1301
+ class FakePlanningAgent(FakeCoderAgent):
1302
+ pass
1303
+
1304
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1305
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
1306
+
1307
+ project = tmp_path / "project"
1308
+ project.mkdir()
1309
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1310
+ store = SessionStore(tmp_path / "state")
1311
+ session = store.create(project, "code", config_summary(config))
1312
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1313
+
1314
+ async with app.run_test():
1315
+ await app.action_toggle_interaction_mode()
1316
+ app._latest_plan = "# Plan\n\nBuild it after discussing."
1317
+ app._plan_decision_active = True
1318
+
1319
+ app._discuss_pending_plan()
1320
+
1321
+ assert app._latest_plan is None
1322
+ assert app._plan_decision_active is False
1323
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "No plan captured yet."
1324
+ assert app.query_one("#plan_actions").display is False
1325
+ assert store.load(session.session_id).latest_plan_markdown == ""
1326
+
1327
+ await app._implement_pending_plan()
1328
+ assert app.agent_worker is None
1329
+ assert app.interaction_mode == "plan"
1330
+
1331
+ app._latest_plan = "# New plan\n\nBuild this instead."
1332
+ app._plan_decision_active = True
1333
+
1334
+ await app._implement_pending_plan()
1335
+ assert app.agent_worker is not None
1336
+ await app.agent_worker.wait()
1337
+
1338
+ assert app.interaction_mode == "build"
1339
+ assert isinstance(app.agent, FakeCoderAgent)
1340
+ assert "# New plan\n\nBuild this instead." in app.agent.messages[-1]
1341
+ assert "# Plan\n\nBuild it after discussing." not in app.agent.messages[-1]
1342
+ assert app._latest_plan == "# New plan\n\nBuild this instead."
1343
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "# New plan\n\nBuild this instead."
1344
+ assert app.query_one("#plan_actions").display is False
1345
+ assert store.load(session.session_id).latest_plan_markdown == "# New plan\n\nBuild this instead."
1346
+
1347
+
1348
+ @pytest.mark.asyncio
1349
+ async def test_textual_app_does_not_save_startup_entry_to_history(
1350
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1351
+ ) -> None:
1352
+ pytest.importorskip("textual")
1353
+
1354
+ from kolega_code.cli import app as app_module
1355
+ from kolega_code.cli.app import KolegaCodeApp
1356
+
1357
+ saved_history = [Message(role="assistant", content=[TextBlock("saved response")]).to_dict()]
1358
+
1359
+ class FakeCoderAgent:
1360
+ def __init__(self, **kwargs):
1361
+ self.kwargs = kwargs
1362
+
1363
+ def restore_message_history(self, history):
1364
+ return None
1365
+
1366
+ def dump_message_history(self):
1367
+ return saved_history
1368
+
1369
+ async def cleanup(self):
1370
+ return None
1371
+
1372
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1373
+
1374
+ project = tmp_path / "project"
1375
+ project.mkdir()
1376
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1377
+ store = SessionStore(tmp_path / "state")
1378
+ session = store.create(project, "code", config_summary(config))
1379
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1380
+
1381
+ async with app.run_test():
1382
+ assert app.conversation_entries[0].kind == "startup"
1383
+ app._save_session_history()
1384
+
1385
+ assert session.history == saved_history
1386
+ assert all("Kolega Code" not in str(item) for item in session.history)
1387
+
1388
+
1389
+ @pytest.mark.asyncio
1390
+ async def test_textual_app_composer_shift_enter_inserts_line_break_and_enter_submits(
1391
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1392
+ ) -> None:
1393
+ pytest.importorskip("textual")
1394
+
1395
+ from kolega_code.cli import app as app_module
1396
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1397
+
1398
+ class FakeCoderAgent:
1399
+ def __init__(self, **kwargs):
1400
+ self.kwargs = kwargs
1401
+ self.messages: list[str] = []
1402
+
1403
+ def restore_message_history(self, history):
1404
+ return None
1405
+
1406
+ def dump_message_history(self):
1407
+ return []
1408
+
1409
+ async def cleanup(self):
1410
+ return None
1411
+
1412
+ async def process_message_stream(self, message):
1413
+ self.messages.append(message)
1414
+ yield {"type": "response", "content": "ok", "complete": True, "uuid": "response-1"}
1415
+
1416
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1417
+
1418
+ project = tmp_path / "project"
1419
+ project.mkdir()
1420
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1421
+ store = SessionStore(tmp_path / "state")
1422
+ session = store.create(project, "code", config_summary(config))
1423
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1424
+
1425
+ async with app.run_test() as pilot:
1426
+ composer = app.query_one("#composer", ChatComposer)
1427
+ composer.focus()
1428
+
1429
+ await pilot.press("h", "i")
1430
+ await pilot.press("shift+enter")
1431
+ await pilot.press("t", "h", "e", "r", "e")
1432
+ assert composer.text == "hi\nthere"
1433
+
1434
+ await pilot.press("enter")
1435
+ await pilot.pause()
1436
+
1437
+ assert app.agent is not None
1438
+ assert app.agent.messages == ["hi\nthere"]
1439
+ assert composer.text == ""
1440
+ user_entries = [entry for entry in app.conversation_entries if entry.kind == "user"]
1441
+ assert user_entries[-1].content == "hi\nthere"
1442
+
1443
+
1444
+ @pytest.mark.asyncio
1445
+ async def test_textual_app_composer_ctrl_enter_still_inserts_line_break(
1446
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1447
+ ) -> None:
1448
+ pytest.importorskip("textual")
1449
+
1450
+ from kolega_code.cli import app as app_module
1451
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1452
+
1453
+ class FakeCoderAgent:
1454
+ def __init__(self, **kwargs):
1455
+ self.kwargs = kwargs
1456
+
1457
+ def restore_message_history(self, history):
1458
+ return None
1459
+
1460
+ def dump_message_history(self):
1461
+ return []
1462
+
1463
+ async def cleanup(self):
1464
+ return None
1465
+
1466
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1467
+
1468
+ project = tmp_path / "project"
1469
+ project.mkdir()
1470
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1471
+ store = SessionStore(tmp_path / "state")
1472
+ session = store.create(project, "code", config_summary(config))
1473
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1474
+
1475
+ async with app.run_test() as pilot:
1476
+ composer = app.query_one("#composer", ChatComposer)
1477
+ composer.focus()
1478
+
1479
+ await pilot.press("h", "i")
1480
+ await pilot.press("ctrl+enter")
1481
+ await pilot.press("t", "h", "e", "r", "e")
1482
+
1483
+ assert composer.text == "hi\nthere"
1484
+
1485
+
1486
+ @pytest.mark.asyncio
1487
+ async def test_textual_app_composer_preserves_multiline_paste(
1488
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1489
+ ) -> None:
1490
+ pytest.importorskip("textual")
1491
+
1492
+ from textual import events
1493
+
1494
+ from kolega_code.cli import app as app_module
1495
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1496
+
1497
+ class FakeCoderAgent:
1498
+ def __init__(self, **kwargs):
1499
+ self.kwargs = kwargs
1500
+ self.messages: list[str] = []
1501
+
1502
+ def restore_message_history(self, history):
1503
+ return None
1504
+
1505
+ def dump_message_history(self):
1506
+ return []
1507
+
1508
+ async def cleanup(self):
1509
+ return None
1510
+
1511
+ async def process_message_stream(self, message):
1512
+ self.messages.append(message)
1513
+ yield {"type": "response", "content": "ok", "complete": True, "uuid": "response-1"}
1514
+
1515
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1516
+
1517
+ pasted = "line one\n line two\nline three"
1518
+ project = tmp_path / "project"
1519
+ project.mkdir()
1520
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1521
+ store = SessionStore(tmp_path / "state")
1522
+ session = store.create(project, "code", config_summary(config))
1523
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1524
+
1525
+ async with app.run_test():
1526
+ composer = app.query_one("#composer", ChatComposer)
1527
+ composer.focus()
1528
+
1529
+ await composer._on_paste(events.Paste(pasted))
1530
+ assert composer.text == pasted
1531
+
1532
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
1533
+ worker = app.agent_worker
1534
+ assert worker is not None
1535
+ await worker.wait()
1536
+
1537
+ assert app.agent is not None
1538
+ assert app.agent.messages == [pasted]
1539
+ assert composer.text == ""
1540
+
1541
+
1542
+ @pytest.mark.asyncio
1543
+ @pytest.mark.parametrize("command", ["/clear", "/reset"])
1544
+ async def test_textual_app_reset_command_clears_current_thread(
1545
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str
1546
+ ) -> None:
1547
+ pytest.importorskip("textual")
1548
+
1549
+ from textual.widgets import Markdown
1550
+
1551
+ from kolega_code.cli import app as app_module
1552
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp, PendingQuestion, THREAD_RESET_MESSAGE
1553
+
1554
+ class FakeCoderAgent:
1555
+ def __init__(self, **kwargs):
1556
+ self.kwargs = kwargs
1557
+ self.history = []
1558
+
1559
+ def restore_message_history(self, history):
1560
+ self.history = list(history)
1561
+
1562
+ def dump_message_history(self):
1563
+ return self.history
1564
+
1565
+ async def cleanup(self):
1566
+ return None
1567
+
1568
+ async def process_message_stream(self, message):
1569
+ raise AssertionError("reset commands should not be sent to the agent")
1570
+
1571
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1572
+
1573
+ saved_history = [
1574
+ Message(role="user", content=[TextBlock("old request")]).to_dict(),
1575
+ Message(role="assistant", content=[TextBlock("old response")]).to_dict(),
1576
+ ]
1577
+ project = tmp_path / "project"
1578
+ project.mkdir()
1579
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1580
+ store = SessionStore(tmp_path / "state")
1581
+ session = store.create(project, "code", config_summary(config))
1582
+ session.history = saved_history
1583
+ session.task_list_markdown = "- [ ] old task"
1584
+ session.latest_plan_markdown = "# Plan\n\nOld plan."
1585
+ store.save(session)
1586
+
1587
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1588
+
1589
+ async with app.run_test():
1590
+ assert app.agent is not None
1591
+ assert len(app.agent.history) == 2
1592
+ assert any(entry.content == "old request" for entry in app.conversation_entries)
1593
+ app._latest_plan = "# Plan\n\nOld plan."
1594
+ app._plan_decision_active = False
1595
+ app._set_plan_actions_visible(True)
1596
+ question_future = asyncio.get_running_loop().create_future()
1597
+ app._pending_question = PendingQuestion(
1598
+ question="Old question?",
1599
+ options=["A", "B"],
1600
+ future=question_future,
1601
+ )
1602
+ app._set_question_actions_visible(True)
1603
+
1604
+ composer = app.query_one("#composer", ChatComposer)
1605
+ composer.load_text(command)
1606
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
1607
+
1608
+ assert app.agent_worker is None
1609
+ assert len(app.agent.history) == 0
1610
+ assert app.session.history == []
1611
+ assert app.session.task_list_markdown == ""
1612
+ assert app._latest_plan is None
1613
+ assert app._plan_decision_active is False
1614
+ assert app._pending_question is None
1615
+ assert question_future.cancelled()
1616
+ assert app.query_one("#plan_actions").display is False
1617
+ assert app.query_one("#question_actions").display is False
1618
+ assert app.query_one("#planning_plan_markdown", Markdown).source == "No plan captured yet."
1619
+ assert app.query_one("#planning_task_list_markdown", Markdown).source == "No task list has been set."
1620
+ assert store.load(session.session_id).history == []
1621
+ assert store.load(session.session_id).task_list_markdown == ""
1622
+ assert store.load(session.session_id).latest_plan_markdown == ""
1623
+ assert composer.text == ""
1624
+ assert [entry.kind for entry in app.conversation_entries] == ["startup", "progress"]
1625
+ assert app.conversation_entries[-1].content == THREAD_RESET_MESSAGE
1626
+ assert all(entry.content != command for entry in app.conversation_entries)
1627
+
1628
+
1629
+ @pytest.mark.asyncio
1630
+ async def test_textual_app_reset_command_waits_for_active_turn(
1631
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1632
+ ) -> None:
1633
+ pytest.importorskip("textual")
1634
+
1635
+ from textual.widgets import Static
1636
+
1637
+ from kolega_code.cli import app as app_module
1638
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
1639
+
1640
+ class FakeCoderAgent:
1641
+ def __init__(self, **kwargs):
1642
+ self.kwargs = kwargs
1643
+ self.history = ["old history"]
1644
+
1645
+ def restore_message_history(self, history):
1646
+ return None
1647
+
1648
+ def dump_message_history(self):
1649
+ return self.history
1650
+
1651
+ async def cleanup(self):
1652
+ return None
1653
+
1654
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1655
+
1656
+ saved_history = [Message(role="user", content=[TextBlock("old request")]).to_dict()]
1657
+ project = tmp_path / "project"
1658
+ project.mkdir()
1659
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1660
+ store = SessionStore(tmp_path / "state")
1661
+ session = store.create(project, "code", config_summary(config))
1662
+ session.history = saved_history
1663
+ store.save(session)
1664
+
1665
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1666
+
1667
+ async with app.run_test():
1668
+ composer = app.query_one("#composer", ChatComposer)
1669
+ composer.load_text("/clear")
1670
+ app._turn_active = True
1671
+
1672
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
1673
+
1674
+ assert app.session.history == saved_history
1675
+ assert store.load(session.session_id).history == saved_history
1676
+ assert composer.text == "/clear"
1677
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
1678
+ hint = app.query_one("#composer_hint", Static)
1679
+ assert hint.display is True
1680
+ assert "Stop the current turn before resetting the thread." in str(hint.render())
1681
+
1682
+
1683
+ @pytest.mark.asyncio
1684
+ async def test_textual_app_mounts_settings_without_api_key(
1685
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
1686
+ ) -> None:
1687
+ pytest.importorskip("textual")
1688
+
1689
+ from kolega_code.cli import app as app_module
1690
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1691
+
1692
+ class FakeCoderAgent:
1693
+ def __init__(self, **kwargs):
1694
+ raise AssertionError("agent should not be built without a valid API key")
1695
+
1696
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1697
+
1698
+ project = tmp_path / "project"
1699
+ project.mkdir()
1700
+ store = SessionStore(tmp_path / "state")
1701
+ settings_store = SettingsStore(tmp_path / "state")
1702
+ session = store.create(project, "code", {})
1703
+
1704
+ app = KolegaCodeApp(
1705
+ project_path=project,
1706
+ mode="code",
1707
+ store=store,
1708
+ settings_store=settings_store,
1709
+ session=session,
1710
+ )
1711
+
1712
+ async with app.run_test():
1713
+ assert app.agent is None
1714
+ assert app.query_one("#composer", ChatComposer).disabled is True
1715
+ startup = app.conversation_entries[0].content
1716
+ assert f"Model: {UI_DEFAULT_PROVIDER}/{UI_DEFAULT_MODEL}" in startup
1717
+ assert "API key: missing" in startup
1718
+ status = str(app.query_one("#settings_status").render())
1719
+ assert "Configuration incomplete" in status
1720
+ assert "MOONSHOT_API_KEY" in status
1721
+
1722
+
1723
+ @pytest.mark.asyncio
1724
+ async def test_textual_app_mounts_with_stored_kimi_settings(
1725
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
1726
+ ) -> None:
1727
+ pytest.importorskip("textual")
1728
+
1729
+ from kolega_code.cli import app as app_module
1730
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1731
+
1732
+ class FakeCoderAgent:
1733
+ def __init__(self, **kwargs):
1734
+ self.kwargs = kwargs
1735
+
1736
+ def restore_message_history(self, history):
1737
+ return None
1738
+
1739
+ def dump_message_history(self):
1740
+ return []
1741
+
1742
+ async def cleanup(self):
1743
+ return None
1744
+
1745
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1746
+
1747
+ project = tmp_path / "project"
1748
+ project.mkdir()
1749
+ state_dir = tmp_path / "state"
1750
+ store = SessionStore(state_dir)
1751
+ settings_store = SettingsStore(state_dir)
1752
+ settings = CliSettings(active_provider=UI_DEFAULT_PROVIDER, active_model=UI_DEFAULT_MODEL)
1753
+ settings.set_api_key(UI_DEFAULT_PROVIDER, "moonshot-key")
1754
+ settings_store.save(settings)
1755
+ session = store.create(project, "code", {})
1756
+
1757
+ app = KolegaCodeApp(
1758
+ project_path=project,
1759
+ mode="code",
1760
+ store=store,
1761
+ settings_store=settings_store,
1762
+ session=session,
1763
+ )
1764
+
1765
+ async with app.run_test():
1766
+ assert isinstance(app.agent, FakeCoderAgent)
1767
+ assert app.agent.kwargs["config"].long_context_config.provider == ModelProvider.MOONSHOT
1768
+ assert app.agent.kwargs["config"].long_context_config.model == UI_DEFAULT_MODEL
1769
+ assert app.query_one("#composer", ChatComposer).disabled is False
1770
+
1771
+
1772
+ @pytest.mark.asyncio
1773
+ async def test_textual_app_mounts_with_stored_deepseek_settings(
1774
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
1775
+ ) -> None:
1776
+ pytest.importorskip("textual")
1777
+
1778
+ from kolega_code.cli import app as app_module
1779
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1780
+
1781
+ class FakeCoderAgent:
1782
+ def __init__(self, **kwargs):
1783
+ self.kwargs = kwargs
1784
+
1785
+ def restore_message_history(self, history):
1786
+ return None
1787
+
1788
+ def dump_message_history(self):
1789
+ return []
1790
+
1791
+ async def cleanup(self):
1792
+ return None
1793
+
1794
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1795
+
1796
+ project = tmp_path / "project"
1797
+ project.mkdir()
1798
+ state_dir = tmp_path / "state"
1799
+ store = SessionStore(state_dir)
1800
+ settings_store = SettingsStore(state_dir)
1801
+ settings = CliSettings(active_provider=ModelProvider.DEEPSEEK.value, active_model=DEEPSEEK_DEFAULT_MODEL)
1802
+ settings.set_api_key(ModelProvider.DEEPSEEK.value, "deepseek-key")
1803
+ settings_store.save(settings)
1804
+ session = store.create(project, "code", {})
1805
+
1806
+ app = KolegaCodeApp(
1807
+ project_path=project,
1808
+ mode="code",
1809
+ store=store,
1810
+ settings_store=settings_store,
1811
+ session=session,
1812
+ )
1813
+
1814
+ async with app.run_test():
1815
+ assert isinstance(app.agent, FakeCoderAgent)
1816
+ assert app.agent.kwargs["config"].long_context_config.provider == ModelProvider.DEEPSEEK
1817
+ assert app.agent.kwargs["config"].long_context_config.model == DEEPSEEK_DEFAULT_MODEL
1818
+ assert app.query_one("#composer", ChatComposer).disabled is False
1819
+
1820
+
1821
+ @pytest.mark.asyncio
1822
+ async def test_textual_app_saves_settings_and_builds_agent(
1823
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
1824
+ ) -> None:
1825
+ pytest.importorskip("textual")
1826
+
1827
+ from textual.widgets import Input
1828
+
1829
+ from kolega_code.cli import app as app_module
1830
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1831
+
1832
+ class FakeCoderAgent:
1833
+ def __init__(self, **kwargs):
1834
+ self.kwargs = kwargs
1835
+
1836
+ def restore_message_history(self, history):
1837
+ return None
1838
+
1839
+ def dump_message_history(self):
1840
+ return []
1841
+
1842
+ async def cleanup(self):
1843
+ return None
1844
+
1845
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1846
+
1847
+ project = tmp_path / "project"
1848
+ project.mkdir()
1849
+ state_dir = tmp_path / "state"
1850
+ store = SessionStore(state_dir)
1851
+ settings_store = SettingsStore(state_dir)
1852
+ session = store.create(project, "code", {})
1853
+ app = KolegaCodeApp(
1854
+ project_path=project,
1855
+ mode="code",
1856
+ store=store,
1857
+ settings_store=settings_store,
1858
+ session=session,
1859
+ )
1860
+
1861
+ async with app.run_test():
1862
+ assert app.agent is None
1863
+ app.query_one("#api_key_input", Input).value = "moonshot-key"
1864
+ await app._save_settings_from_ui()
1865
+
1866
+ assert isinstance(app.agent, FakeCoderAgent)
1867
+ assert app.agent.kwargs["config"].long_context_config.provider == ModelProvider.MOONSHOT
1868
+ assert settings_store.load().get_api_key(UI_DEFAULT_PROVIDER) == "moonshot-key"
1869
+ assert app.query_one("#composer", ChatComposer).disabled is False
1870
+ assert [entry.kind for entry in app.conversation_entries].count("startup") == 1
1871
+ startup = app.conversation_entries[0].content
1872
+ assert f"Model: {UI_DEFAULT_PROVIDER}/{UI_DEFAULT_MODEL}" in startup
1873
+ assert "API key: present in local settings" in startup
1874
+
1875
+
1876
+ @pytest.mark.asyncio
1877
+ async def test_textual_app_saves_deepseek_settings_and_builds_agent(
1878
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
1879
+ ) -> None:
1880
+ pytest.importorskip("textual")
1881
+
1882
+ from textual.widgets import Input, Select
1883
+
1884
+ from kolega_code.cli import app as app_module
1885
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
1886
+
1887
+ class FakeCoderAgent:
1888
+ def __init__(self, **kwargs):
1889
+ self.kwargs = kwargs
1890
+
1891
+ def restore_message_history(self, history):
1892
+ return None
1893
+
1894
+ def dump_message_history(self):
1895
+ return []
1896
+
1897
+ async def cleanup(self):
1898
+ return None
1899
+
1900
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1901
+
1902
+ project = tmp_path / "project"
1903
+ project.mkdir()
1904
+ state_dir = tmp_path / "state"
1905
+ store = SessionStore(state_dir)
1906
+ settings_store = SettingsStore(state_dir)
1907
+ session = store.create(project, "code", {})
1908
+ app = KolegaCodeApp(
1909
+ project_path=project,
1910
+ mode="code",
1911
+ store=store,
1912
+ settings_store=settings_store,
1913
+ session=session,
1914
+ )
1915
+
1916
+ async with app.run_test():
1917
+ assert app.agent is None
1918
+ app.query_one("#provider_select", Select).value = ModelProvider.DEEPSEEK.value
1919
+ model_select = app.query_one("#model_select", Select)
1920
+ model_select.set_options([("DeepSeek V4 Pro", DEEPSEEK_DEFAULT_MODEL)])
1921
+ model_select.value = DEEPSEEK_DEFAULT_MODEL
1922
+ app.query_one("#api_key_input", Input).value = "deepseek-key"
1923
+ await app._save_settings_from_ui()
1924
+
1925
+ assert isinstance(app.agent, FakeCoderAgent)
1926
+ assert app.agent.kwargs["config"].long_context_config.provider == ModelProvider.DEEPSEEK
1927
+ assert app.agent.kwargs["config"].long_context_config.model == DEEPSEEK_DEFAULT_MODEL
1928
+ assert settings_store.load().get_api_key(ModelProvider.DEEPSEEK.value) == "deepseek-key"
1929
+ assert app.query_one("#composer", ChatComposer).disabled is False
1930
+
1931
+
1932
+ @pytest.mark.asyncio
1933
+ async def test_textual_app_merges_streamed_response_chunks(
1934
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1935
+ ) -> None:
1936
+ pytest.importorskip("textual")
1937
+
1938
+ from kolega_code.cli import app as app_module
1939
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
1940
+
1941
+ chunks = [
1942
+ {"type": "response", "content": "hello ", "complete": False, "uuid": "response-1"},
1943
+ {"type": "response", "content": "world", "complete": False, "uuid": "response-1"},
1944
+ {"type": "response", "content": "", "complete": True, "uuid": "response-1"},
1945
+ ]
1946
+
1947
+ class FakeCoderAgent:
1948
+ def __init__(self, **kwargs):
1949
+ self.kwargs = kwargs
1950
+
1951
+ def restore_message_history(self, history):
1952
+ return None
1953
+
1954
+ def dump_message_history(self):
1955
+ return []
1956
+
1957
+ async def cleanup(self):
1958
+ return None
1959
+
1960
+ async def process_message_stream(self, message):
1961
+ for chunk in chunks:
1962
+ yield chunk
1963
+
1964
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
1965
+
1966
+ project = tmp_path / "project"
1967
+ project.mkdir()
1968
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
1969
+ store = SessionStore(tmp_path / "state")
1970
+ session = store.create(project, "code", config_summary(config))
1971
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
1972
+
1973
+ async with app.run_test():
1974
+ await app._process_message("hi")
1975
+
1976
+ assistant_entries = [entry for entry in app.conversation_entries if entry.kind == "assistant"]
1977
+ assert len(assistant_entries) == 1
1978
+ assert assistant_entries[0].content == "hello world"
1979
+ assert assistant_entries[0].complete is True
1980
+ assert [entry for entry in app.conversation_entries if entry.kind == "progress"] == []
1981
+ assert app.query_one("#composer", ChatComposer).placeholder == COMPOSER_PLACEHOLDER
1982
+
1983
+
1984
+ @pytest.mark.asyncio
1985
+ async def test_textual_app_merges_streamed_thinking_chunks(
1986
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1987
+ ) -> None:
1988
+ pytest.importorskip("textual")
1989
+
1990
+ from kolega_code.cli import app as app_module
1991
+ from kolega_code.cli.app import KolegaCodeApp
1992
+
1993
+ chunks = [
1994
+ {"type": "thinking", "content": "checking ", "complete": False, "uuid": "thinking-1"},
1995
+ {"type": "thinking", "content": "context", "complete": False, "uuid": "thinking-1"},
1996
+ {"type": "thinking", "content": "", "complete": True, "uuid": "thinking-1"},
1997
+ {"type": "response", "content": "done", "complete": True, "uuid": "response-1"},
1998
+ ]
1999
+
2000
+ class FakeCoderAgent:
2001
+ def __init__(self, **kwargs):
2002
+ self.kwargs = kwargs
2003
+
2004
+ def restore_message_history(self, history):
2005
+ return None
2006
+
2007
+ def dump_message_history(self):
2008
+ return []
2009
+
2010
+ async def cleanup(self):
2011
+ return None
2012
+
2013
+ async def process_message_stream(self, message):
2014
+ for chunk in chunks:
2015
+ yield chunk
2016
+
2017
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2018
+
2019
+ project = tmp_path / "project"
2020
+ project.mkdir()
2021
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2022
+ store = SessionStore(tmp_path / "state")
2023
+ session = store.create(project, "code", config_summary(config))
2024
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2025
+
2026
+ async with app.run_test():
2027
+ await app._process_message("hi")
2028
+
2029
+ thinking_entries = [entry for entry in app.conversation_entries if entry.kind == "thinking"]
2030
+ assert len(thinking_entries) == 1
2031
+ assert thinking_entries[0].content == "checking context"
2032
+ assert thinking_entries[0].complete is True
2033
+
2034
+
2035
+ @pytest.mark.asyncio
2036
+ async def test_textual_app_formats_thinking_as_italic_chat_entry(
2037
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2038
+ ) -> None:
2039
+ pytest.importorskip("textual")
2040
+
2041
+ from kolega_code.cli import app as app_module
2042
+ from kolega_code.cli.app import ConversationEntry, KolegaCodeApp
2043
+
2044
+ class FakeCoderAgent:
2045
+ def __init__(self, **kwargs):
2046
+ self.kwargs = kwargs
2047
+
2048
+ def restore_message_history(self, history):
2049
+ return None
2050
+
2051
+ def dump_message_history(self):
2052
+ return []
2053
+
2054
+ async def cleanup(self):
2055
+ return None
2056
+
2057
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2058
+
2059
+ project = tmp_path / "project"
2060
+ project.mkdir()
2061
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2062
+ store = SessionStore(tmp_path / "state")
2063
+ session = store.create(project, "code", config_summary(config))
2064
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2065
+
2066
+ async with app.run_test():
2067
+ formatted = app._format_conversation_entry(
2068
+ ConversationEntry(kind="thinking", content="inspect [red]markup[/red]", complete=False)
2069
+ )
2070
+
2071
+ assert "[dim italic]Thinking[/dim italic]" in formatted
2072
+ assert "\\[red]" in formatted
2073
+ assert "[italic dim]" in formatted
2074
+ assert "…" in formatted # streaming indicator in the header
2075
+
2076
+
2077
+ @pytest.mark.asyncio
2078
+ async def test_textual_app_renders_one_widget_per_chat_entry(
2079
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2080
+ ) -> None:
2081
+ pytest.importorskip("textual")
2082
+
2083
+ from kolega_code.cli import app as app_module
2084
+ from kolega_code.cli.app import ConversationEntry, ConversationEntryWidget, KolegaCodeApp
2085
+
2086
+ class FakeCoderAgent:
2087
+ def __init__(self, **kwargs):
2088
+ self.kwargs = kwargs
2089
+
2090
+ def restore_message_history(self, history):
2091
+ return None
2092
+
2093
+ def dump_message_history(self):
2094
+ return []
2095
+
2096
+ async def cleanup(self):
2097
+ return None
2098
+
2099
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2100
+
2101
+ project = tmp_path / "project"
2102
+ project.mkdir()
2103
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2104
+ store = SessionStore(tmp_path / "state")
2105
+ session = store.create(project, "code", config_summary(config))
2106
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2107
+
2108
+ async with app.run_test() as pilot:
2109
+ app.conversation_entries = [
2110
+ ConversationEntry(kind="user", content="first"),
2111
+ ConversationEntry(kind="assistant", content="second", complete=False),
2112
+ ConversationEntry(kind="user", content="third"),
2113
+ ]
2114
+ app._render_conversation()
2115
+ await pilot.pause()
2116
+
2117
+ widgets = list(app.query(ConversationEntryWidget))
2118
+ assert len(widgets) == 3
2119
+ assert [widget.entry.content for widget in widgets] == ["first", "second", "third"]
2120
+ assert widgets[0].has_class("entry-user")
2121
+ assert widgets[1].has_class("entry-assistant")
2122
+
2123
+ # Streaming into an entry updates its widget in place without remounting
2124
+ app.conversation_entries[1].content = "second updated"
2125
+ app._invalidate_conversation(app.conversation_entries[1])
2126
+ app._flush_conversation_render()
2127
+ await pilot.pause()
2128
+
2129
+ same_widgets = list(app.query(ConversationEntryWidget))
2130
+ assert len(same_widgets) == 3
2131
+ assert same_widgets[1] is widgets[1]
2132
+ assert "second updated" in str(same_widgets[1]._formatted)
2133
+
2134
+
2135
+ @pytest.mark.asyncio
2136
+ async def test_conversation_entry_widget_extracts_plain_selected_text(
2137
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2138
+ ) -> None:
2139
+ pytest.importorskip("textual")
2140
+
2141
+ from textual.selection import Selection
2142
+
2143
+ from kolega_code.cli import app as app_module
2144
+ from kolega_code.cli.app import ConversationEntry, ConversationEntryWidget, KolegaCodeApp
2145
+
2146
+ class FakeCoderAgent:
2147
+ def __init__(self, **kwargs):
2148
+ self.kwargs = kwargs
2149
+
2150
+ def restore_message_history(self, history):
2151
+ return None
2152
+
2153
+ def dump_message_history(self):
2154
+ return []
2155
+
2156
+ async def cleanup(self):
2157
+ return None
2158
+
2159
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2160
+
2161
+ project = tmp_path / "project"
2162
+ project.mkdir()
2163
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2164
+ store = SessionStore(tmp_path / "state")
2165
+ session = store.create(project, "code", config_summary(config))
2166
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2167
+
2168
+ async with app.run_test() as pilot:
2169
+ app.conversation_entries = [ConversationEntry(kind="assistant", content="copy this")]
2170
+ app._render_conversation()
2171
+ await pilot.pause()
2172
+
2173
+ widget = app.query(ConversationEntryWidget).last()
2174
+ selected = widget.get_selection(Selection(None, None))
2175
+
2176
+ assert selected is not None
2177
+ text, ending = selected
2178
+ assert ending == "\n"
2179
+ assert "Agent" in text
2180
+ assert "copy this" in text
2181
+ assert "\x1b" not in text
2182
+ assert "[bold]" not in text
2183
+
2184
+
2185
+ @pytest.mark.asyncio
2186
+ async def test_conversation_entry_supports_mouse_drag_selection(
2187
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2188
+ ) -> None:
2189
+ pytest.importorskip("textual")
2190
+
2191
+ from textual import events
2192
+
2193
+ from kolega_code.cli import app as app_module
2194
+ from kolega_code.cli.app import ConversationEntry, ConversationEntryWidget, KolegaCodeApp
2195
+
2196
+ class FakeCoderAgent:
2197
+ def __init__(self, **kwargs):
2198
+ self.kwargs = kwargs
2199
+
2200
+ def restore_message_history(self, history):
2201
+ return None
2202
+
2203
+ def dump_message_history(self):
2204
+ return []
2205
+
2206
+ async def cleanup(self):
2207
+ return None
2208
+
2209
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2210
+
2211
+ project = tmp_path / "project"
2212
+ project.mkdir()
2213
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2214
+ store = SessionStore(tmp_path / "state")
2215
+ session = store.create(project, "code", config_summary(config))
2216
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2217
+
2218
+ async with app.run_test() as pilot:
2219
+ app.conversation_entries = [ConversationEntry(kind="assistant", content="select this text")]
2220
+ app._render_conversation()
2221
+ await pilot.pause()
2222
+
2223
+ widget = app.query(ConversationEntryWidget).last()
2224
+
2225
+ await pilot.mouse_down(widget, offset=(0, 1))
2226
+ await pilot._post_mouse_events([events.MouseMove], widget, offset=(19, 1), button=1)
2227
+ await pilot.mouse_up(widget, offset=(19, 1))
2228
+
2229
+ selected_text = app.screen.get_selected_text()
2230
+ assert selected_text is not None
2231
+ assert selected_text.strip() == "select this text"
2232
+
2233
+
2234
+ @pytest.mark.asyncio
2235
+ async def test_command_c_copies_selected_chat_text_to_macos_clipboard(
2236
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2237
+ ) -> None:
2238
+ pytest.importorskip("textual")
2239
+
2240
+ from textual.selection import Selection
2241
+
2242
+ from kolega_code.cli import app as app_module
2243
+ from kolega_code.cli.app import ConversationEntry, ConversationEntryWidget, KolegaCodeApp
2244
+
2245
+ class FakeCoderAgent:
2246
+ def __init__(self, **kwargs):
2247
+ self.kwargs = kwargs
2248
+
2249
+ def restore_message_history(self, history):
2250
+ return None
2251
+
2252
+ def dump_message_history(self):
2253
+ return []
2254
+
2255
+ async def cleanup(self):
2256
+ return None
2257
+
2258
+ pbcopy_calls: list[dict] = []
2259
+
2260
+ def fake_run(args, *, input, text, check):
2261
+ pbcopy_calls.append({"args": args, "input": input, "text": text, "check": check})
2262
+
2263
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2264
+ monkeypatch.setattr(app_module.sys, "platform", "darwin")
2265
+ monkeypatch.setattr(app_module.subprocess, "run", fake_run)
2266
+
2267
+ project = tmp_path / "project"
2268
+ project.mkdir()
2269
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2270
+ store = SessionStore(tmp_path / "state")
2271
+ session = store.create(project, "code", config_summary(config))
2272
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2273
+
2274
+ async with app.run_test() as pilot:
2275
+ app.conversation_entries = [ConversationEntry(kind="assistant", content="copy this")]
2276
+ app._render_conversation()
2277
+ await pilot.pause()
2278
+
2279
+ widget = app.query(ConversationEntryWidget).last()
2280
+ app.screen.selections = {widget: Selection(None, None)}
2281
+
2282
+ await pilot.press("super+c")
2283
+
2284
+ assert "copy this" in app.clipboard
2285
+ assert "\x1b" not in app.clipboard
2286
+ assert len(pbcopy_calls) == 1
2287
+ assert pbcopy_calls[0]["args"] == ["pbcopy"]
2288
+ assert pbcopy_calls[0]["input"] == app.clipboard
2289
+
2290
+
2291
+ @pytest.mark.asyncio
2292
+ async def test_textual_app_formats_agent_and_tool_chat_entries(
2293
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2294
+ ) -> None:
2295
+ pytest.importorskip("textual")
2296
+
2297
+ from kolega_code.cli import app as app_module
2298
+ from kolega_code.cli.app import ConversationEntry, KolegaCodeApp
2299
+
2300
+ class FakeCoderAgent:
2301
+ def __init__(self, **kwargs):
2302
+ self.kwargs = kwargs
2303
+
2304
+ def restore_message_history(self, history):
2305
+ return None
2306
+
2307
+ def dump_message_history(self):
2308
+ return []
2309
+
2310
+ async def cleanup(self):
2311
+ return None
2312
+
2313
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2314
+
2315
+ project = tmp_path / "project"
2316
+ project.mkdir()
2317
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2318
+ store = SessionStore(tmp_path / "state")
2319
+ session = store.create(project, "code", config_summary(config))
2320
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2321
+
2322
+ async with app.run_test():
2323
+ assistant = app._format_conversation_entry(
2324
+ ConversationEntry(kind="assistant", content="hello", complete=False)
2325
+ )
2326
+ tool_call = app._format_conversation_entry(
2327
+ ConversationEntry(
2328
+ kind="tool_call",
2329
+ content="inspect [red]markup[/red]\nthen continue",
2330
+ tool_name="read_file",
2331
+ complete=False,
2332
+ )
2333
+ )
2334
+ tool_result = app._format_conversation_entry(
2335
+ ConversationEntry(kind="tool_result", content="completed\nok", tool_name="read_file")
2336
+ )
2337
+ tool_error = app._format_conversation_entry(
2338
+ ConversationEntry(kind="tool_error", content="Permission denied", tool_name="write_file")
2339
+ )
2340
+
2341
+ assert "[magenta]●[/magenta] [bold]Agent[/bold]" in assistant
2342
+ assert "Kolega" not in assistant
2343
+ assert "[cyan]⏺[/cyan] [bold]read_file[/bold]" in tool_call
2344
+ assert "· running" in tool_call
2345
+ assert "[dim] │[/dim] inspect \\[red]markup\\[/red]" in tool_call
2346
+ assert "[dim] │[/dim] then continue" in tool_call
2347
+ assert "[green]⏺[/green] [bold]read_file[/bold]" in tool_result
2348
+ assert "· done" in tool_result
2349
+ assert "[red]⏺[/red] [bold]write_file[/bold]" in tool_error
2350
+ assert "· failed" in tool_error
2351
+
2352
+
2353
+ @pytest.mark.asyncio
2354
+ async def test_textual_app_ignores_empty_final_response_without_existing_entry(
2355
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2356
+ ) -> None:
2357
+ pytest.importorskip("textual")
2358
+
2359
+ from kolega_code.cli import app as app_module
2360
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
2361
+
2362
+ class FakeCoderAgent:
2363
+ def __init__(self, **kwargs):
2364
+ self.kwargs = kwargs
2365
+
2366
+ def restore_message_history(self, history):
2367
+ return None
2368
+
2369
+ def dump_message_history(self):
2370
+ return []
2371
+
2372
+ async def cleanup(self):
2373
+ return None
2374
+
2375
+ async def process_message_stream(self, message):
2376
+ yield {"type": "response", "content": "", "complete": True, "uuid": "response-empty"}
2377
+
2378
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2379
+
2380
+ project = tmp_path / "project"
2381
+ project.mkdir()
2382
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2383
+ store = SessionStore(tmp_path / "state")
2384
+ session = store.create(project, "code", config_summary(config))
2385
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2386
+
2387
+ async with app.run_test():
2388
+ await app._process_message("hi")
2389
+
2390
+ assert [entry for entry in app.conversation_entries if entry.kind == "assistant"] == []
2391
+ assert [entry for entry in app.conversation_entries if entry.kind == "progress"] == []
2392
+ assert app.query_one("#composer", ChatComposer).placeholder == COMPOSER_PLACEHOLDER
2393
+
2394
+
2395
+ @pytest.mark.asyncio
2396
+ async def test_textual_app_shows_working_progress_during_active_turn(
2397
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2398
+ ) -> None:
2399
+ pytest.importorskip("textual")
2400
+
2401
+ from textual.widgets import Static
2402
+
2403
+ from kolega_code.cli import app as app_module
2404
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
2405
+
2406
+ started = asyncio.Event()
2407
+ release = asyncio.Event()
2408
+
2409
+ class FakeCoderAgent:
2410
+ def __init__(self, **kwargs):
2411
+ self.kwargs = kwargs
2412
+
2413
+ def restore_message_history(self, history):
2414
+ return None
2415
+
2416
+ def dump_message_history(self):
2417
+ return []
2418
+
2419
+ async def cleanup(self):
2420
+ return None
2421
+
2422
+ async def process_message_stream(self, message):
2423
+ started.set()
2424
+ await release.wait()
2425
+ yield {"type": "response", "content": "done", "complete": True, "uuid": "response-1"}
2426
+
2427
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2428
+
2429
+ project = tmp_path / "project"
2430
+ project.mkdir()
2431
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2432
+ store = SessionStore(tmp_path / "state")
2433
+ session = store.create(project, "code", config_summary(config))
2434
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2435
+ now = 100.0
2436
+ monkeypatch.setattr(app, "_now", lambda: now)
2437
+
2438
+ async with app.run_test():
2439
+ composer = app.query_one("#composer", ChatComposer)
2440
+ turn_status = app.query_one("#turn_status", Static)
2441
+ assert turn_status.display is False
2442
+
2443
+ task = asyncio.create_task(app._process_message("hi"))
2444
+ await started.wait()
2445
+
2446
+ progress_entries = [entry for entry in app.conversation_entries if entry.kind == "progress"]
2447
+ assert progress_entries == []
2448
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
2449
+ assert composer.disabled is True
2450
+ assert "Working…" in str(turn_status.render())
2451
+ assert "0s" in str(turn_status.render())
2452
+
2453
+ now = 103.0
2454
+ app._render_event(
2455
+ AgentEvent(event_type="status_update", sender="coder", content={"text": "Indexing workspace"})
2456
+ )
2457
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
2458
+ app._refresh_turn_status_strip()
2459
+ assert "Indexing workspace" in str(turn_status.render())
2460
+ assert "3s" in str(turn_status.render())
2461
+
2462
+ now = 423.0
2463
+ release.set()
2464
+ await task
2465
+
2466
+ assert [entry for entry in app.conversation_entries if entry.kind == "progress"] == []
2467
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
2468
+ assert composer.disabled is False
2469
+ assert "Done in 5m 23s" in str(turn_status.render())
2470
+
2471
+
2472
+ @pytest.mark.asyncio
2473
+ async def test_textual_app_renders_tool_events_in_chat(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
2474
+ pytest.importorskip("textual")
2475
+
2476
+ from kolega_code.cli import app as app_module
2477
+ from kolega_code.cli.app import KolegaCodeApp, TOOL_RESULT_PREVIEW_CHARS
2478
+
2479
+ class FakeCoderAgent:
2480
+ def __init__(self, **kwargs):
2481
+ self.kwargs = kwargs
2482
+
2483
+ def restore_message_history(self, history):
2484
+ return None
2485
+
2486
+ def dump_message_history(self):
2487
+ return []
2488
+
2489
+ async def cleanup(self):
2490
+ return None
2491
+
2492
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2493
+
2494
+ project = tmp_path / "project"
2495
+ project.mkdir()
2496
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2497
+ store = SessionStore(tmp_path / "state")
2498
+ session = store.create(project, "code", config_summary(config))
2499
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2500
+
2501
+ async with app.run_test():
2502
+ app._render_event(
2503
+ AgentEvent(
2504
+ event_type="chat_message",
2505
+ sender="coder",
2506
+ content={
2507
+ "message_type": "tool_call",
2508
+ "text": "Calling read_file",
2509
+ "tool_description": "read_file",
2510
+ "tool_call_id": "tool-1",
2511
+ },
2512
+ )
2513
+ )
2514
+ app._render_event(
2515
+ AgentEvent(
2516
+ event_type="chat_message",
2517
+ sender="coder",
2518
+ content={
2519
+ "message_type": "tool_result",
2520
+ "text": "short result",
2521
+ "tool_description": "read_file",
2522
+ "tool_call_id": "tool-1",
2523
+ },
2524
+ )
2525
+ )
2526
+ app._render_event(
2527
+ AgentEvent(
2528
+ event_type="chat_message",
2529
+ sender="coder",
2530
+ content={
2531
+ "message_type": "tool_error",
2532
+ "text": "x" * (TOOL_RESULT_PREVIEW_CHARS + 10),
2533
+ "tool_description": "read_file",
2534
+ "tool_call_id": "tool-2",
2535
+ },
2536
+ )
2537
+ )
2538
+
2539
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2540
+ assert [entry.kind for entry in tool_entries] == ["tool_result", "tool_error"]
2541
+ assert tool_entries[0].content == "short result"
2542
+ assert tool_entries[0].tool_call_id == "tool-1"
2543
+ assert tool_entries[1].content.endswith("…")
2544
+ assert tool_entries[1].tool_call_id == "tool-2"
2545
+ assert len(tool_entries[1].content) == TOOL_RESULT_PREVIEW_CHARS + 1
2546
+
2547
+
2548
+ @pytest.mark.asyncio
2549
+ async def test_textual_app_appends_append_mode_tool_streaming_events_in_chat(
2550
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2551
+ ) -> None:
2552
+ pytest.importorskip("textual")
2553
+
2554
+ from kolega_code.cli import app as app_module
2555
+ from kolega_code.cli.app import KolegaCodeApp
2556
+
2557
+ class FakeCoderAgent:
2558
+ def __init__(self, **kwargs):
2559
+ self.kwargs = kwargs
2560
+
2561
+ def restore_message_history(self, history):
2562
+ return None
2563
+
2564
+ def dump_message_history(self):
2565
+ return []
2566
+
2567
+ async def cleanup(self):
2568
+ return None
2569
+
2570
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2571
+
2572
+ project = tmp_path / "project"
2573
+ project.mkdir()
2574
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2575
+ store = SessionStore(tmp_path / "state")
2576
+ session = store.create(project, "code", config_summary(config))
2577
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2578
+
2579
+ async with app.run_test():
2580
+ app._render_event(
2581
+ AgentEvent(
2582
+ event_type="chat_message",
2583
+ sender="coder",
2584
+ content={
2585
+ "message_type": "tool_call",
2586
+ "text": "Calling think_hard",
2587
+ "tool_description": "think_hard",
2588
+ "tool_call_id": "tool-1",
2589
+ },
2590
+ )
2591
+ )
2592
+ app._render_event(
2593
+ AgentEvent(
2594
+ event_type="tool_streaming_update",
2595
+ sender="coder",
2596
+ content={
2597
+ "text": "partial analysis",
2598
+ "tool_name": "think_hard",
2599
+ "tool_call_id": "tool-1",
2600
+ "is_complete": False,
2601
+ "stream_mode": "append",
2602
+ },
2603
+ )
2604
+ )
2605
+ app._render_event(
2606
+ AgentEvent(
2607
+ event_type="tool_streaming_update",
2608
+ sender="coder",
2609
+ content={
2610
+ "text": "\ncontinued analysis",
2611
+ "tool_name": "think_hard",
2612
+ "tool_call_id": "tool-1",
2613
+ "is_complete": False,
2614
+ "stream_mode": "append",
2615
+ },
2616
+ )
2617
+ )
2618
+
2619
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2620
+ assert len(tool_entries) == 1
2621
+ assert tool_entries[0].kind == "tool_call"
2622
+ assert tool_entries[0].content == "partial analysis\ncontinued analysis"
2623
+ assert tool_entries[0].complete is False
2624
+
2625
+ app._render_event(
2626
+ AgentEvent(
2627
+ event_type="tool_streaming_update",
2628
+ sender="coder",
2629
+ content={
2630
+ "text": "final analysis",
2631
+ "tool_name": "think_hard",
2632
+ "tool_call_id": "tool-1",
2633
+ "is_complete": True,
2634
+ },
2635
+ )
2636
+ )
2637
+
2638
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2639
+ assert len(tool_entries) == 1
2640
+ assert tool_entries[0].kind == "tool_result"
2641
+ assert tool_entries[0].content == "final analysis"
2642
+ assert tool_entries[0].complete is True
2643
+ assert app._tool_stream_buffers == {}
2644
+
2645
+
2646
+ @pytest.mark.asyncio
2647
+ async def test_textual_app_replaces_default_tool_streaming_events_in_chat(
2648
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2649
+ ) -> None:
2650
+ pytest.importorskip("textual")
2651
+
2652
+ from kolega_code.cli import app as app_module
2653
+ from kolega_code.cli.app import KolegaCodeApp
2654
+
2655
+ class FakeCoderAgent:
2656
+ def __init__(self, **kwargs):
2657
+ self.kwargs = kwargs
2658
+
2659
+ def restore_message_history(self, history):
2660
+ return None
2661
+
2662
+ def dump_message_history(self):
2663
+ return []
2664
+
2665
+ async def cleanup(self):
2666
+ return None
2667
+
2668
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2669
+
2670
+ project = tmp_path / "project"
2671
+ project.mkdir()
2672
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2673
+ store = SessionStore(tmp_path / "state")
2674
+ session = store.create(project, "code", config_summary(config))
2675
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2676
+
2677
+ async with app.run_test():
2678
+ app._render_event(
2679
+ AgentEvent(
2680
+ event_type="tool_streaming_update",
2681
+ sender="coder",
2682
+ content={
2683
+ "text": "Fetching content...",
2684
+ "tool_name": "web_fetch",
2685
+ "tool_call_id": "tool-1",
2686
+ "is_complete": False,
2687
+ },
2688
+ )
2689
+ )
2690
+ app._render_event(
2691
+ AgentEvent(
2692
+ event_type="tool_streaming_update",
2693
+ sender="coder",
2694
+ content={
2695
+ "text": "Processing content...",
2696
+ "tool_name": "web_fetch",
2697
+ "tool_call_id": "tool-1",
2698
+ "is_complete": False,
2699
+ },
2700
+ )
2701
+ )
2702
+
2703
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2704
+ assert len(tool_entries) == 1
2705
+ assert tool_entries[0].kind == "tool_call"
2706
+ assert tool_entries[0].content == "Processing content..."
2707
+
2708
+
2709
+ @pytest.mark.asyncio
2710
+ async def test_textual_app_caps_long_append_mode_tool_streaming_events(
2711
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2712
+ ) -> None:
2713
+ pytest.importorskip("textual")
2714
+
2715
+ from kolega_code.cli import app as app_module
2716
+ from kolega_code.cli.app import KolegaCodeApp, TOOL_STREAM_PREVIEW_CHARS
2717
+
2718
+ class FakeCoderAgent:
2719
+ def __init__(self, **kwargs):
2720
+ self.kwargs = kwargs
2721
+
2722
+ def restore_message_history(self, history):
2723
+ return None
2724
+
2725
+ def dump_message_history(self):
2726
+ return []
2727
+
2728
+ async def cleanup(self):
2729
+ return None
2730
+
2731
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2732
+
2733
+ project = tmp_path / "project"
2734
+ project.mkdir()
2735
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2736
+ store = SessionStore(tmp_path / "state")
2737
+ session = store.create(project, "code", config_summary(config))
2738
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2739
+
2740
+ async with app.run_test():
2741
+ app._render_event(
2742
+ AgentEvent(
2743
+ event_type="tool_streaming_update",
2744
+ sender="coder",
2745
+ content={
2746
+ "text": "a" * (TOOL_STREAM_PREVIEW_CHARS + 10),
2747
+ "tool_name": "think_hard",
2748
+ "tool_call_id": "tool-1",
2749
+ "is_complete": False,
2750
+ "stream_mode": "append",
2751
+ },
2752
+ )
2753
+ )
2754
+
2755
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2756
+ assert len(tool_entries) == 1
2757
+ assert tool_entries[0].content.startswith(f"[stream truncated to the last {TOOL_STREAM_PREVIEW_CHARS} characters]")
2758
+ assert tool_entries[0].content.endswith("a" * TOOL_STREAM_PREVIEW_CHARS)
2759
+
2760
+
2761
+ @pytest.mark.asyncio
2762
+ async def test_textual_app_renders_queued_tool_events_during_active_turn(
2763
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2764
+ ) -> None:
2765
+ pytest.importorskip("textual")
2766
+
2767
+ from textual.widgets import Static
2768
+
2769
+ from kolega_code.cli import app as app_module
2770
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
2771
+
2772
+ started = asyncio.Event()
2773
+ release = asyncio.Event()
2774
+
2775
+ class FakeCoderAgent:
2776
+ def __init__(self, **kwargs):
2777
+ self.kwargs = kwargs
2778
+ self.connection_manager = kwargs["connection_manager"]
2779
+ self.workspace_id = kwargs["workspace_id"]
2780
+ self.thread_id = kwargs["thread_id"]
2781
+
2782
+ def restore_message_history(self, history):
2783
+ return None
2784
+
2785
+ def dump_message_history(self):
2786
+ return []
2787
+
2788
+ async def cleanup(self):
2789
+ return None
2790
+
2791
+ async def process_message_stream(self, message):
2792
+ await self.connection_manager.broadcast_event(
2793
+ AgentEvent(
2794
+ event_type="chat_message",
2795
+ sender="coder",
2796
+ content={
2797
+ "message_type": "tool_call",
2798
+ "text": "Calling read_file",
2799
+ "tool_description": "read_file",
2800
+ "tool_call_id": "tool-1",
2801
+ },
2802
+ ),
2803
+ self.workspace_id,
2804
+ self.thread_id,
2805
+ )
2806
+ started.set()
2807
+ await release.wait()
2808
+ await self.connection_manager.broadcast_event(
2809
+ AgentEvent(
2810
+ event_type="chat_message",
2811
+ sender="coder",
2812
+ content={
2813
+ "message_type": "tool_result",
2814
+ "text": "README contents",
2815
+ "tool_description": "read_file",
2816
+ "tool_call_id": "tool-1",
2817
+ },
2818
+ ),
2819
+ self.workspace_id,
2820
+ self.thread_id,
2821
+ )
2822
+ yield {"type": "response", "content": "done", "complete": True, "uuid": "response-1"}
2823
+
2824
+ async def wait_for_tool_entries(app: KolegaCodeApp, count: int) -> list:
2825
+ while True:
2826
+ entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2827
+ if len(entries) >= count:
2828
+ return entries
2829
+ await asyncio.sleep(0.01)
2830
+
2831
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2832
+
2833
+ project = tmp_path / "project"
2834
+ project.mkdir()
2835
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2836
+ store = SessionStore(tmp_path / "state")
2837
+ session = store.create(project, "code", config_summary(config))
2838
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2839
+ now = 10.0
2840
+ monkeypatch.setattr(app, "_now", lambda: now)
2841
+
2842
+ async with app.run_test():
2843
+ composer = app.query_one("#composer", ChatComposer)
2844
+ turn_status = app.query_one("#turn_status", Static)
2845
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, "hi"))
2846
+ worker = app.agent_worker
2847
+ assert worker is not None
2848
+ assert worker.group == "turns"
2849
+
2850
+ await started.wait()
2851
+ event_worker = next(worker for worker in app.workers if worker.name == "kolega-events")
2852
+ assert event_worker.group == "events"
2853
+ assert not event_worker.is_cancelled
2854
+
2855
+ tool_entries = await asyncio.wait_for(wait_for_tool_entries(app, 1), timeout=1)
2856
+ assert tool_entries[0].kind == "tool_call"
2857
+ assert tool_entries[0].content == "Calling read_file"
2858
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
2859
+ assert "Running read_file…" in str(turn_status.render())
2860
+
2861
+ now = 25.0
2862
+ release.set()
2863
+ await worker.wait()
2864
+
2865
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2866
+ assert len(tool_entries) == 1
2867
+ assert tool_entries[0].kind == "tool_result"
2868
+ assert tool_entries[0].content == "README contents"
2869
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
2870
+ assert "Done in 15s" in str(turn_status.render())
2871
+
2872
+
2873
+ @pytest.mark.asyncio
2874
+ async def test_textual_app_late_tool_result_updates_existing_tool_row(
2875
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2876
+ ) -> None:
2877
+ pytest.importorskip("textual")
2878
+
2879
+ from kolega_code.cli import app as app_module
2880
+ from kolega_code.cli.app import KolegaCodeApp
2881
+
2882
+ class FakeCoderAgent:
2883
+ def __init__(self, **kwargs):
2884
+ self.kwargs = kwargs
2885
+
2886
+ def restore_message_history(self, history):
2887
+ return None
2888
+
2889
+ def dump_message_history(self):
2890
+ return []
2891
+
2892
+ async def cleanup(self):
2893
+ return None
2894
+
2895
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2896
+
2897
+ project = tmp_path / "project"
2898
+ project.mkdir()
2899
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2900
+ store = SessionStore(tmp_path / "state")
2901
+ session = store.create(project, "code", config_summary(config))
2902
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2903
+
2904
+ async with app.run_test():
2905
+ app._render_event(
2906
+ AgentEvent(
2907
+ event_type="chat_message",
2908
+ sender="coder",
2909
+ content={
2910
+ "message_type": "tool_call",
2911
+ "text": "Calling read_file",
2912
+ "tool_description": "read_file",
2913
+ "tool_call_id": "tool-1",
2914
+ },
2915
+ )
2916
+ )
2917
+ app._active_progress_entry = None
2918
+ app._turn_active = False
2919
+
2920
+ app._render_event(
2921
+ AgentEvent(
2922
+ event_type="chat_message",
2923
+ sender="coder",
2924
+ content={
2925
+ "message_type": "tool_result",
2926
+ "text": "late result",
2927
+ "tool_description": "read_file",
2928
+ "tool_call_id": "tool-1",
2929
+ },
2930
+ )
2931
+ )
2932
+
2933
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind.startswith("tool")]
2934
+ assert len(tool_entries) == 1
2935
+ assert tool_entries[0].kind == "tool_result"
2936
+ assert tool_entries[0].content == "late result"
2937
+
2938
+
2939
+ @pytest.mark.asyncio
2940
+ async def test_textual_app_cancellation_is_visible_in_chat(
2941
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2942
+ ) -> None:
2943
+ pytest.importorskip("textual")
2944
+
2945
+ from textual.widgets import Static
2946
+
2947
+ from kolega_code.cli import app as app_module
2948
+ from kolega_code.cli.app import COMPOSER_PLACEHOLDER, ChatComposer, KolegaCodeApp
2949
+
2950
+ started = asyncio.Event()
2951
+
2952
+ class FakeCoderAgent:
2953
+ def __init__(self, **kwargs):
2954
+ self.kwargs = kwargs
2955
+
2956
+ def restore_message_history(self, history):
2957
+ return None
2958
+
2959
+ def dump_message_history(self):
2960
+ return []
2961
+
2962
+ async def cleanup(self):
2963
+ return None
2964
+
2965
+ async def process_message_stream(self, message):
2966
+ started.set()
2967
+ while True:
2968
+ await asyncio.sleep(1)
2969
+ yield {"type": "thinking", "content": "still working", "complete": False, "uuid": "thinking-1"}
2970
+
2971
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
2972
+
2973
+ project = tmp_path / "project"
2974
+ project.mkdir()
2975
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
2976
+ store = SessionStore(tmp_path / "state")
2977
+ session = store.create(project, "code", config_summary(config))
2978
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
2979
+ now = 10.0
2980
+ monkeypatch.setattr(app, "_now", lambda: now)
2981
+
2982
+ async with app.run_test():
2983
+ composer = app.query_one("#composer", ChatComposer)
2984
+ turn_status = app.query_one("#turn_status", Static)
2985
+ task = asyncio.create_task(app._process_message("hi"))
2986
+ app.agent_worker = task
2987
+ await started.wait()
2988
+
2989
+ now = 52.0
2990
+ app.action_cancel_generation()
2991
+ progress_entries = [entry for entry in app.conversation_entries if entry.kind == "progress"]
2992
+ assert progress_entries == []
2993
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
2994
+ assert "Stopping…" in str(turn_status.render())
2995
+ assert "42s" in str(turn_status.render())
2996
+
2997
+ await task
2998
+
2999
+ progress_entries = [entry for entry in app.conversation_entries if entry.kind == "progress"]
3000
+ assert len(progress_entries) == 1
3001
+ assert progress_entries[0].content == "Stopped by user."
3002
+ assert progress_entries[0].complete is True
3003
+ assert composer.placeholder == COMPOSER_PLACEHOLDER
3004
+ assert "Stopped after 42s" in str(turn_status.render())
3005
+
3006
+
3007
+ @pytest.mark.asyncio
3008
+ async def test_textual_app_renders_resumed_history_in_chat(
3009
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3010
+ ) -> None:
3011
+ pytest.importorskip("textual")
3012
+
3013
+ from kolega_code.cli import app as app_module
3014
+ from kolega_code.cli.app import KolegaCodeApp
3015
+
3016
+ class FakeCoderAgent:
3017
+ def __init__(self, **kwargs):
3018
+ self.kwargs = kwargs
3019
+ self.restored_history = None
3020
+
3021
+ def restore_message_history(self, history):
3022
+ self.restored_history = history
3023
+
3024
+ def dump_message_history(self):
3025
+ return self.restored_history or []
3026
+
3027
+ async def cleanup(self):
3028
+ return None
3029
+
3030
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
3031
+
3032
+ project = tmp_path / "project"
3033
+ project.mkdir()
3034
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
3035
+ store = SessionStore(tmp_path / "state")
3036
+ session = store.create(project, "code", config_summary(config))
3037
+ session.history = [
3038
+ Message(role="user", content=[TextBlock("Please read the README")]).to_dict(),
3039
+ Message(
3040
+ role="assistant",
3041
+ content=[
3042
+ TextBlock("I'll inspect it."),
3043
+ ToolCall(id="tool-1", name="read_file", input={"relative_path": "README.md"}),
3044
+ ],
3045
+ ).to_dict(),
3046
+ Message(
3047
+ role="user",
3048
+ content=[ToolResult(tool_use_id="tool-1", content="README contents", name="read_file", is_error=False)],
3049
+ ).to_dict(),
3050
+ Message(
3051
+ role="user",
3052
+ content=[ToolResult(tool_use_id="tool-2", content="Permission denied", name="write_file", is_error=True)],
3053
+ ).to_dict(),
3054
+ Message(role="assistant", content=[TextBlock("Done.")]).to_dict(),
3055
+ ]
3056
+
3057
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
3058
+
3059
+ async with app.run_test():
3060
+ assert app.agent.restored_history == session.history
3061
+ assert app.conversation_entries[0].kind == "startup"
3062
+ startup = app.conversation_entries[0].content
3063
+ expected_model = f"{config.long_context_config.provider.value}/{config.long_context_config.model}"
3064
+ assert f"Project: {project}" in startup
3065
+ assert f"Model: {expected_model}" in startup
3066
+ assert [(entry.kind, entry.content, entry.tool_name) for entry in app.conversation_entries[1:]] == [
3067
+ ("user", "Please read the README", None),
3068
+ ("assistant", "I'll inspect it.", None),
3069
+ ("tool_call", "Calling read_file", "read_file"),
3070
+ ("tool_result", "README contents", "read_file"),
3071
+ ("tool_error", "Permission denied", "write_file"),
3072
+ ("assistant", "Done.", None),
3073
+ ]
3074
+
3075
+
3076
+ # ---------------------------------------------------------------------------
3077
+ # Parallel sub-agent rendering
3078
+ # ---------------------------------------------------------------------------
3079
+
3080
+
3081
+ def _build_sub_agent_test_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
3082
+ pytest.importorskip("textual")
3083
+
3084
+ from kolega_code.cli import app as app_module
3085
+ from kolega_code.cli.app import KolegaCodeApp
3086
+
3087
+ class FakeCoderAgent:
3088
+ def __init__(self, **kwargs):
3089
+ self.kwargs = kwargs
3090
+
3091
+ def restore_message_history(self, history):
3092
+ return None
3093
+
3094
+ def dump_message_history(self):
3095
+ return []
3096
+
3097
+ async def cleanup(self):
3098
+ return None
3099
+
3100
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
3101
+
3102
+ project = tmp_path / "project"
3103
+ project.mkdir()
3104
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
3105
+ store = SessionStore(tmp_path / "state")
3106
+ session = store.create(project, "code", config_summary(config))
3107
+ return KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
3108
+
3109
+
3110
+ def _sub_agent_event(
3111
+ agent_id="agent-1",
3112
+ agent_name="general-agent",
3113
+ task="inspect sessions",
3114
+ parent_tool_call_id="tc-1",
3115
+ uuid=None,
3116
+ **content,
3117
+ ):
3118
+ kwargs = {"uuid": uuid} if uuid is not None else {}
3119
+ return AgentEvent(
3120
+ event_type="chat_message",
3121
+ sender=agent_name,
3122
+ content=content,
3123
+ sub_agent_info={
3124
+ "agent_id": agent_id,
3125
+ "agent_name": agent_name,
3126
+ "task": task,
3127
+ "parent_tool_call_id": parent_tool_call_id,
3128
+ "conversation_id": None,
3129
+ "depth": 1,
3130
+ },
3131
+ **kwargs,
3132
+ )
3133
+
3134
+
3135
+ def _sub_agent_entries(app):
3136
+ return [entry for entry in app.conversation_entries if entry.kind == "sub_agent"]
3137
+
3138
+
3139
+ @pytest.mark.asyncio
3140
+ async def test_sub_agent_stream_chunks_group_into_single_entry(
3141
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3142
+ ) -> None:
3143
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3144
+
3145
+ async with app.run_test():
3146
+ app._render_event(_sub_agent_event(uuid="u1", text="The session store wri"))
3147
+ app._render_event(_sub_agent_event(uuid="u1", text="tes JSON records"))
3148
+
3149
+ entries = _sub_agent_entries(app)
3150
+ assert len(entries) == 1
3151
+ assert not any(entry.kind == "message" for entry in app.conversation_entries)
3152
+ assert "general-agent" in entries[0].content
3153
+ assert "#1" in entries[0].content
3154
+ assert "The session store writes JSON records" in entries[0].content
3155
+ assert "Task: inspect sessions" in entries[0].content
3156
+
3157
+
3158
+ @pytest.mark.asyncio
3159
+ async def test_parallel_sub_agents_create_separate_entries(
3160
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3161
+ ) -> None:
3162
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3163
+
3164
+ async with app.run_test():
3165
+ app._render_event(_sub_agent_event(agent_id="a1", task="task one", uuid="u1", text="alpha"))
3166
+ app._render_event(_sub_agent_event(agent_id="a2", task="task two", parent_tool_call_id="tc-2", uuid="u2", text="beta"))
3167
+ app._render_event(_sub_agent_event(agent_id="a1", task="task one", uuid="u1", text=" more"))
3168
+
3169
+ entries = _sub_agent_entries(app)
3170
+ assert len(entries) == 2
3171
+ assert "#1" in entries[0].content and "alpha more" in entries[0].content
3172
+ assert "#2" in entries[1].content and "beta" in entries[1].content
3173
+ assert "alpha" not in entries[1].content
3174
+
3175
+
3176
+ @pytest.mark.asyncio
3177
+ async def test_sub_agent_tool_events_update_counters_not_top_level(
3178
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3179
+ ) -> None:
3180
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3181
+
3182
+ async with app.run_test():
3183
+ app._render_event(
3184
+ _sub_agent_event(message_type="tool_call", text="Calling search_codebase", tool_description="search_codebase")
3185
+ )
3186
+ app._render_event(
3187
+ _sub_agent_event(message_type="tool_result", text="found things", tool_description="search_codebase")
3188
+ )
3189
+
3190
+ assert not any(entry.kind.startswith("tool") for entry in app.conversation_entries)
3191
+ entries = _sub_agent_entries(app)
3192
+ assert len(entries) == 1
3193
+ assert "1 tool" in entries[0].content
3194
+ assert "last: search_codebase done" in entries[0].content
3195
+ activity = next(iter(app._sub_agent_activities.values()))
3196
+ assert activity.tool_calls == 1
3197
+
3198
+
3199
+ @pytest.mark.asyncio
3200
+ async def test_sub_agent_status_events_complete_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3201
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3202
+
3203
+ async with app.run_test():
3204
+ app._render_event(_sub_agent_event(status="GENERATING", message="Starting general-agent task"))
3205
+ activity = next(iter(app._sub_agent_activities.values()))
3206
+ assert activity.status == "running"
3207
+ assert activity.entry.complete is False
3208
+
3209
+ app._render_event(_sub_agent_event(status="STOPPED", message="Completed general-agent task"))
3210
+ assert activity.status == "completed"
3211
+ assert activity.entry.complete is True
3212
+ assert "completed in" in activity.entry.content
3213
+
3214
+
3215
+ @pytest.mark.asyncio
3216
+ async def test_sub_agent_error_status_marks_failed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3217
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3218
+
3219
+ async with app.run_test():
3220
+ app._render_event(_sub_agent_event(status="GENERATING", message="Starting general-agent task"))
3221
+ app._render_event(_sub_agent_event(status="ERROR", message="Error in general-agent: boom"))
3222
+
3223
+ activity = next(iter(app._sub_agent_activities.values()))
3224
+ assert activity.status == "failed"
3225
+ assert "failed after" in activity.entry.content
3226
+
3227
+
3228
+ @pytest.mark.asyncio
3229
+ async def test_activity_strip_running_sub_agents(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3230
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3231
+
3232
+ async with app.run_test():
3233
+ app._turn_active = True
3234
+ app._render_event(_sub_agent_event(agent_id="a1", status="GENERATING", message="Starting"))
3235
+ assert app._status_state.activity == "Running sub-agent general-agent #1…"
3236
+
3237
+ app._render_event(
3238
+ _sub_agent_event(agent_id="a2", parent_tool_call_id="tc-2", status="GENERATING", message="Starting")
3239
+ )
3240
+ assert app._status_state.activity == "Running 2 sub-agents…"
3241
+ assert app._status_state.turn_state == "Running sub-agents"
3242
+
3243
+ app._render_event(_sub_agent_event(agent_id="a1", status="STOPPED", message="Completed"))
3244
+ app._render_event(_sub_agent_event(agent_id="a2", parent_tool_call_id="tc-2", status="STOPPED", message="Completed"))
3245
+ assert app._status_state.activity == "Working…"
3246
+
3247
+
3248
+ @pytest.mark.asyncio
3249
+ async def test_main_agent_tool_events_unaffected_by_sub_agent_routing(
3250
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3251
+ ) -> None:
3252
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3253
+
3254
+ async with app.run_test():
3255
+ app._render_event(
3256
+ AgentEvent(
3257
+ event_type="chat_message",
3258
+ sender="coder",
3259
+ content={
3260
+ "message_type": "tool_call",
3261
+ "text": "Calling read_file",
3262
+ "tool_description": "read_file",
3263
+ "tool_call_id": "tool-1",
3264
+ },
3265
+ )
3266
+ )
3267
+
3268
+ tool_entries = [entry for entry in app.conversation_entries if entry.kind == "tool_call"]
3269
+ assert len(tool_entries) == 1
3270
+ assert not _sub_agent_entries(app)
3271
+
3272
+
3273
+ @pytest.mark.asyncio
3274
+ async def test_sub_agent_event_without_agent_id_uses_fallback_key(
3275
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3276
+ ) -> None:
3277
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3278
+
3279
+ async with app.run_test():
3280
+ event1 = _sub_agent_event(uuid="u1", text="part one ")
3281
+ event2 = _sub_agent_event(uuid="u1", text="part two")
3282
+ for event in (event1, event2):
3283
+ del event.sub_agent_info["agent_id"]
3284
+ app._render_event(event)
3285
+
3286
+ entries = _sub_agent_entries(app)
3287
+ assert len(entries) == 1
3288
+ assert "part one part two" in entries[0].content
3289
+ assert "tc-1" in app._sub_agent_activities
3290
+
3291
+
3292
+ @pytest.mark.asyncio
3293
+ async def test_sub_agent_tool_streaming_update_routes_to_activity(
3294
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3295
+ ) -> None:
3296
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3297
+
3298
+ async with app.run_test():
3299
+ event = _sub_agent_event(text="ignored")
3300
+ streaming = AgentEvent(
3301
+ event_type="tool_streaming_update",
3302
+ sender="general-agent",
3303
+ content={"text": "partial", "tool_call_id": "t1", "tool_name": "run_command_tracked", "is_complete": False},
3304
+ sub_agent_info=event.sub_agent_info,
3305
+ )
3306
+ app._render_event(streaming)
3307
+
3308
+ assert not any(entry.kind.startswith("tool") for entry in app.conversation_entries)
3309
+ entries = _sub_agent_entries(app)
3310
+ assert len(entries) == 1
3311
+ assert "run_command_tracked streaming" in entries[0].content
3312
+
3313
+
3314
+ @pytest.mark.asyncio
3315
+ async def test_cancel_finalizes_running_sub_agents(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3316
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3317
+
3318
+ async with app.run_test():
3319
+ app._render_event(_sub_agent_event(status="GENERATING", message="Starting"))
3320
+ activity = next(iter(app._sub_agent_activities.values()))
3321
+ assert activity.status == "running"
3322
+
3323
+ app._finalize_sub_agent_activities()
3324
+
3325
+ assert activity.status == "stopped"
3326
+ assert activity.entry.complete is True
3327
+ assert "stopped after" in activity.entry.content
3328
+
3329
+
3330
+ @pytest.mark.asyncio
3331
+ async def test_thread_reset_clears_sub_agent_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3332
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3333
+
3334
+ async with app.run_test():
3335
+ app._render_event(_sub_agent_event(uuid="u1", text="some output"))
3336
+ assert app._sub_agent_activities
3337
+
3338
+ app._reset_current_thread()
3339
+
3340
+ assert app._sub_agent_activities == {}
3341
+ assert app._sub_agent_by_tool_call == {}
3342
+ assert not _sub_agent_entries(app)
3343
+
3344
+
3345
+ @pytest.mark.asyncio
3346
+ async def test_rapid_stream_chunks_coalesce_renders(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3347
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3348
+
3349
+ async with app.run_test() as pilot:
3350
+ render_calls = 0
3351
+ original_render = app._render_conversation
3352
+
3353
+ def counting_render() -> None:
3354
+ nonlocal render_calls
3355
+ render_calls += 1
3356
+ original_render()
3357
+
3358
+ monkeypatch.setattr(app, "_render_conversation", counting_render)
3359
+
3360
+ for index in range(50):
3361
+ app._apply_stream_chunk({"uuid": "chunk-1", "content": f"word{index} ", "complete": False}, kind="assistant")
3362
+ app._apply_stream_chunk({"uuid": "chunk-1", "content": "done", "complete": True}, kind="assistant")
3363
+
3364
+ await pilot.pause(0.1)
3365
+
3366
+ assert render_calls < 10
3367
+ entry = app._stream_entries["chunk-1"]
3368
+ assert entry.complete is True
3369
+ assert "word0" in entry.content
3370
+ assert "word49" in entry.content
3371
+ assert entry.content.endswith("done")
3372
+
3373
+
3374
+ @pytest.mark.asyncio
3375
+ async def test_conversation_scroll_position_survives_streaming(
3376
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3377
+ ) -> None:
3378
+ pytest.importorskip("textual")
3379
+
3380
+ from kolega_code.cli.app import ConversationEntry, JumpToBottomBar
3381
+
3382
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3383
+
3384
+ async with app.run_test() as pilot:
3385
+ view = app._conversation
3386
+ for index in range(40):
3387
+ app._add_conversation_entry(ConversationEntry(kind="user", content=f"message {index}"))
3388
+ app._flush_conversation_render()
3389
+ await pilot.pause()
3390
+ await pilot.pause()
3391
+
3392
+ assert view.max_scroll_y > 0
3393
+ # Anchored: streaming keeps the view pinned to the bottom
3394
+ assert view.scroll_y == view.max_scroll_y
3395
+
3396
+ # User scrolls up; new entries must not yank the view back down
3397
+ view.scroll_to(y=0, animate=False)
3398
+ await pilot.pause()
3399
+ for index in range(5):
3400
+ app._add_conversation_entry(ConversationEntry(kind="user", content=f"late message {index}"))
3401
+ app._flush_conversation_render()
3402
+ await pilot.pause()
3403
+ await pilot.pause()
3404
+
3405
+ assert view.scroll_y == 0
3406
+ assert app.query_one("#jump_to_bottom", JumpToBottomBar).display is True
3407
+
3408
+ # Jump-to-bottom restores the anchor and hides the bar
3409
+ app.on_jump_to_bottom_bar_pressed(JumpToBottomBar.Pressed(app.query_one("#jump_to_bottom", JumpToBottomBar)))
3410
+ await pilot.pause()
3411
+ assert view.scroll_y == view.max_scroll_y
3412
+ assert app.query_one("#jump_to_bottom", JumpToBottomBar).display is False
3413
+
3414
+
3415
+ @pytest.mark.asyncio
3416
+ async def test_assistant_entries_render_markdown_when_complete(
3417
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3418
+ ) -> None:
3419
+ pytest.importorskip("textual")
3420
+
3421
+ from rich.console import Group
3422
+ from rich.markdown import Markdown as RichMarkdown
3423
+
3424
+ from kolega_code.cli.app import ConversationEntry
3425
+
3426
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3427
+
3428
+ async with app.run_test():
3429
+ streaming = app._format_conversation_entry(
3430
+ ConversationEntry(kind="assistant", content="# Title\n\nsome `code`", complete=False)
3431
+ )
3432
+ assert isinstance(streaming, str)
3433
+ assert "…" in streaming # header carries the streaming indicator
3434
+
3435
+ complete = app._format_conversation_entry(
3436
+ ConversationEntry(kind="assistant", content="# Title\n\nsome `code`", complete=True)
3437
+ )
3438
+ assert isinstance(complete, Group)
3439
+ renderables = list(complete.renderables)
3440
+ assert any(
3441
+ isinstance(getattr(item, "renderable", item), RichMarkdown) for item in renderables
3442
+ )
3443
+
3444
+ plan = app._format_conversation_entry(
3445
+ ConversationEntry(kind="plan", content="- step one\n- step two", complete=True)
3446
+ )
3447
+ assert isinstance(plan, Group)
3448
+
3449
+
3450
+ @pytest.mark.asyncio
3451
+ async def test_confirmations_surface_as_toasts_and_logs(
3452
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3453
+ ) -> None:
3454
+ pytest.importorskip("textual")
3455
+
3456
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3457
+
3458
+ async with app.run_test():
3459
+ notifications: list[tuple[str, str]] = []
3460
+ logged: list[tuple[str, str]] = []
3461
+
3462
+ def fake_notify(message, *, severity="information", title=None, **kwargs):
3463
+ notifications.append((message, severity))
3464
+
3465
+ original_log_status = app._log_status
3466
+
3467
+ def spy_log_status(text, level="info"):
3468
+ logged.append((text, level))
3469
+ original_log_status(text, level)
3470
+
3471
+ monkeypatch.setattr(app, "notify", fake_notify)
3472
+ monkeypatch.setattr(app, "_log_status", spy_log_status)
3473
+
3474
+ await app._set_interaction_mode("plan")
3475
+
3476
+ assert ("Switched to plan mode.", "information") in notifications
3477
+ assert ("Switched to plan mode.", "ok") in logged # diagnostic record kept
3478
+
3479
+ # Blockers surface as warning toasts
3480
+ app._turn_active = True
3481
+ await app.action_toggle_interaction_mode()
3482
+ assert ("Stop the current turn before switching modes.", "warning") in notifications
3483
+
3484
+
3485
+ @pytest.mark.asyncio
3486
+ async def test_turn_status_strip_shows_spinner_and_outcome_glyph(
3487
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3488
+ ) -> None:
3489
+ pytest.importorskip("textual")
3490
+
3491
+ from kolega_code.cli import theme
3492
+ from kolega_code.cli.app import TurnState
3493
+
3494
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3495
+ now = 0.0
3496
+ monkeypatch.setattr(app, "_now", lambda: now)
3497
+
3498
+ async with app.run_test():
3499
+ app._begin_turn_progress()
3500
+ content = app._turn_status_content()
3501
+ assert any(frame in content for frame in theme.spinner_frames())
3502
+ assert "Working…" in content
3503
+
3504
+ now = 12.0
3505
+ app._finish_turn_progress("Finished.", TurnState.IDLE)
3506
+ content = app._turn_status_content()
3507
+ assert theme.g(theme.Glyph.CHECK) in content
3508
+ assert "Done in 12s" in content
3509
+
3510
+
3511
+ @pytest.mark.asyncio
3512
+ async def test_tool_entries_render_as_collapsibles_with_full_output(
3513
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3514
+ ) -> None:
3515
+ pytest.importorskip("textual")
3516
+
3517
+ from textual.widgets import Collapsible
3518
+
3519
+ from kolega_code.cli.app import TOOL_RESULT_PREVIEW_CHARS, ToolEntryWidget
3520
+
3521
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3522
+
3523
+ async with app.run_test() as pilot:
3524
+ app._turn_active = True
3525
+ long_output = "x" * (TOOL_RESULT_PREVIEW_CHARS + 200)
3526
+ app._add_tool_message(
3527
+ "tool_call", {"tool_name": "read_file", "tool_call_id": "tc-1", "text": "Calling read_file"}
3528
+ )
3529
+ app._flush_conversation_render()
3530
+ await pilot.pause()
3531
+
3532
+ widget = app.query(ToolEntryWidget).last()
3533
+ collapsible = widget.query_one(Collapsible)
3534
+ assert collapsible.collapsed is True
3535
+ assert "running" in str(collapsible.title)
3536
+
3537
+ app._add_tool_message(
3538
+ "tool_result", {"tool_name": "read_file", "tool_call_id": "tc-1", "text": long_output}
3539
+ )
3540
+ app._flush_conversation_render()
3541
+ await pilot.pause()
3542
+
3543
+ # The same widget is updated in place: title flips to done, body holds full output
3544
+ same_widget = app.query(ToolEntryWidget).last()
3545
+ assert same_widget is widget
3546
+ assert "done" in str(widget.query_one(Collapsible).title)
3547
+ entry = widget.entry
3548
+ assert len(entry.content) == TOOL_RESULT_PREVIEW_CHARS + 1 # preview stays truncated
3549
+ assert entry.full_content == long_output # expand-on-demand shows everything
3550
+
3551
+
3552
+ @pytest.mark.asyncio
3553
+ async def test_log_lines_carry_timestamp_and_level_glyph(
3554
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3555
+ ) -> None:
3556
+ pytest.importorskip("textual")
3557
+
3558
+ import re
3559
+
3560
+ from kolega_code.agent import AgentEvent
3561
+
3562
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3563
+
3564
+ async with app.run_test():
3565
+ line = app._format_log_line("boom", "error")
3566
+ assert re.fullmatch(r"\d{2}:\d{2}:\d{2} \S+ boom", line.plain)
3567
+
3568
+ written: list[object] = []
3569
+ monkeypatch.setattr(app._logs, "write", written.append)
3570
+ app._render_event(
3571
+ AgentEvent(event_type="log_message", sender="coder", content={"level": "error", "message": "it [broke]"})
3572
+ )
3573
+ assert len(written) == 1
3574
+ assert "[error]" not in written[0].plain # no raw level prefix
3575
+ assert "it [broke]" in written[0].plain # brackets survive without markup errors
3576
+
3577
+
3578
+ @pytest.mark.asyncio
3579
+ async def test_terminal_commands_render_as_styled_blocks(
3580
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3581
+ ) -> None:
3582
+ pytest.importorskip("textual")
3583
+
3584
+ from kolega_code.agent import AgentEvent
3585
+ from kolega_code.cli import theme
3586
+
3587
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3588
+
3589
+ async with app.run_test():
3590
+ formatted = app._format_terminal_command("ls -la")
3591
+ assert formatted.plain == f"{theme.g(theme.Glyph.USER)} ls -la"
3592
+
3593
+ written: list[object] = []
3594
+ monkeypatch.setattr(app._terminal, "write", written.append)
3595
+ app._render_event(AgentEvent(event_type="terminal_command", sender="coder", content={"command": "echo one"}))
3596
+ app._render_event(AgentEvent(event_type="terminal_output", sender="coder", content={"output": "one"}))
3597
+ app._render_event(AgentEvent(event_type="terminal_command", sender="coder", content={"command": "echo two"}))
3598
+
3599
+ plains = [item.plain if hasattr(item, "plain") else item for item in written]
3600
+ # Second command block is preceded by a blank separator line
3601
+ assert plains == [f"{theme.g(theme.Glyph.USER)} echo one", "one", "", f"{theme.g(theme.Glyph.USER)} echo two"]
3602
+
3603
+
3604
+ @pytest.mark.asyncio
3605
+ async def test_status_dashboard_context_note_uses_alert_level(
3606
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3607
+ ) -> None:
3608
+ pytest.importorskip("textual")
3609
+
3610
+ from kolega_code.agent import AgentEvent
3611
+
3612
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3613
+
3614
+ async with app.run_test():
3615
+ def context_event(alert_level):
3616
+ return AgentEvent(
3617
+ event_type="llm_context_update",
3618
+ sender="coder",
3619
+ content={
3620
+ "input_tokens": 1000,
3621
+ "max_tokens": 2000,
3622
+ "usage_percentage": 50.0,
3623
+ "compression_threshold": 80.0,
3624
+ "alert_level": alert_level,
3625
+ "message": "Context is getting large.",
3626
+ },
3627
+ )
3628
+
3629
+ app._render_event(context_event("info"))
3630
+ dashboard = app._format_status_dashboard()
3631
+ assert "[yellow]Context is getting large.[/yellow]" in dashboard
3632
+
3633
+ app._render_event(context_event("critical"))
3634
+ dashboard = app._format_status_dashboard()
3635
+ assert "[red]Context is getting large.[/red]" in dashboard
3636
+
3637
+
3638
+ @pytest.mark.asyncio
3639
+ async def test_save_settings_toasts_on_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3640
+ pytest.importorskip("textual")
3641
+
3642
+ from textual.widgets import Input
3643
+
3644
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3645
+
3646
+ async with app.run_test():
3647
+ notifications: list[tuple[str, str]] = []
3648
+
3649
+ def fake_notify(message, *, severity="information", title=None, **kwargs):
3650
+ notifications.append((message, severity))
3651
+
3652
+ monkeypatch.setattr(app, "notify", fake_notify)
3653
+
3654
+ app.query_one("#api_key_input", Input).value = "moonshot-key"
3655
+ await app._save_settings_from_ui()
3656
+
3657
+ assert ("Settings saved.", "information") in notifications
3658
+ status_text = str(app.query_one("#settings_status").render())
3659
+ assert "Active model:" in status_text
3660
+
3661
+
3662
+ @pytest.mark.asyncio
3663
+ async def test_planning_sidebar_marks_empty_states(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3664
+ pytest.importorskip("textual")
3665
+
3666
+ from textual.widgets import Markdown
3667
+
3668
+ from kolega_code.cli.app import PLAN_EMPTY_MESSAGE
3669
+
3670
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3671
+
3672
+ async with app.run_test():
3673
+ plan_md = app.query_one("#planning_plan_markdown", Markdown)
3674
+ assert plan_md.source == PLAN_EMPTY_MESSAGE
3675
+ assert plan_md.has_class("empty-state")
3676
+
3677
+ app._latest_plan = "# Plan\n\n- do the thing"
3678
+ app._refresh_planning_sidebar()
3679
+
3680
+ assert plan_md.source == "# Plan\n\n- do the thing"
3681
+ assert not plan_md.has_class("empty-state")
3682
+
3683
+
3684
+ @pytest.mark.asyncio
3685
+ async def test_logs_tab_shows_activity_dot_until_visited(
3686
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3687
+ ) -> None:
3688
+ pytest.importorskip("textual")
3689
+
3690
+ from textual.widgets import TabbedContent
3691
+
3692
+ from kolega_code.cli import theme
3693
+
3694
+ app = _build_sub_agent_test_app(tmp_path, monkeypatch)
3695
+
3696
+ async with app.run_test() as pilot:
3697
+ tabs = app.query_one("#events", TabbedContent)
3698
+ assert tabs.active == "status_pane"
3699
+
3700
+ app._write_log("background activity")
3701
+ dot = theme.g(theme.Glyph.STATUS)
3702
+ assert str(tabs.get_tab("logs_pane").label) == f"Logs {dot}"
3703
+
3704
+ tabs.active = "logs_pane"
3705
+ await pilot.pause()
3706
+ assert str(tabs.get_tab("logs_pane").label) == "Logs"
3707
+
3708
+ # Writing while the tab is active does not re-add the dot
3709
+ app._write_log("foreground activity")
3710
+ assert str(tabs.get_tab("logs_pane").label) == "Logs"
3711
+
3712
+
3713
+ def _build_mention_test_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
3714
+ from kolega_code.cli import app as app_module
3715
+ from kolega_code.cli.app import KolegaCodeApp
3716
+
3717
+ class FakeCoderAgent:
3718
+ def __init__(self, **kwargs):
3719
+ self.kwargs = kwargs
3720
+ self.history = []
3721
+ self.messages = []
3722
+ self.attachments = []
3723
+
3724
+ def append_user_message(self, content):
3725
+ self.history.append(Message(role="user", content=content))
3726
+
3727
+ def restore_message_history(self, history):
3728
+ self.history = [Message.from_dict(item) for item in history]
3729
+
3730
+ def dump_message_history(self):
3731
+ return [message.to_dict() for message in self.history]
3732
+
3733
+ async def cleanup(self):
3734
+ return None
3735
+
3736
+ async def process_message_stream(self, message, attachments=None):
3737
+ self.messages.append(message)
3738
+ self.attachments.append(attachments)
3739
+ yield {"type": "response", "content": "done", "complete": True, "uuid": "response-1"}
3740
+
3741
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
3742
+
3743
+ project = tmp_path / "project"
3744
+ project.mkdir()
3745
+ (project / "src").mkdir()
3746
+ (project / "src" / "alpha.py").write_text("print('alpha')\n", encoding="utf-8")
3747
+ (project / "src" / "alpine.txt").write_text("mountains\n", encoding="utf-8")
3748
+ (project / "README.md").write_text("# Readme\n", encoding="utf-8")
3749
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
3750
+ store = SessionStore(tmp_path / "state")
3751
+ session = store.create(project, "code", config_summary(config))
3752
+ return KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
3753
+
3754
+
3755
+ @pytest.mark.asyncio
3756
+ async def test_textual_app_mention_dropdown_opens_and_escape_dismisses(
3757
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3758
+ ) -> None:
3759
+ pytest.importorskip("textual")
3760
+
3761
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3762
+
3763
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3764
+
3765
+ async with app.run_test() as pilot:
3766
+ composer = app.query_one("#composer", ChatComposer)
3767
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3768
+ composer.focus()
3769
+ await pilot.pause()
3770
+
3771
+ composer.insert("@alp")
3772
+ await pilot.pause()
3773
+ assert dropdown.is_open
3774
+ assert dropdown.option_count > 0
3775
+
3776
+ await pilot.press("escape")
3777
+ assert not dropdown.is_open
3778
+ assert composer.text == "@alp"
3779
+
3780
+
3781
+ @pytest.mark.asyncio
3782
+ async def test_textual_app_mention_dropdown_not_opened_by_email_address(
3783
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3784
+ ) -> None:
3785
+ pytest.importorskip("textual")
3786
+
3787
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3788
+
3789
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3790
+
3791
+ async with app.run_test() as pilot:
3792
+ composer = app.query_one("#composer", ChatComposer)
3793
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3794
+ composer.focus()
3795
+ composer.insert("mail user@example")
3796
+ await pilot.pause()
3797
+ assert not dropdown.is_open
3798
+
3799
+
3800
+ @pytest.mark.asyncio
3801
+ async def test_textual_app_mention_dropdown_down_and_tab_completes(
3802
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3803
+ ) -> None:
3804
+ pytest.importorskip("textual")
3805
+
3806
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3807
+
3808
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3809
+
3810
+ async with app.run_test() as pilot:
3811
+ composer = app.query_one("#composer", ChatComposer)
3812
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3813
+ composer.focus()
3814
+ composer.insert("@alp")
3815
+ await pilot.pause()
3816
+ assert dropdown.is_open
3817
+
3818
+ expected = dropdown.entry_at(1).path
3819
+ await pilot.press("down")
3820
+ assert dropdown.highlighted == 1
3821
+ await pilot.press("tab")
3822
+ assert composer.text == f"@{expected} "
3823
+ assert not dropdown.is_open
3824
+
3825
+
3826
+ @pytest.mark.asyncio
3827
+ async def test_textual_app_mention_enter_completes_instead_of_submitting(
3828
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3829
+ ) -> None:
3830
+ pytest.importorskip("textual")
3831
+
3832
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3833
+
3834
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3835
+
3836
+ async with app.run_test() as pilot:
3837
+ composer = app.query_one("#composer", ChatComposer)
3838
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3839
+ composer.focus()
3840
+ composer.insert("@README")
3841
+ await pilot.pause()
3842
+ assert dropdown.is_open
3843
+
3844
+ await pilot.press("enter")
3845
+ await pilot.pause()
3846
+ assert composer.text == "@README.md "
3847
+ assert not dropdown.is_open
3848
+ # No message was submitted, only the completion was applied.
3849
+ assert app.agent.messages == []
3850
+
3851
+
3852
+ @pytest.mark.asyncio
3853
+ async def test_textual_app_submitting_mention_attaches_file_and_keeps_short_text(
3854
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3855
+ ) -> None:
3856
+ pytest.importorskip("textual")
3857
+
3858
+ from kolega_code.cli.app import ChatComposer
3859
+
3860
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3861
+
3862
+ async with app.run_test() as pilot:
3863
+ composer = app.query_one("#composer", ChatComposer)
3864
+ composer.load_text("summarize @src/alpha.py please")
3865
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
3866
+ await pilot.pause()
3867
+
3868
+ assert app.agent.messages == ["summarize @src/alpha.py please"]
3869
+ attachments = app.agent.attachments[0]
3870
+ assert attachments is not None and len(attachments) == 1
3871
+ assert attachments[0]["type"] == "file"
3872
+ assert attachments[0]["path"] == "src/alpha.py"
3873
+ assert attachments[0]["content"] == "print('alpha')\n"
3874
+ assert any(
3875
+ entry.kind == "user" and entry.content == "summarize @src/alpha.py please"
3876
+ for entry in app.conversation_entries
3877
+ )
3878
+
3879
+
3880
+ @pytest.mark.asyncio
3881
+ async def test_textual_app_unresolved_mention_shows_hint_and_sends_plain_text(
3882
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3883
+ ) -> None:
3884
+ pytest.importorskip("textual")
3885
+
3886
+ from textual.widgets import Static
3887
+
3888
+ from kolega_code.cli.app import ChatComposer
3889
+
3890
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3891
+
3892
+ async with app.run_test() as pilot:
3893
+ composer = app.query_one("#composer", ChatComposer)
3894
+ composer.load_text("look at @does/not/exist.py")
3895
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
3896
+
3897
+ # The hint is visible while the turn runs; end-of-turn cleanup restores the placeholder.
3898
+ hint = app.query_one("#composer_hint", Static)
3899
+ assert "does/not/exist.py" in str(hint.render())
3900
+
3901
+ await pilot.pause()
3902
+ assert app.agent.messages == ["look at @does/not/exist.py"]
3903
+ assert app.agent.attachments == [None]
3904
+
3905
+
3906
+ @pytest.mark.asyncio
3907
+ async def test_textual_app_slash_dropdown_opens_filters_and_tab_completes(
3908
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3909
+ ) -> None:
3910
+ pytest.importorskip("textual")
3911
+
3912
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3913
+ from kolega_code.cli.slash_commands import SlashCommandEntry
3914
+
3915
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3916
+
3917
+ async with app.run_test() as pilot:
3918
+ composer = app.query_one("#composer", ChatComposer)
3919
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3920
+ composer.focus()
3921
+ await pilot.pause()
3922
+
3923
+ composer.insert("/")
3924
+ await pilot.pause()
3925
+ assert dropdown.is_open
3926
+ assert dropdown.option_count > 1
3927
+ assert isinstance(dropdown.highlighted_entry(), SlashCommandEntry)
3928
+
3929
+ composer.insert("pl")
3930
+ await pilot.pause()
3931
+ assert dropdown.is_open
3932
+ assert dropdown.highlighted_entry().name == "plan"
3933
+
3934
+ await pilot.press("tab")
3935
+ assert composer.text == "/plan "
3936
+ assert not dropdown.is_open
3937
+
3938
+
3939
+ @pytest.mark.asyncio
3940
+ async def test_textual_app_slash_dropdown_lists_skills_with_descriptions(
3941
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3942
+ ) -> None:
3943
+ pytest.importorskip("textual")
3944
+
3945
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3946
+
3947
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3948
+ skill_dir = app.project_path / ".agents" / "skills" / "demo-skill"
3949
+ skill_dir.mkdir(parents=True)
3950
+ (skill_dir / "SKILL.md").write_text(
3951
+ "---\nname: demo-skill\ndescription: Use this demo skill.\n---\n\nFollow demo instructions.\n",
3952
+ encoding="utf-8",
3953
+ )
3954
+
3955
+ async with app.run_test() as pilot:
3956
+ composer = app.query_one("#composer", ChatComposer)
3957
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3958
+ composer.focus()
3959
+ composer.insert("/demo")
3960
+ await pilot.pause()
3961
+
3962
+ assert dropdown.is_open
3963
+ entry = dropdown.highlighted_entry()
3964
+ assert entry.name == "demo-skill"
3965
+ assert entry.description == "Use this demo skill."
3966
+
3967
+ await pilot.press("tab")
3968
+ assert composer.text == "/demo-skill "
3969
+
3970
+
3971
+ @pytest.mark.asyncio
3972
+ async def test_textual_app_slash_dropdown_enter_completes_instead_of_submitting(
3973
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3974
+ ) -> None:
3975
+ pytest.importorskip("textual")
3976
+
3977
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
3978
+
3979
+ app = _build_mention_test_app(tmp_path, monkeypatch)
3980
+
3981
+ async with app.run_test() as pilot:
3982
+ composer = app.query_one("#composer", ChatComposer)
3983
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
3984
+ composer.focus()
3985
+ composer.insert("/versio")
3986
+ await pilot.pause()
3987
+ assert dropdown.is_open
3988
+
3989
+ await pilot.press("enter")
3990
+ await pilot.pause()
3991
+ assert composer.text == "/version "
3992
+ assert not dropdown.is_open
3993
+ assert app.agent.messages == []
3994
+
3995
+
3996
+ @pytest.mark.asyncio
3997
+ async def test_textual_app_slash_dropdown_does_not_open_mid_text_or_after_args(
3998
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3999
+ ) -> None:
4000
+ pytest.importorskip("textual")
4001
+
4002
+ from kolega_code.cli.app import ChatComposer, CompletionDropdown
4003
+
4004
+ app = _build_mention_test_app(tmp_path, monkeypatch)
4005
+
4006
+ async with app.run_test() as pilot:
4007
+ composer = app.query_one("#composer", ChatComposer)
4008
+ dropdown = app.query_one("#completion_dropdown", CompletionDropdown)
4009
+ composer.focus()
4010
+
4011
+ composer.insert("see src/")
4012
+ await pilot.pause()
4013
+ assert not dropdown.is_open
4014
+
4015
+ composer.load_text("")
4016
+ composer.insert("first line")
4017
+ composer.action_insert_newline()
4018
+ composer.insert("/")
4019
+ await pilot.pause()
4020
+ assert not dropdown.is_open
4021
+
4022
+ composer.load_text("")
4023
+ composer.insert("/skills extra")
4024
+ await pilot.pause()
4025
+ assert not dropdown.is_open
4026
+
4027
+
4028
+ @pytest.mark.asyncio
4029
+ async def test_chat_composer_active_slash_query(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
4030
+ pytest.importorskip("textual")
4031
+
4032
+ from kolega_code.cli.app import ChatComposer
4033
+
4034
+ app = _build_mention_test_app(tmp_path, monkeypatch)
4035
+
4036
+ async with app.run_test():
4037
+ composer = app.query_one("#composer", ChatComposer)
4038
+ composer.focus()
4039
+
4040
+ composer.insert("/")
4041
+ assert composer.active_slash_query() == ("", 0, 1)
4042
+
4043
+ composer.insert("mod")
4044
+ assert composer.active_slash_query() == ("mod", 0, 4)
4045
+
4046
+ composer.load_text("")
4047
+ composer.insert(" /he")
4048
+ assert composer.active_slash_query() == ("he", 2, 5)
4049
+
4050
+ composer.load_text("")
4051
+ composer.insert("hello /he")
4052
+ assert composer.active_slash_query() is None
4053
+
4054
+ composer.load_text("")
4055
+ composer.insert("/model kimi")
4056
+ assert composer.active_slash_query() is None
4057
+
4058
+
4059
+ @pytest.mark.asyncio
4060
+ async def test_textual_app_plan_and_build_slash_commands_switch_mode(
4061
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
4062
+ ) -> None:
4063
+ pytest.importorskip("textual")
4064
+
4065
+ from kolega_code.cli import app as app_module
4066
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
4067
+
4068
+ class FakeAgent:
4069
+ def __init__(self, **kwargs):
4070
+ self.kwargs = kwargs
4071
+
4072
+ def restore_message_history(self, history):
4073
+ return None
4074
+
4075
+ def dump_message_history(self):
4076
+ return []
4077
+
4078
+ async def cleanup(self):
4079
+ return None
4080
+
4081
+ class FakeCoderAgent(FakeAgent):
4082
+ pass
4083
+
4084
+ class FakePlanningAgent(FakeAgent):
4085
+ pass
4086
+
4087
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
4088
+ monkeypatch.setattr(app_module, "PlanningAgent", FakePlanningAgent)
4089
+
4090
+ project = tmp_path / "project"
4091
+ project.mkdir()
4092
+ config = build_agent_config(project, env={"ANTHROPIC_API_KEY": "test-key"})
4093
+ store = SessionStore(tmp_path / "state")
4094
+ session = store.create(project, "code", config_summary(config))
4095
+ app = KolegaCodeApp(project_path=project, config=config, mode="code", store=store, session=session)
4096
+
4097
+ async with app.run_test():
4098
+ composer = app.query_one("#composer", ChatComposer)
4099
+ assert app.interaction_mode == "build"
4100
+
4101
+ composer.load_text("/plan")
4102
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4103
+ assert app.interaction_mode == "plan"
4104
+ assert isinstance(app.agent, FakePlanningAgent)
4105
+ assert composer.text == ""
4106
+
4107
+ composer.load_text("/build")
4108
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4109
+ assert app.interaction_mode == "build"
4110
+ assert isinstance(app.agent, FakeCoderAgent)
4111
+
4112
+
4113
+ @pytest.mark.asyncio
4114
+ async def test_textual_app_model_slash_command_shows_and_switches_model(
4115
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_cli_env: None
4116
+ ) -> None:
4117
+ pytest.importorskip("textual")
4118
+
4119
+ from kolega_code.cli import app as app_module
4120
+ from kolega_code.cli.app import ChatComposer, KolegaCodeApp
4121
+
4122
+ class FakeCoderAgent:
4123
+ def __init__(self, **kwargs):
4124
+ self.kwargs = kwargs
4125
+
4126
+ def restore_message_history(self, history):
4127
+ return None
4128
+
4129
+ def dump_message_history(self):
4130
+ return []
4131
+
4132
+ async def cleanup(self):
4133
+ return None
4134
+
4135
+ monkeypatch.setattr(app_module, "CoderAgent", FakeCoderAgent)
4136
+ monkeypatch.setattr(
4137
+ app_module,
4138
+ "ui_model_options",
4139
+ lambda provider: [("Kimi K2.6", "kimi-k2.6"), ("Kimi K3", "kimi-k3")],
4140
+ )
4141
+
4142
+ project = tmp_path / "project"
4143
+ project.mkdir()
4144
+ state_dir = tmp_path / "state"
4145
+ store = SessionStore(state_dir)
4146
+ settings_store = SettingsStore(state_dir)
4147
+ session = store.create(project, "code", {})
4148
+ app = KolegaCodeApp(
4149
+ project_path=project,
4150
+ mode="code",
4151
+ store=store,
4152
+ settings_store=settings_store,
4153
+ session=session,
4154
+ )
4155
+
4156
+ async with app.run_test():
4157
+ from textual.widgets import Input
4158
+
4159
+ app.query_one("#api_key_input", Input).value = "moonshot-key"
4160
+ await app._save_settings_from_ui()
4161
+ first_agent = app.agent
4162
+ assert isinstance(first_agent, FakeCoderAgent)
4163
+
4164
+ composer = app.query_one("#composer", ChatComposer)
4165
+ composer.load_text("/model")
4166
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4167
+ entry = app.conversation_entries[-1]
4168
+ assert entry.kind == "system"
4169
+ assert "kimi-k2.6" in entry.content and "kimi-k3" in entry.content
4170
+
4171
+ # kimi-k3 is a fake model the real config builder rejects, so stub it for the rebuild step.
4172
+ saved_config = app.config
4173
+ monkeypatch.setattr(app_module, "build_agent_config", lambda *args, **kwargs: saved_config)
4174
+
4175
+ composer.load_text("/model kimi-k3")
4176
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4177
+ assert settings_store.load().active_model == "kimi-k3"
4178
+ assert isinstance(app.agent, FakeCoderAgent)
4179
+ assert app.agent is not first_agent
4180
+
4181
+ composer.load_text("/model does-not-exist")
4182
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4183
+ assert settings_store.load().active_model == "kimi-k3"
4184
+
4185
+
4186
+ @pytest.mark.asyncio
4187
+ async def test_textual_app_copy_and_version_slash_commands(
4188
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
4189
+ ) -> None:
4190
+ pytest.importorskip("textual")
4191
+
4192
+ import kolega_code
4193
+ from kolega_code.cli.app import ChatComposer, ConversationEntry
4194
+
4195
+ app = _build_mention_test_app(tmp_path, monkeypatch)
4196
+ copied: list[str] = []
4197
+
4198
+ async with app.run_test():
4199
+ monkeypatch.setattr(app, "copy_to_clipboard", copied.append)
4200
+ composer = app.query_one("#composer", ChatComposer)
4201
+
4202
+ composer.load_text("/copy")
4203
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4204
+ assert copied == []
4205
+
4206
+ app._add_conversation_entry(ConversationEntry(kind="assistant", content="the answer"))
4207
+ composer.load_text("/copy")
4208
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4209
+ assert copied == ["the answer"]
4210
+
4211
+ composer.load_text("/version")
4212
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4213
+ entry = app.conversation_entries[-1]
4214
+ assert entry.kind == "system"
4215
+ assert kolega_code.__version__ in entry.content
4216
+
4217
+
4218
+ @pytest.mark.asyncio
4219
+ async def test_textual_app_quit_slash_command_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
4220
+ pytest.importorskip("textual")
4221
+
4222
+ from kolega_code.cli.app import ChatComposer
4223
+
4224
+ app = _build_mention_test_app(tmp_path, monkeypatch)
4225
+
4226
+ async with app.run_test():
4227
+ composer = app.query_one("#composer", ChatComposer)
4228
+ composer.load_text("/quit")
4229
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4230
+
4231
+ assert app.return_value is None
4232
+ assert not app.is_running
4233
+
4234
+
4235
+ @pytest.mark.asyncio
4236
+ async def test_textual_app_unknown_slash_command_falls_through_to_agent(
4237
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
4238
+ ) -> None:
4239
+ pytest.importorskip("textual")
4240
+
4241
+ from kolega_code.cli.app import ChatComposer
4242
+
4243
+ app = _build_mention_test_app(tmp_path, monkeypatch)
4244
+
4245
+ async with app.run_test() as pilot:
4246
+ composer = app.query_one("#composer", ChatComposer)
4247
+ composer.load_text("/help")
4248
+ await app.on_chat_composer_submitted(ChatComposer.Submitted(composer, composer.text))
4249
+ await pilot.pause()
4250
+
4251
+ assert app.agent.messages == ["/help"]