zwarm 3.2.0__py3-none-any.whl → 3.3.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.
@@ -1,69 +0,0 @@
1
- """
2
- Adapter registry for discovering and instantiating executor adapters.
3
-
4
- This follows the same pattern as the watcher registry, enabling:
5
- - Easy addition of new adapters without modifying orchestrator code
6
- - Runtime discovery of available adapters
7
- - Consistent instantiation across CLI and orchestrator
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from typing import Any, Type
13
-
14
- from zwarm.adapters.base import ExecutorAdapter
15
-
16
-
17
- # Global adapter registry
18
- _ADAPTERS: dict[str, Type[ExecutorAdapter]] = {}
19
-
20
-
21
- def register_adapter(name: str):
22
- """
23
- Decorator to register an adapter class.
24
-
25
- Example:
26
- @register_adapter("codex_mcp")
27
- class CodexMCPAdapter(ExecutorAdapter):
28
- ...
29
- """
30
-
31
- def decorator(cls: Type[ExecutorAdapter]) -> Type[ExecutorAdapter]:
32
- cls.name = name
33
- _ADAPTERS[name] = cls
34
- return cls
35
-
36
- return decorator
37
-
38
-
39
- def get_adapter(name: str, model: str | None = None, **kwargs: Any) -> ExecutorAdapter:
40
- """
41
- Get an adapter instance by name.
42
-
43
- Args:
44
- name: Registered adapter name (e.g., "codex_mcp", "claude_code")
45
- model: Optional model override to pass to adapter
46
- **kwargs: Additional kwargs passed to adapter constructor
47
-
48
- Returns:
49
- Instantiated adapter
50
-
51
- Raises:
52
- ValueError: If adapter not found
53
- """
54
- if name not in _ADAPTERS:
55
- available = list(_ADAPTERS.keys())
56
- raise ValueError(
57
- f"Unknown adapter: {name}. Available: {available}"
58
- )
59
- return _ADAPTERS[name](model=model, **kwargs)
60
-
61
-
62
- def list_adapters() -> list[str]:
63
- """List all registered adapter names."""
64
- return list(_ADAPTERS.keys())
65
-
66
-
67
- def adapter_exists(name: str) -> bool:
68
- """Check if an adapter is registered."""
69
- return name in _ADAPTERS
@@ -1,274 +0,0 @@
1
- """Tests for Codex MCP adapter."""
2
-
3
- import subprocess
4
- import tempfile
5
- from pathlib import Path
6
- from unittest.mock import MagicMock, patch
7
-
8
- import pytest
9
-
10
- from zwarm.adapters.codex_mcp import CodexMCPAdapter, MCPClient
11
- from zwarm.core.models import SessionMode, SessionStatus
12
-
13
-
14
- class TestMCPClient:
15
- """Tests for the MCP client."""
16
-
17
- def test_next_id_increments(self):
18
- """Test that request IDs increment properly."""
19
- client = MCPClient()
20
- assert client._next_id() == 1
21
- assert client._next_id() == 2
22
- assert client._next_id() == 3
23
-
24
-
25
- class TestCodexMCPAdapter:
26
- """Tests for the Codex MCP adapter."""
27
-
28
- @pytest.fixture
29
- def adapter(self):
30
- return CodexMCPAdapter()
31
-
32
- @pytest.mark.asyncio
33
- async def test_start_session_creates_session(self, adapter):
34
- """Test that start_session creates a proper session object."""
35
- with tempfile.TemporaryDirectory() as tmpdir:
36
- # Mock the _call_codex method (now synchronous)
37
- with patch.object(adapter, "_call_codex", return_value={
38
- "conversation_id": "conv-123",
39
- "response": "Hello! I'll help you with that.",
40
- "raw_messages": [],
41
- "usage": {},
42
- "total_usage": {},
43
- }):
44
- session = await adapter.start_session(
45
- task="Say hello",
46
- working_dir=Path(tmpdir),
47
- mode="sync",
48
- )
49
-
50
- assert session.adapter == "codex_mcp"
51
- assert session.mode == SessionMode.SYNC
52
- assert session.status == SessionStatus.ACTIVE
53
- assert session.conversation_id == "conv-123"
54
- assert len(session.messages) == 2
55
- assert session.messages[0].role == "user"
56
- assert session.messages[1].role == "assistant"
57
-
58
- @pytest.mark.asyncio
59
- async def test_send_message_continues_conversation(self, adapter):
60
- """Test that send_message continues an existing conversation."""
61
- with tempfile.TemporaryDirectory() as tmpdir:
62
- # Mock _call_codex for start_session
63
- with patch.object(adapter, "_call_codex", return_value={
64
- "conversation_id": "conv-123",
65
- "response": "Initial response",
66
- "raw_messages": [],
67
- "usage": {},
68
- "total_usage": {},
69
- }):
70
- session = await adapter.start_session(
71
- task="Start task",
72
- working_dir=Path(tmpdir),
73
- mode="sync",
74
- )
75
-
76
- # Mock _call_codex_reply for send_message
77
- with patch.object(adapter, "_call_codex_reply", return_value={
78
- "response": "Follow-up response",
79
- "raw_messages": [],
80
- "usage": {},
81
- "total_usage": {},
82
- }):
83
- response = await adapter.send_message(session, "Continue please")
84
-
85
- assert response == "Follow-up response"
86
- assert len(session.messages) == 4 # 2 from start + 2 from reply
87
-
88
- @pytest.mark.asyncio
89
- async def test_send_message_fails_on_async_session(self, adapter):
90
- """Test that send_message raises error for async sessions."""
91
- with tempfile.TemporaryDirectory() as tmpdir:
92
- # Create async session (mocked to avoid actually starting codex)
93
- with patch("subprocess.Popen") as mock_popen:
94
- mock_popen.return_value = MagicMock()
95
- session = await adapter.start_session(
96
- task="Async task",
97
- working_dir=Path(tmpdir),
98
- mode="async",
99
- )
100
-
101
- with pytest.raises(ValueError, match="Cannot send message to async session"):
102
- await adapter.send_message(session, "Should fail")
103
-
104
- @pytest.mark.asyncio
105
- async def test_check_status_async_running(self, adapter):
106
- """Test checking status of a running async session."""
107
- with tempfile.TemporaryDirectory() as tmpdir:
108
- with patch("subprocess.Popen") as mock_popen:
109
- mock_proc = MagicMock()
110
- mock_proc.poll.return_value = None # Still running
111
- mock_popen.return_value = mock_proc
112
-
113
- session = await adapter.start_session(
114
- task="Async task",
115
- working_dir=Path(tmpdir),
116
- mode="async",
117
- )
118
-
119
- status = await adapter.check_status(session)
120
- assert status["status"] == "running"
121
-
122
- @pytest.mark.asyncio
123
- async def test_check_status_async_completed(self, adapter):
124
- """Test checking status of a completed async session."""
125
- with tempfile.TemporaryDirectory() as tmpdir:
126
- with patch("subprocess.Popen") as mock_popen:
127
- mock_proc = MagicMock()
128
- mock_proc.poll.return_value = 0 # Completed
129
- mock_proc.communicate.return_value = ("Output text", "")
130
- mock_popen.return_value = mock_proc
131
-
132
- session = await adapter.start_session(
133
- task="Async task",
134
- working_dir=Path(tmpdir),
135
- mode="async",
136
- )
137
-
138
- status = await adapter.check_status(session)
139
- assert status["status"] == "completed"
140
- assert session.status == SessionStatus.COMPLETED
141
-
142
- @pytest.mark.asyncio
143
- async def test_stop_session(self, adapter):
144
- """Test stopping a session."""
145
- with tempfile.TemporaryDirectory() as tmpdir:
146
- with patch("subprocess.Popen") as mock_popen:
147
- mock_proc = MagicMock()
148
- mock_proc.poll.return_value = None # Running
149
- mock_popen.return_value = mock_proc
150
-
151
- session = await adapter.start_session(
152
- task="Async task",
153
- working_dir=Path(tmpdir),
154
- mode="async",
155
- )
156
-
157
- await adapter.stop(session)
158
-
159
- mock_proc.terminate.assert_called_once()
160
- assert session.status == SessionStatus.FAILED
161
-
162
- def test_extract_response_content_list(self, adapter):
163
- """Test response extraction from content list."""
164
- result = {"content": [{"text": "Line 1"}, {"text": "Line 2"}]}
165
- response = adapter._extract_response(result)
166
- assert response == "Line 1\nLine 2"
167
-
168
- def test_extract_response_output(self, adapter):
169
- """Test response extraction from output field."""
170
- result = {"output": "Direct output"}
171
- response = adapter._extract_response(result)
172
- assert response == "Direct output"
173
-
174
- def test_extract_response_fallback(self, adapter):
175
- """Test response extraction fallback to JSON."""
176
- result = {"unknown": "field"}
177
- response = adapter._extract_response(result)
178
- assert "unknown" in response
179
-
180
- def test_parse_jsonl_output(self, adapter):
181
- """Test parsing JSONL output from codex exec --json."""
182
- jsonl_output = """{"type":"thread.started","thread_id":"abc123"}
183
- {"type":"turn.started"}
184
- {"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Thinking..."}}
185
- {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"The answer is 4"}}
186
- {"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":10}}"""
187
-
188
- parsed = adapter._parse_jsonl_output(jsonl_output)
189
-
190
- assert parsed["response"] == "The answer is 4"
191
- assert parsed["thread_id"] == "abc123"
192
- assert parsed["usage"]["input_tokens"] == 100
193
- assert parsed["usage"]["output_tokens"] == 10
194
- assert len(parsed["events"]) == 5
195
-
196
- def test_parse_jsonl_output_multiple_messages(self, adapter):
197
- """Test parsing JSONL with multiple agent messages."""
198
- jsonl_output = """{"type":"thread.started","thread_id":"xyz"}
199
- {"type":"item.completed","item":{"type":"agent_message","text":"First part"}}
200
- {"type":"item.completed","item":{"type":"agent_message","text":"Second part"}}
201
- {"type":"turn.completed","usage":{"input_tokens":50,"output_tokens":20}}"""
202
-
203
- parsed = adapter._parse_jsonl_output(jsonl_output)
204
-
205
- assert parsed["response"] == "First part\nSecond part"
206
- assert parsed["thread_id"] == "xyz"
207
-
208
- def test_parse_jsonl_output_empty(self, adapter):
209
- """Test parsing empty JSONL output."""
210
- parsed = adapter._parse_jsonl_output("")
211
- assert parsed["response"] == ""
212
- assert parsed["usage"] == {}
213
- assert parsed["thread_id"] is None
214
-
215
- def test_parse_jsonl_output_malformed_lines(self, adapter):
216
- """Test parsing JSONL with some malformed lines."""
217
- jsonl_output = """{"type":"thread.started","thread_id":"test123"}
218
- not valid json
219
- {"type":"item.completed","item":{"type":"agent_message","text":"Valid response"}}
220
- also not json
221
- {"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"""
222
-
223
- parsed = adapter._parse_jsonl_output(jsonl_output)
224
-
225
- # Should still extract valid data
226
- assert parsed["response"] == "Valid response"
227
- assert parsed["thread_id"] == "test123"
228
- assert len(parsed["events"]) == 3 # Only valid JSON lines
229
-
230
-
231
- @pytest.mark.integration
232
- class TestCodexMCPIntegration:
233
- """
234
- Integration tests that run against real codex mcp-server.
235
-
236
- These tests are skipped by default. Run with:
237
- pytest -m integration
238
- """
239
-
240
- @pytest.fixture
241
- def adapter(self):
242
- return CodexMCPAdapter()
243
-
244
- @pytest.mark.asyncio
245
- async def test_real_sync_conversation(self, adapter):
246
- """Test a real sync conversation with codex."""
247
- with tempfile.TemporaryDirectory() as tmpdir:
248
- try:
249
- # Start a simple session
250
- session = await adapter.start_session(
251
- task="What is 2 + 2? Reply with just the number.",
252
- working_dir=Path(tmpdir),
253
- mode="sync",
254
- sandbox="read-only",
255
- )
256
-
257
- assert session.conversation_id is not None
258
- assert len(session.messages) >= 2
259
-
260
- # Continue conversation
261
- response = await adapter.send_message(
262
- session,
263
- "And what is that number times 3?"
264
- )
265
-
266
- assert response is not None
267
- assert len(session.messages) >= 4
268
-
269
- finally:
270
- await adapter.cleanup()
271
-
272
-
273
- if __name__ == "__main__":
274
- pytest.main([__file__, "-v", "-m", "not integration"])
@@ -1,68 +0,0 @@
1
- """Tests for the adapter registry."""
2
-
3
- import pytest
4
-
5
- from zwarm.adapters import (
6
- get_adapter,
7
- list_adapters,
8
- adapter_exists,
9
- ExecutorAdapter,
10
- )
11
-
12
-
13
- class TestAdapterRegistry:
14
- """Test adapter registration and retrieval."""
15
-
16
- def test_list_adapters_includes_builtins(self):
17
- """Built-in adapters are registered on import."""
18
- adapters = list_adapters()
19
- assert "codex_mcp" in adapters
20
- assert "claude_code" in adapters
21
-
22
- def test_get_adapter_codex(self):
23
- """Can retrieve codex adapter by name."""
24
- adapter = get_adapter("codex_mcp")
25
- assert isinstance(adapter, ExecutorAdapter)
26
- assert adapter.name == "codex_mcp"
27
-
28
- def test_get_adapter_claude(self):
29
- """Can retrieve claude adapter by name."""
30
- adapter = get_adapter("claude_code")
31
- assert isinstance(adapter, ExecutorAdapter)
32
- assert adapter.name == "claude_code"
33
-
34
- def test_get_adapter_with_model(self):
35
- """Model parameter is passed to adapter."""
36
- adapter = get_adapter("codex_mcp", model="custom-model")
37
- # The model should be set (adapters store it as _model)
38
- assert adapter._model == "custom-model"
39
-
40
- def test_get_unknown_adapter(self):
41
- """Unknown adapter raises ValueError."""
42
- with pytest.raises(ValueError) as exc_info:
43
- get_adapter("nonexistent_adapter")
44
- assert "Unknown adapter" in str(exc_info.value)
45
- assert "nonexistent_adapter" in str(exc_info.value)
46
-
47
- def test_adapter_exists(self):
48
- """adapter_exists returns correct boolean."""
49
- assert adapter_exists("codex_mcp") is True
50
- assert adapter_exists("claude_code") is True
51
- assert adapter_exists("nonexistent") is False
52
-
53
-
54
- class TestAdapterInstances:
55
- """Test that retrieved adapters are independent instances."""
56
-
57
- def test_separate_instances(self):
58
- """Each get_adapter call returns a new instance."""
59
- adapter1 = get_adapter("codex_mcp")
60
- adapter2 = get_adapter("codex_mcp")
61
- assert adapter1 is not adapter2
62
-
63
- def test_different_models(self):
64
- """Can create adapters with different models."""
65
- adapter1 = get_adapter("codex_mcp", model="model-a")
66
- adapter2 = get_adapter("codex_mcp", model="model-b")
67
- assert adapter1._model == "model-a"
68
- assert adapter2._model == "model-b"