kolega-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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"]
|