kolega-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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")