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