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,98 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from kolega_code.sandbox.terminal import SandboxTerminalManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeCommandHandle:
|
|
10
|
+
def __init__(self, pid: int = 123):
|
|
11
|
+
self.pid = pid
|
|
12
|
+
self._done = asyncio.Event()
|
|
13
|
+
|
|
14
|
+
async def wait(self):
|
|
15
|
+
await self._done.wait()
|
|
16
|
+
return SimpleNamespace(exit_code=0)
|
|
17
|
+
|
|
18
|
+
def complete(self) -> None:
|
|
19
|
+
self._done.set()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeCommands:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.handle = FakeCommandHandle()
|
|
25
|
+
self.run_calls = []
|
|
26
|
+
self.send_stdin_calls = []
|
|
27
|
+
self.started = asyncio.Event()
|
|
28
|
+
|
|
29
|
+
async def run(self, command: str, **kwargs):
|
|
30
|
+
if command.startswith("test -d"):
|
|
31
|
+
return SimpleNamespace(exit_code=0)
|
|
32
|
+
|
|
33
|
+
self.run_calls.append((command, kwargs))
|
|
34
|
+
self.started.set()
|
|
35
|
+
return self.handle
|
|
36
|
+
|
|
37
|
+
async def send_stdin(self, pid: int, data: str):
|
|
38
|
+
self.send_stdin_calls.append((pid, data))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeSandbox:
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.commands = FakeCommands()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_sandbox_terminal_input_uses_active_command_stdin():
|
|
48
|
+
sandbox = FakeSandbox()
|
|
49
|
+
manager = SandboxTerminalManager(sandbox, "workspace", "thread")
|
|
50
|
+
terminal_id = await manager.launch_terminal()
|
|
51
|
+
|
|
52
|
+
command_id = await manager.send_command_tracked(terminal_id, "python prompt.py", "Prompt test")
|
|
53
|
+
await asyncio.wait_for(sandbox.commands.started.wait(), timeout=1)
|
|
54
|
+
|
|
55
|
+
assert sandbox.commands.run_calls
|
|
56
|
+
_, kwargs = sandbox.commands.run_calls[0]
|
|
57
|
+
assert kwargs["background"] is True
|
|
58
|
+
assert kwargs["stdin"] is True
|
|
59
|
+
assert manager.command_history[command_id]["pid"] == 123
|
|
60
|
+
|
|
61
|
+
result = await manager.send_input(terminal_id, "Ada")
|
|
62
|
+
|
|
63
|
+
assert result is True
|
|
64
|
+
assert sandbox.commands.send_stdin_calls == [(123, "Ada\n")]
|
|
65
|
+
assert "Ada" not in manager.read_output(terminal_id, num_chars=1000)
|
|
66
|
+
|
|
67
|
+
sandbox.commands.handle.complete()
|
|
68
|
+
for _ in range(10):
|
|
69
|
+
if manager.get_command_status(terminal_id, command_id)["status"] == "completed":
|
|
70
|
+
break
|
|
71
|
+
await asyncio.sleep(0.01)
|
|
72
|
+
|
|
73
|
+
assert manager.get_command_status(terminal_id, command_id)["status"] == "completed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_sandbox_terminal_input_requires_command_id_when_ambiguous():
|
|
78
|
+
manager = SandboxTerminalManager(FakeSandbox(), "workspace", "thread")
|
|
79
|
+
terminal_id = await manager.launch_terminal()
|
|
80
|
+
first_command = {
|
|
81
|
+
"command": "python prompt_one.py",
|
|
82
|
+
"terminal_id": terminal_id,
|
|
83
|
+
"status": "running",
|
|
84
|
+
"pid": 101,
|
|
85
|
+
}
|
|
86
|
+
second_command = {
|
|
87
|
+
"command": "python prompt_two.py",
|
|
88
|
+
"terminal_id": terminal_id,
|
|
89
|
+
"status": "running",
|
|
90
|
+
"pid": 102,
|
|
91
|
+
}
|
|
92
|
+
manager.command_history["cmd_1"] = first_command
|
|
93
|
+
manager.command_history["cmd_2"] = second_command
|
|
94
|
+
manager.terminals[terminal_id]["active_commands"]["cmd_1"] = first_command
|
|
95
|
+
manager.terminals[terminal_id]["active_commands"]["cmd_2"] = second_command
|
|
96
|
+
|
|
97
|
+
with pytest.raises(ValueError, match="Multiple active commands"):
|
|
98
|
+
await manager.send_input(terminal_id, "Ada")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import AsyncMock, Mock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from kolega_code.services.terminal import AsyncPersistentTerminal, LocalTerminalManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestAsyncPersistentTerminal:
|
|
10
|
+
"""Test class for AsyncPersistentTerminal read_output with offset functionality."""
|
|
11
|
+
|
|
12
|
+
def test_read_output_with_offset(self):
|
|
13
|
+
"""Test read_output method with offset parameter."""
|
|
14
|
+
terminal = AsyncPersistentTerminal(
|
|
15
|
+
workspace_id="test_workspace",
|
|
16
|
+
thread_id="test_thread",
|
|
17
|
+
terminal_id="test_terminal",
|
|
18
|
+
connection_manager=Mock(),
|
|
19
|
+
auto_activate_venv=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Create test output
|
|
23
|
+
test_output = "0123456789ABCDEFGHIJ" # 20 characters
|
|
24
|
+
terminal.persistent_output_buffer = bytearray(test_output.encode())
|
|
25
|
+
|
|
26
|
+
# Test default behavior (offset = 0)
|
|
27
|
+
result = terminal.read_output(num_chars=5, offset=0)
|
|
28
|
+
assert result == "FGHIJ" # Last 5 characters
|
|
29
|
+
|
|
30
|
+
# Test with offset = 3 (skip last 3 characters, read 5 before that)
|
|
31
|
+
result = terminal.read_output(num_chars=5, offset=3)
|
|
32
|
+
assert result == "CDEFG" # Characters at indices 12-16 (skipping last 3: HIJ)
|
|
33
|
+
|
|
34
|
+
# Test with offset = 10 (skip last 10 characters, read 5 before that)
|
|
35
|
+
result = terminal.read_output(num_chars=5, offset=10)
|
|
36
|
+
assert result == "56789" # Characters at indices 5-9
|
|
37
|
+
|
|
38
|
+
# Test edge case: offset + num_chars > total length
|
|
39
|
+
result = terminal.read_output(num_chars=15, offset=5)
|
|
40
|
+
assert result == "0123456789ABCDE" # Should read from start to (total - offset)
|
|
41
|
+
|
|
42
|
+
# Test edge case: offset >= total length
|
|
43
|
+
result = terminal.read_output(num_chars=5, offset=25)
|
|
44
|
+
assert result == "" # Should return empty string
|
|
45
|
+
|
|
46
|
+
# Test when trying to read more than available
|
|
47
|
+
result = terminal.read_output(num_chars=10, offset=5)
|
|
48
|
+
assert result == "56789ABCDE" # Characters at indices 5-14 (skipping last 5: FGHIJ)
|
|
49
|
+
|
|
50
|
+
# Test with empty buffer
|
|
51
|
+
terminal.persistent_output_buffer = bytearray()
|
|
52
|
+
result = terminal.read_output(num_chars=5, offset=3)
|
|
53
|
+
assert result == ""
|
|
54
|
+
|
|
55
|
+
def test_read_output_with_unicode_and_offset(self):
|
|
56
|
+
"""Test read_output method with offset parameter and unicode characters."""
|
|
57
|
+
terminal = AsyncPersistentTerminal(
|
|
58
|
+
workspace_id="test_workspace",
|
|
59
|
+
thread_id="test_thread",
|
|
60
|
+
terminal_id="test_terminal",
|
|
61
|
+
connection_manager=Mock(),
|
|
62
|
+
auto_activate_venv=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Create test output with unicode characters
|
|
66
|
+
test_output = "Hello 🌍 World 🚀 Test" # Mix of ASCII and unicode
|
|
67
|
+
terminal.persistent_output_buffer = bytearray(test_output.encode())
|
|
68
|
+
|
|
69
|
+
# Test reading with offset (should handle unicode properly)
|
|
70
|
+
result = terminal.read_output(num_chars=10, offset=5)
|
|
71
|
+
expected_total_chars = len(test_output)
|
|
72
|
+
expected_start = max(0, expected_total_chars - 5 - 10)
|
|
73
|
+
expected_end = max(0, expected_total_chars - 5)
|
|
74
|
+
expected = test_output[expected_start:expected_end]
|
|
75
|
+
assert result == expected
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_send_input_appends_newline_without_mutating_last_command(self):
|
|
79
|
+
terminal = AsyncPersistentTerminal(
|
|
80
|
+
workspace_id="test_workspace",
|
|
81
|
+
thread_id="test_thread",
|
|
82
|
+
terminal_id="test_terminal",
|
|
83
|
+
connection_manager=Mock(),
|
|
84
|
+
auto_activate_venv=False,
|
|
85
|
+
)
|
|
86
|
+
terminal.is_running = True
|
|
87
|
+
terminal.master_fd = 123
|
|
88
|
+
terminal.last_command = "python prompt.py\n"
|
|
89
|
+
terminal.last_command_purpose = "Prompt test"
|
|
90
|
+
|
|
91
|
+
with patch("kolega_code.services.terminal.os.write") as mock_write:
|
|
92
|
+
result = await terminal.send_input("Ada", submit=True)
|
|
93
|
+
|
|
94
|
+
assert result is True
|
|
95
|
+
mock_write.assert_called_once_with(123, b"Ada\n")
|
|
96
|
+
assert terminal.last_command == "python prompt.py\n"
|
|
97
|
+
assert terminal.last_command_purpose == "Prompt test"
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_send_input_can_send_raw_text(self):
|
|
101
|
+
terminal = AsyncPersistentTerminal(
|
|
102
|
+
workspace_id="test_workspace",
|
|
103
|
+
thread_id="test_thread",
|
|
104
|
+
terminal_id="test_terminal",
|
|
105
|
+
connection_manager=Mock(),
|
|
106
|
+
auto_activate_venv=False,
|
|
107
|
+
)
|
|
108
|
+
terminal.is_running = True
|
|
109
|
+
terminal.master_fd = 123
|
|
110
|
+
|
|
111
|
+
with patch("kolega_code.services.terminal.os.write") as mock_write:
|
|
112
|
+
result = await terminal.send_input("A", submit=False)
|
|
113
|
+
|
|
114
|
+
assert result is True
|
|
115
|
+
mock_write.assert_called_once_with(123, b"A")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_local_terminal_manager_can_answer_python_prompt(tmp_path):
|
|
120
|
+
manager = LocalTerminalManager("test_workspace", "test_thread", AsyncMock())
|
|
121
|
+
terminal_id = await manager.launch_terminal(cwd=tmp_path, auto_activate_venv=False)
|
|
122
|
+
command_id = None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
command_id = await manager.send_command_tracked(
|
|
126
|
+
terminal_id,
|
|
127
|
+
'python -c "prompt = \'READY_\' + \'FOR_\' + \'INPUT>\'; name = input(prompt); print(\'hello \' + name)"',
|
|
128
|
+
"Prompt for a name and echo it",
|
|
129
|
+
)
|
|
130
|
+
assert command_id
|
|
131
|
+
|
|
132
|
+
prompt_seen = False
|
|
133
|
+
for _ in range(50):
|
|
134
|
+
if "READY_FOR_INPUT>" in manager.read_output(terminal_id, num_chars=500):
|
|
135
|
+
prompt_seen = True
|
|
136
|
+
break
|
|
137
|
+
await asyncio.sleep(0.1)
|
|
138
|
+
|
|
139
|
+
assert prompt_seen
|
|
140
|
+
assert await manager.send_input(terminal_id, "Ada", command_id=command_id)
|
|
141
|
+
|
|
142
|
+
completed = False
|
|
143
|
+
for _ in range(50):
|
|
144
|
+
status = manager.get_command_status(terminal_id, command_id)
|
|
145
|
+
if status["status"] == "completed":
|
|
146
|
+
completed = True
|
|
147
|
+
break
|
|
148
|
+
await asyncio.sleep(0.1)
|
|
149
|
+
|
|
150
|
+
assert completed
|
|
151
|
+
assert "hello Ada" in manager.read_output(terminal_id, num_chars=1000)
|
|
152
|
+
finally:
|
|
153
|
+
if terminal_id in manager.terminals:
|
|
154
|
+
await manager.close_terminal(terminal_id)
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from unittest.mock import AsyncMock, Mock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import psutil
|
|
7
|
+
|
|
8
|
+
from kolega_code.services.terminal import AsyncPersistentTerminal, LocalTerminalManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Check if running in CI environment
|
|
12
|
+
SKIP_IN_CI = bool(os.getenv("CI")) or bool(os.getenv("GITLAB_CI"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_connection_manager():
|
|
17
|
+
return AsyncMock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def workspace_id():
|
|
22
|
+
return "test_workspace"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def terminal_id():
|
|
27
|
+
return "test_terminal"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestAsyncPersistentTerminalCommandTracking:
|
|
31
|
+
"""Tests for command tracking in AsyncPersistentTerminal"""
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def mock_terminal(self, workspace_id, terminal_id, mock_connection_manager):
|
|
35
|
+
"""Create a mock terminal without actually starting it"""
|
|
36
|
+
with patch("kolega_code.services.terminal.pty.fork"), patch(
|
|
37
|
+
"kolega_code.services.terminal.os.execvpe"
|
|
38
|
+
), patch("kolega_code.services.terminal.fcntl.fcntl"):
|
|
39
|
+
# Don't patch asyncio.create_task here - let each test handle it
|
|
40
|
+
|
|
41
|
+
terminal = AsyncPersistentTerminal(
|
|
42
|
+
workspace_id=workspace_id,
|
|
43
|
+
thread_id="test_thread",
|
|
44
|
+
terminal_id=terminal_id,
|
|
45
|
+
connection_manager=mock_connection_manager,
|
|
46
|
+
cwd="/tmp",
|
|
47
|
+
auto_activate_venv=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Mock the required attributes for testing
|
|
51
|
+
terminal.is_running = True
|
|
52
|
+
terminal.master_fd = 123
|
|
53
|
+
terminal.pid = 12345
|
|
54
|
+
terminal.shell_cleaned = True
|
|
55
|
+
|
|
56
|
+
return terminal
|
|
57
|
+
|
|
58
|
+
def test_command_tracking_initialization(self, mock_terminal):
|
|
59
|
+
"""Test that command tracking attributes are initialized correctly"""
|
|
60
|
+
assert mock_terminal.active_commands == {}
|
|
61
|
+
assert mock_terminal.command_counter == 0
|
|
62
|
+
assert mock_terminal.shell_prompt_detected
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_send_command_tracked_success(self, mock_terminal):
|
|
66
|
+
"""Test successful command tracking"""
|
|
67
|
+
# Mock the send_command method
|
|
68
|
+
mock_terminal.send_command = AsyncMock(return_value=True)
|
|
69
|
+
|
|
70
|
+
# Mock the monitoring task creation with a function that properly handles the coroutine
|
|
71
|
+
def mock_create_task(coro):
|
|
72
|
+
# Close the coroutine to avoid warnings
|
|
73
|
+
if hasattr(coro, "close"):
|
|
74
|
+
coro.close()
|
|
75
|
+
return Mock()
|
|
76
|
+
|
|
77
|
+
with patch("asyncio.create_task", side_effect=mock_create_task) as mock_create_task_patch:
|
|
78
|
+
command_id = await mock_terminal.send_command_tracked("echo test", "Test purpose")
|
|
79
|
+
|
|
80
|
+
assert command_id == "test_terminal_1"
|
|
81
|
+
assert "test_terminal_1" in mock_terminal.active_commands
|
|
82
|
+
|
|
83
|
+
command_info = mock_terminal.active_commands["test_terminal_1"]
|
|
84
|
+
assert command_info["command"] == "echo test"
|
|
85
|
+
assert command_info["purpose"] == "Test purpose"
|
|
86
|
+
assert command_info["status"] == "running"
|
|
87
|
+
assert command_info["child_pids"] == set()
|
|
88
|
+
assert command_info["return_code"] is None
|
|
89
|
+
assert isinstance(command_info["start_time"], float)
|
|
90
|
+
|
|
91
|
+
# Verify monitoring task was created
|
|
92
|
+
assert mock_create_task_patch.call_count == 1
|
|
93
|
+
assert not mock_terminal.shell_prompt_detected
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_send_command_tracked_failure(self, mock_terminal):
|
|
97
|
+
"""Test command tracking when send fails"""
|
|
98
|
+
# Mock the send_command method to fail
|
|
99
|
+
mock_terminal.send_command = AsyncMock(return_value=False)
|
|
100
|
+
|
|
101
|
+
command_id = await mock_terminal.send_command_tracked("echo test", "Test purpose")
|
|
102
|
+
|
|
103
|
+
assert command_id is None
|
|
104
|
+
assert len(mock_terminal.active_commands) == 0
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_send_command_tracked_terminal_not_running(self, mock_terminal):
|
|
108
|
+
"""Test command tracking when terminal is not running"""
|
|
109
|
+
mock_terminal.is_running = False
|
|
110
|
+
|
|
111
|
+
command_id = await mock_terminal.send_command_tracked("echo test", "Test purpose")
|
|
112
|
+
|
|
113
|
+
assert command_id is None
|
|
114
|
+
assert len(mock_terminal.active_commands) == 0
|
|
115
|
+
|
|
116
|
+
def test_get_command_status_running(self, mock_terminal):
|
|
117
|
+
"""Test getting status of a running command"""
|
|
118
|
+
# Add a test command
|
|
119
|
+
start_time = time.time()
|
|
120
|
+
mock_terminal.active_commands["test_cmd"] = {
|
|
121
|
+
"command": "sleep 10",
|
|
122
|
+
"purpose": "Test sleep",
|
|
123
|
+
"start_time": start_time,
|
|
124
|
+
"status": "running",
|
|
125
|
+
"child_pids": {1234, 5678},
|
|
126
|
+
"return_code": None,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
status = mock_terminal.get_command_status("test_cmd")
|
|
130
|
+
|
|
131
|
+
assert status["status"] == "running"
|
|
132
|
+
assert status["command"] == "sleep 10"
|
|
133
|
+
assert status["purpose"] == "Test sleep"
|
|
134
|
+
assert status["duration"] >= 0
|
|
135
|
+
assert status["return_code"] is None
|
|
136
|
+
assert set(status["child_pids"]) == {1234, 5678}
|
|
137
|
+
|
|
138
|
+
def test_get_command_status_not_found(self, mock_terminal):
|
|
139
|
+
"""Test getting status of non-existent command"""
|
|
140
|
+
status = mock_terminal.get_command_status("nonexistent")
|
|
141
|
+
|
|
142
|
+
assert status["status"] == "not_found"
|
|
143
|
+
|
|
144
|
+
def test_get_active_commands(self, mock_terminal):
|
|
145
|
+
"""Test getting all active commands"""
|
|
146
|
+
# Add some test commands
|
|
147
|
+
mock_terminal.active_commands = {
|
|
148
|
+
"cmd1": {
|
|
149
|
+
"command": "running_cmd",
|
|
150
|
+
"start_time": time.time(),
|
|
151
|
+
"status": "running",
|
|
152
|
+
"child_pids": set(),
|
|
153
|
+
"return_code": None,
|
|
154
|
+
},
|
|
155
|
+
"cmd2": {
|
|
156
|
+
"command": "completed_cmd",
|
|
157
|
+
"start_time": time.time() - 10,
|
|
158
|
+
"status": "completed",
|
|
159
|
+
"child_pids": set(),
|
|
160
|
+
"return_code": 0,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
active = mock_terminal.get_active_commands()
|
|
165
|
+
|
|
166
|
+
assert len(active) == 1
|
|
167
|
+
assert "cmd1" in active
|
|
168
|
+
assert "cmd2" not in active
|
|
169
|
+
|
|
170
|
+
def test_is_ready_for_commands_true(self, mock_terminal):
|
|
171
|
+
"""Test terminal ready state when no active commands"""
|
|
172
|
+
mock_terminal.shell_prompt_detected = True
|
|
173
|
+
mock_terminal.active_commands = {}
|
|
174
|
+
|
|
175
|
+
assert mock_terminal.is_ready_for_commands()
|
|
176
|
+
|
|
177
|
+
def test_is_ready_for_commands_false_no_prompt(self, mock_terminal):
|
|
178
|
+
"""Test terminal not ready when no prompt detected"""
|
|
179
|
+
mock_terminal.shell_prompt_detected = False
|
|
180
|
+
mock_terminal.active_commands = {}
|
|
181
|
+
|
|
182
|
+
assert not mock_terminal.is_ready_for_commands()
|
|
183
|
+
|
|
184
|
+
def test_is_ready_for_commands_false_active_commands(self, mock_terminal):
|
|
185
|
+
"""Test terminal not ready when commands are active"""
|
|
186
|
+
mock_terminal.shell_prompt_detected = True
|
|
187
|
+
mock_terminal.active_commands = {
|
|
188
|
+
"cmd1": {
|
|
189
|
+
"command": "running_cmd",
|
|
190
|
+
"status": "running",
|
|
191
|
+
"start_time": time.time(),
|
|
192
|
+
"child_pids": set(),
|
|
193
|
+
"return_code": None,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
assert not mock_terminal.is_ready_for_commands()
|
|
198
|
+
|
|
199
|
+
def test_check_for_shell_prompt_detected(self, mock_terminal):
|
|
200
|
+
"""Test shell prompt detection"""
|
|
201
|
+
# Mock read_output to return output with prompt
|
|
202
|
+
mock_terminal.read_output = Mock(return_value="some output\n$ ")
|
|
203
|
+
|
|
204
|
+
assert mock_terminal._check_for_shell_prompt()
|
|
205
|
+
|
|
206
|
+
def test_check_for_shell_prompt_not_detected(self, mock_terminal):
|
|
207
|
+
"""Test shell prompt not detected"""
|
|
208
|
+
# Mock read_output to return output without prompt
|
|
209
|
+
mock_terminal.read_output = Mock(return_value="some output\nstill processing...")
|
|
210
|
+
|
|
211
|
+
assert not mock_terminal._check_for_shell_prompt()
|
|
212
|
+
|
|
213
|
+
@pytest.mark.asyncio
|
|
214
|
+
@pytest.mark.skipif(SKIP_IN_CI, reason="Skipping slow test in CI environment")
|
|
215
|
+
async def test_monitor_command_completion_success(self, mock_terminal):
|
|
216
|
+
"""Test command completion monitoring"""
|
|
217
|
+
# Set up a test command
|
|
218
|
+
command_id = "test_cmd"
|
|
219
|
+
mock_terminal.active_commands[command_id] = {
|
|
220
|
+
"command": "echo test",
|
|
221
|
+
"start_time": time.time(),
|
|
222
|
+
"status": "running",
|
|
223
|
+
"child_pids": set(),
|
|
224
|
+
"return_code": None,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Mock psutil to simulate no child processes
|
|
228
|
+
with patch("psutil.Process") as mock_process_class:
|
|
229
|
+
mock_process = Mock()
|
|
230
|
+
mock_process.children.return_value = []
|
|
231
|
+
mock_process_class.return_value = mock_process
|
|
232
|
+
|
|
233
|
+
# Mock prompt detection to return True
|
|
234
|
+
mock_terminal._check_for_shell_prompt = Mock(return_value=True)
|
|
235
|
+
|
|
236
|
+
# Run the monitoring
|
|
237
|
+
await mock_terminal._monitor_command_completion(command_id)
|
|
238
|
+
|
|
239
|
+
# Check that command was marked as completed
|
|
240
|
+
assert mock_terminal.active_commands[command_id]["status"] == "completed"
|
|
241
|
+
assert mock_terminal.active_commands[command_id]["return_code"] == 0
|
|
242
|
+
assert mock_terminal.shell_prompt_detected
|
|
243
|
+
|
|
244
|
+
@pytest.mark.asyncio
|
|
245
|
+
@pytest.mark.skipif(SKIP_IN_CI, reason="Skipping slow test in CI environment")
|
|
246
|
+
async def test_monitor_command_completion_process_died(self, mock_terminal):
|
|
247
|
+
"""Test command completion monitoring when shell process dies"""
|
|
248
|
+
command_id = "test_cmd"
|
|
249
|
+
mock_terminal.active_commands[command_id] = {
|
|
250
|
+
"command": "echo test",
|
|
251
|
+
"start_time": time.time(),
|
|
252
|
+
"status": "running",
|
|
253
|
+
"child_pids": set(),
|
|
254
|
+
"return_code": None,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# Mock psutil to raise NoSuchProcess
|
|
258
|
+
with patch("psutil.Process", side_effect=psutil.NoSuchProcess(123)):
|
|
259
|
+
await mock_terminal._monitor_command_completion(command_id)
|
|
260
|
+
|
|
261
|
+
# Check that command was marked as terminated
|
|
262
|
+
assert mock_terminal.active_commands[command_id]["status"] == "terminated"
|
|
263
|
+
assert mock_terminal.active_commands[command_id]["return_code"] == -1
|
|
264
|
+
|
|
265
|
+
@pytest.mark.asyncio
|
|
266
|
+
async def test_monitor_command_completion_monitor_timeout(self, mock_terminal):
|
|
267
|
+
"""Test command monitoring timeout is not reported as completion."""
|
|
268
|
+
command_id = "test_cmd"
|
|
269
|
+
mock_terminal.active_commands[command_id] = {
|
|
270
|
+
"command": "sleep 999",
|
|
271
|
+
"start_time": time.time(),
|
|
272
|
+
"status": "running",
|
|
273
|
+
"child_pids": set(),
|
|
274
|
+
"return_code": None,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
with patch.object(AsyncPersistentTerminal, "COMMAND_MONITOR_TIMEOUT_SECONDS", 0):
|
|
278
|
+
await mock_terminal._monitor_command_completion(command_id)
|
|
279
|
+
|
|
280
|
+
command_info = mock_terminal.active_commands[command_id]
|
|
281
|
+
assert command_info["status"] == "monitor_timeout"
|
|
282
|
+
assert command_info["return_code"] is None
|
|
283
|
+
assert command_info["monitor_timeout_seconds"] == 0
|
|
284
|
+
assert command_id in mock_terminal.get_active_commands()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TestLocalTerminalManagerCommandTracking:
|
|
288
|
+
"""Tests for command tracking in LocalTerminalManager"""
|
|
289
|
+
|
|
290
|
+
@pytest.fixture
|
|
291
|
+
def terminal_manager(self, workspace_id, mock_connection_manager):
|
|
292
|
+
return LocalTerminalManager(workspace_id, "test-thread-id", mock_connection_manager)
|
|
293
|
+
|
|
294
|
+
@pytest.fixture
|
|
295
|
+
def mock_terminal(self):
|
|
296
|
+
terminal = Mock()
|
|
297
|
+
terminal.send_command_tracked = AsyncMock(return_value="terminal_1_1")
|
|
298
|
+
terminal.get_command_status = Mock(return_value={"status": "running"})
|
|
299
|
+
terminal.is_alive = AsyncMock(return_value=True)
|
|
300
|
+
terminal.is_ready_for_commands = Mock(return_value=True)
|
|
301
|
+
terminal.get_active_commands = Mock(return_value={})
|
|
302
|
+
terminal.last_command = "echo test"
|
|
303
|
+
return terminal
|
|
304
|
+
|
|
305
|
+
@pytest.mark.asyncio
|
|
306
|
+
async def test_send_command_tracked_success(self, terminal_manager, mock_terminal):
|
|
307
|
+
"""Test sending tracked command through manager"""
|
|
308
|
+
terminal_manager.terminals["terminal_1"] = mock_terminal
|
|
309
|
+
|
|
310
|
+
command_id = await terminal_manager.send_command_tracked("terminal_1", "echo test", "Test purpose")
|
|
311
|
+
|
|
312
|
+
assert command_id == "terminal_1_1"
|
|
313
|
+
mock_terminal.send_command_tracked.assert_called_once_with("echo test", "Test purpose")
|
|
314
|
+
|
|
315
|
+
@pytest.mark.asyncio
|
|
316
|
+
async def test_send_command_tracked_terminal_not_found(self, terminal_manager):
|
|
317
|
+
"""Test sending tracked command to non-existent terminal"""
|
|
318
|
+
with pytest.raises(KeyError, match="Terminal with ID invalid not found"):
|
|
319
|
+
await terminal_manager.send_command_tracked("invalid", "echo test", "Test purpose")
|
|
320
|
+
|
|
321
|
+
@pytest.mark.asyncio
|
|
322
|
+
async def test_send_input_success(self, terminal_manager, mock_terminal):
|
|
323
|
+
"""Test sending input through manager to an active command."""
|
|
324
|
+
mock_terminal.get_active_commands = Mock(return_value={"cmd_1": {"status": "running"}})
|
|
325
|
+
mock_terminal.get_command_status = Mock(return_value={"status": "running"})
|
|
326
|
+
mock_terminal.send_input = AsyncMock(return_value=True)
|
|
327
|
+
terminal_manager.terminals["terminal_1"] = mock_terminal
|
|
328
|
+
|
|
329
|
+
result = await terminal_manager.send_input("terminal_1", "Ada", submit=True, command_id="cmd_1")
|
|
330
|
+
|
|
331
|
+
assert result is True
|
|
332
|
+
mock_terminal.get_command_status.assert_called_once_with("cmd_1")
|
|
333
|
+
mock_terminal.send_input.assert_awaited_once_with("Ada", submit=True)
|
|
334
|
+
|
|
335
|
+
@pytest.mark.asyncio
|
|
336
|
+
async def test_send_input_requires_active_command(self, terminal_manager, mock_terminal):
|
|
337
|
+
"""Test sending input without an active command is rejected."""
|
|
338
|
+
mock_terminal.get_active_commands = Mock(return_value={})
|
|
339
|
+
terminal_manager.terminals["terminal_1"] = mock_terminal
|
|
340
|
+
|
|
341
|
+
with pytest.raises(ValueError, match="No active command is running"):
|
|
342
|
+
await terminal_manager.send_input("terminal_1", "Ada")
|
|
343
|
+
|
|
344
|
+
@pytest.mark.asyncio
|
|
345
|
+
async def test_send_input_requires_command_id_when_ambiguous(self, terminal_manager, mock_terminal):
|
|
346
|
+
"""Test sending input with multiple active commands requires command_id."""
|
|
347
|
+
mock_terminal.get_active_commands = Mock(
|
|
348
|
+
return_value={"cmd_1": {"status": "running"}, "cmd_2": {"status": "running"}}
|
|
349
|
+
)
|
|
350
|
+
terminal_manager.terminals["terminal_1"] = mock_terminal
|
|
351
|
+
|
|
352
|
+
with pytest.raises(ValueError, match="Multiple active commands"):
|
|
353
|
+
await terminal_manager.send_input("terminal_1", "Ada")
|
|
354
|
+
|
|
355
|
+
def test_get_command_status_success(self, terminal_manager, mock_terminal):
|
|
356
|
+
"""Test getting command status through manager"""
|
|
357
|
+
terminal_manager.terminals["terminal_1"] = mock_terminal
|
|
358
|
+
|
|
359
|
+
status = terminal_manager.get_command_status("terminal_1", "cmd_1")
|
|
360
|
+
|
|
361
|
+
assert status["status"] == "running"
|
|
362
|
+
mock_terminal.get_command_status.assert_called_once_with("cmd_1")
|
|
363
|
+
|
|
364
|
+
def test_get_command_status_terminal_not_found(self, terminal_manager):
|
|
365
|
+
"""Test getting command status from non-existent terminal"""
|
|
366
|
+
with pytest.raises(KeyError, match="Terminal with ID invalid not found"):
|
|
367
|
+
terminal_manager.get_command_status("invalid", "cmd_1")
|
|
368
|
+
|
|
369
|
+
@pytest.mark.asyncio
|
|
370
|
+
async def test_get_terminal_status_success(self, terminal_manager, mock_terminal):
|
|
371
|
+
"""Test getting terminal status through manager"""
|
|
372
|
+
terminal_manager.terminals["terminal_1"] = mock_terminal
|
|
373
|
+
|
|
374
|
+
status = await terminal_manager.get_terminal_status("terminal_1")
|
|
375
|
+
|
|
376
|
+
assert status["running"]
|
|
377
|
+
assert status["ready_for_commands"]
|
|
378
|
+
assert status["active_commands"] == {}
|
|
379
|
+
assert status["last_command"] == "echo test"
|
|
380
|
+
|
|
381
|
+
@pytest.mark.asyncio
|
|
382
|
+
async def test_get_terminal_status_terminal_not_found(self, terminal_manager):
|
|
383
|
+
"""Test getting status from non-existent terminal"""
|
|
384
|
+
with pytest.raises(KeyError, match="Terminal with ID invalid not found"):
|
|
385
|
+
await terminal_manager.get_terminal_status("invalid")
|