zwarm 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.
- zwarm/__init__.py +38 -0
- zwarm/adapters/__init__.py +0 -0
- zwarm/adapters/base.py +109 -0
- zwarm/adapters/claude_code.py +303 -0
- zwarm/adapters/codex_mcp.py +428 -0
- zwarm/adapters/test_codex_mcp.py +224 -0
- zwarm/cli/__init__.py +0 -0
- zwarm/cli/main.py +534 -0
- zwarm/core/__init__.py +0 -0
- zwarm/core/config.py +271 -0
- zwarm/core/environment.py +83 -0
- zwarm/core/models.py +299 -0
- zwarm/core/state.py +224 -0
- zwarm/core/test_config.py +160 -0
- zwarm/core/test_models.py +265 -0
- zwarm/orchestrator.py +405 -0
- zwarm/prompts/__init__.py +10 -0
- zwarm/prompts/orchestrator.py +214 -0
- zwarm/tools/__init__.py +17 -0
- zwarm/tools/delegation.py +357 -0
- zwarm/watchers/__init__.py +26 -0
- zwarm/watchers/base.py +131 -0
- zwarm/watchers/builtin.py +256 -0
- zwarm/watchers/manager.py +143 -0
- zwarm/watchers/registry.py +57 -0
- zwarm/watchers/test_watchers.py +195 -0
- zwarm-0.1.0.dist-info/METADATA +382 -0
- zwarm-0.1.0.dist-info/RECORD +30 -0
- zwarm-0.1.0.dist-info/WHEEL +4 -0
- zwarm-0.1.0.dist-info/entry_points.txt +2 -0
zwarm/core/state.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flat-file state management for zwarm.
|
|
3
|
+
|
|
4
|
+
State structure:
|
|
5
|
+
.zwarm/
|
|
6
|
+
├── state.json # Current state (sessions, tasks)
|
|
7
|
+
├── events.jsonl # Append-only event log
|
|
8
|
+
├── sessions/
|
|
9
|
+
│ └── <session-id>/
|
|
10
|
+
│ ├── messages.json # Full conversation history
|
|
11
|
+
│ └── output.log # Agent stdout/stderr
|
|
12
|
+
└── orchestrator/
|
|
13
|
+
└── messages.json # Orchestrator's message history (for resume)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .models import ConversationSession, Event, Task
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _json_serializer(obj: Any) -> Any:
|
|
27
|
+
"""Custom JSON serializer for non-standard types."""
|
|
28
|
+
# Handle pydantic models
|
|
29
|
+
if hasattr(obj, "model_dump"):
|
|
30
|
+
return obj.model_dump()
|
|
31
|
+
# Handle objects with __dict__
|
|
32
|
+
if hasattr(obj, "__dict__"):
|
|
33
|
+
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
|
34
|
+
# Handle datetime
|
|
35
|
+
if hasattr(obj, "isoformat"):
|
|
36
|
+
return obj.isoformat()
|
|
37
|
+
# Fallback to string representation
|
|
38
|
+
return str(obj)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StateManager:
|
|
42
|
+
"""
|
|
43
|
+
Manages flat-file state for zwarm.
|
|
44
|
+
|
|
45
|
+
All state is stored as JSON files in a directory (default: .zwarm/).
|
|
46
|
+
This enables:
|
|
47
|
+
- Git-backed history
|
|
48
|
+
- Easy debugging (just read the files)
|
|
49
|
+
- Resume from previous state
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, state_dir: Path | str = ".zwarm"):
|
|
53
|
+
self.state_dir = Path(state_dir)
|
|
54
|
+
self._sessions: dict[str, ConversationSession] = {}
|
|
55
|
+
self._tasks: dict[str, Task] = {}
|
|
56
|
+
self._orchestrator_messages: list[dict[str, Any]] = []
|
|
57
|
+
|
|
58
|
+
def init(self) -> None:
|
|
59
|
+
"""Initialize state directory structure."""
|
|
60
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
(self.state_dir / "sessions").mkdir(exist_ok=True)
|
|
62
|
+
(self.state_dir / "orchestrator").mkdir(exist_ok=True)
|
|
63
|
+
|
|
64
|
+
# Touch events.jsonl
|
|
65
|
+
events_file = self.state_dir / "events.jsonl"
|
|
66
|
+
if not events_file.exists():
|
|
67
|
+
events_file.touch()
|
|
68
|
+
|
|
69
|
+
# --- Sessions ---
|
|
70
|
+
|
|
71
|
+
def add_session(self, session: ConversationSession) -> None:
|
|
72
|
+
"""Add a session and persist it."""
|
|
73
|
+
self._sessions[session.id] = session
|
|
74
|
+
self._save_session(session)
|
|
75
|
+
self._save_state()
|
|
76
|
+
|
|
77
|
+
def get_session(self, session_id: str) -> ConversationSession | None:
|
|
78
|
+
"""Get a session by ID."""
|
|
79
|
+
return self._sessions.get(session_id)
|
|
80
|
+
|
|
81
|
+
def update_session(self, session: ConversationSession) -> None:
|
|
82
|
+
"""Update a session and persist it."""
|
|
83
|
+
self._sessions[session.id] = session
|
|
84
|
+
self._save_session(session)
|
|
85
|
+
self._save_state()
|
|
86
|
+
|
|
87
|
+
def list_sessions(self, status: str | None = None) -> list[ConversationSession]:
|
|
88
|
+
"""List sessions, optionally filtered by status."""
|
|
89
|
+
sessions = list(self._sessions.values())
|
|
90
|
+
if status:
|
|
91
|
+
sessions = [s for s in sessions if s.status.value == status]
|
|
92
|
+
return sessions
|
|
93
|
+
|
|
94
|
+
def _save_session(self, session: ConversationSession) -> None:
|
|
95
|
+
"""Save session to its own directory."""
|
|
96
|
+
session_dir = self.state_dir / "sessions" / session.id
|
|
97
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
# Save messages
|
|
100
|
+
messages_file = session_dir / "messages.json"
|
|
101
|
+
messages_file.write_text(json.dumps([m.to_dict() for m in session.messages], indent=2))
|
|
102
|
+
|
|
103
|
+
# --- Tasks ---
|
|
104
|
+
|
|
105
|
+
def add_task(self, task: Task) -> None:
|
|
106
|
+
"""Add a task and persist it."""
|
|
107
|
+
self._tasks[task.id] = task
|
|
108
|
+
self._save_state()
|
|
109
|
+
|
|
110
|
+
def get_task(self, task_id: str) -> Task | None:
|
|
111
|
+
"""Get a task by ID."""
|
|
112
|
+
return self._tasks.get(task_id)
|
|
113
|
+
|
|
114
|
+
def update_task(self, task: Task) -> None:
|
|
115
|
+
"""Update a task and persist it."""
|
|
116
|
+
self._tasks[task.id] = task
|
|
117
|
+
self._save_state()
|
|
118
|
+
|
|
119
|
+
def list_tasks(self, status: str | None = None) -> list[Task]:
|
|
120
|
+
"""List tasks, optionally filtered by status."""
|
|
121
|
+
tasks = list(self._tasks.values())
|
|
122
|
+
if status:
|
|
123
|
+
tasks = [t for t in tasks if t.status.value == status]
|
|
124
|
+
return tasks
|
|
125
|
+
|
|
126
|
+
# --- Events ---
|
|
127
|
+
|
|
128
|
+
def log_event(self, event: Event) -> None:
|
|
129
|
+
"""Append an event to the log."""
|
|
130
|
+
events_file = self.state_dir / "events.jsonl"
|
|
131
|
+
with open(events_file, "a") as f:
|
|
132
|
+
f.write(json.dumps(event.to_dict()) + "\n")
|
|
133
|
+
|
|
134
|
+
def get_events(
|
|
135
|
+
self,
|
|
136
|
+
session_id: str | None = None,
|
|
137
|
+
task_id: str | None = None,
|
|
138
|
+
kind: str | None = None,
|
|
139
|
+
limit: int | None = None,
|
|
140
|
+
) -> list[Event]:
|
|
141
|
+
"""Read events from the log, optionally filtered."""
|
|
142
|
+
events_file = self.state_dir / "events.jsonl"
|
|
143
|
+
if not events_file.exists():
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
events = []
|
|
147
|
+
with open(events_file) as f:
|
|
148
|
+
for line in f:
|
|
149
|
+
line = line.strip()
|
|
150
|
+
if not line:
|
|
151
|
+
continue
|
|
152
|
+
event = Event.from_dict(json.loads(line))
|
|
153
|
+
if session_id and event.session_id != session_id:
|
|
154
|
+
continue
|
|
155
|
+
if task_id and event.task_id != task_id:
|
|
156
|
+
continue
|
|
157
|
+
if kind and event.kind != kind:
|
|
158
|
+
continue
|
|
159
|
+
events.append(event)
|
|
160
|
+
|
|
161
|
+
# Most recent first
|
|
162
|
+
events.reverse()
|
|
163
|
+
if limit:
|
|
164
|
+
events = events[:limit]
|
|
165
|
+
return events
|
|
166
|
+
|
|
167
|
+
# --- Orchestrator State ---
|
|
168
|
+
|
|
169
|
+
def save_orchestrator_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
170
|
+
"""Save orchestrator's message history for resume."""
|
|
171
|
+
self._orchestrator_messages = messages
|
|
172
|
+
messages_file = self.state_dir / "orchestrator" / "messages.json"
|
|
173
|
+
# Use custom encoder to handle non-serializable types
|
|
174
|
+
messages_file.write_text(json.dumps(messages, indent=2, default=_json_serializer))
|
|
175
|
+
|
|
176
|
+
def load_orchestrator_messages(self) -> list[dict[str, Any]]:
|
|
177
|
+
"""Load orchestrator's message history for resume."""
|
|
178
|
+
messages_file = self.state_dir / "orchestrator" / "messages.json"
|
|
179
|
+
if not messages_file.exists():
|
|
180
|
+
return []
|
|
181
|
+
return json.loads(messages_file.read_text())
|
|
182
|
+
|
|
183
|
+
# --- State Persistence ---
|
|
184
|
+
|
|
185
|
+
def _save_state(self) -> None:
|
|
186
|
+
"""Save current state to state.json."""
|
|
187
|
+
state = {
|
|
188
|
+
"updated_at": datetime.now().isoformat(),
|
|
189
|
+
"sessions": {sid: s.to_dict() for sid, s in self._sessions.items()},
|
|
190
|
+
"tasks": {tid: t.to_dict() for tid, t in self._tasks.items()},
|
|
191
|
+
}
|
|
192
|
+
state_file = self.state_dir / "state.json"
|
|
193
|
+
state_file.write_text(json.dumps(state, indent=2))
|
|
194
|
+
|
|
195
|
+
def load(self) -> None:
|
|
196
|
+
"""Load state from state.json."""
|
|
197
|
+
state_file = self.state_dir / "state.json"
|
|
198
|
+
if not state_file.exists():
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
state = json.loads(state_file.read_text())
|
|
202
|
+
|
|
203
|
+
# Load sessions
|
|
204
|
+
for sid, sdata in state.get("sessions", {}).items():
|
|
205
|
+
self._sessions[sid] = ConversationSession.from_dict(sdata)
|
|
206
|
+
|
|
207
|
+
# Load tasks
|
|
208
|
+
for tid, tdata in state.get("tasks", {}).items():
|
|
209
|
+
self._tasks[tid] = Task.from_dict(tdata)
|
|
210
|
+
|
|
211
|
+
def clear(self) -> None:
|
|
212
|
+
"""Clear all state (for testing)."""
|
|
213
|
+
self._sessions.clear()
|
|
214
|
+
self._tasks.clear()
|
|
215
|
+
self._orchestrator_messages.clear()
|
|
216
|
+
|
|
217
|
+
# Clear files
|
|
218
|
+
state_file = self.state_dir / "state.json"
|
|
219
|
+
if state_file.exists():
|
|
220
|
+
state_file.unlink()
|
|
221
|
+
|
|
222
|
+
events_file = self.state_dir / "events.jsonl"
|
|
223
|
+
if events_file.exists():
|
|
224
|
+
events_file.write_text("")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Tests for the config system."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from zwarm.core.config import (
|
|
9
|
+
ZwarmConfig,
|
|
10
|
+
apply_overrides,
|
|
11
|
+
deep_merge,
|
|
12
|
+
load_config,
|
|
13
|
+
load_yaml_config,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_default_config():
|
|
18
|
+
"""Test default configuration values."""
|
|
19
|
+
config = ZwarmConfig()
|
|
20
|
+
assert config.executor.adapter == "codex_mcp"
|
|
21
|
+
assert config.executor.sandbox == "workspace-write"
|
|
22
|
+
assert config.orchestrator.lm == "gpt-5-mini"
|
|
23
|
+
assert config.orchestrator.sync_first is True
|
|
24
|
+
assert config.state_dir == ".zwarm"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_config_from_dict():
|
|
28
|
+
"""Test creating config from dictionary."""
|
|
29
|
+
data = {
|
|
30
|
+
"executor": {"adapter": "claude_code", "model": "claude-sonnet"},
|
|
31
|
+
"orchestrator": {"lm": "gpt-5", "max_steps": 100},
|
|
32
|
+
"state_dir": ".my_state",
|
|
33
|
+
}
|
|
34
|
+
config = ZwarmConfig.from_dict(data)
|
|
35
|
+
assert config.executor.adapter == "claude_code"
|
|
36
|
+
assert config.executor.model == "claude-sonnet"
|
|
37
|
+
assert config.orchestrator.lm == "gpt-5"
|
|
38
|
+
assert config.orchestrator.max_steps == 100
|
|
39
|
+
assert config.state_dir == ".my_state"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_config_to_dict():
|
|
43
|
+
"""Test converting config to dictionary."""
|
|
44
|
+
config = ZwarmConfig()
|
|
45
|
+
data = config.to_dict()
|
|
46
|
+
assert data["executor"]["adapter"] == "codex_mcp"
|
|
47
|
+
assert data["orchestrator"]["lm"] == "gpt-5-mini"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_deep_merge():
|
|
51
|
+
"""Test deep merging of dictionaries."""
|
|
52
|
+
base = {"a": 1, "b": {"c": 2, "d": 3}}
|
|
53
|
+
override = {"b": {"c": 99}, "e": 4}
|
|
54
|
+
result = deep_merge(base, override)
|
|
55
|
+
assert result == {"a": 1, "b": {"c": 99, "d": 3}, "e": 4}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_apply_overrides():
|
|
59
|
+
"""Test CLI override application."""
|
|
60
|
+
config = {"executor": {"adapter": "codex"}, "orchestrator": {"max_steps": 10}}
|
|
61
|
+
|
|
62
|
+
# Override nested value
|
|
63
|
+
result = apply_overrides(config, ["orchestrator.max_steps=50"])
|
|
64
|
+
assert result["orchestrator"]["max_steps"] == 50
|
|
65
|
+
|
|
66
|
+
# Override with string
|
|
67
|
+
result = apply_overrides(config, ["executor.adapter=claude_code"])
|
|
68
|
+
assert result["executor"]["adapter"] == "claude_code"
|
|
69
|
+
|
|
70
|
+
# Override with boolean
|
|
71
|
+
result = apply_overrides(config, ["orchestrator.sync_first=false"])
|
|
72
|
+
assert result["orchestrator"]["sync_first"] is False
|
|
73
|
+
|
|
74
|
+
# Create new nested path
|
|
75
|
+
result = apply_overrides(config, ["weave.project=my-project"])
|
|
76
|
+
assert result["weave"]["project"] == "my-project"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_yaml_inheritance():
|
|
80
|
+
"""Test YAML config inheritance via extends."""
|
|
81
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
82
|
+
base_path = Path(tmpdir) / "base.yaml"
|
|
83
|
+
child_path = Path(tmpdir) / "child.yaml"
|
|
84
|
+
|
|
85
|
+
# Write base config
|
|
86
|
+
base_path.write_text("""
|
|
87
|
+
executor:
|
|
88
|
+
adapter: codex_mcp
|
|
89
|
+
timeout: 3600
|
|
90
|
+
orchestrator:
|
|
91
|
+
lm: gpt-5-mini
|
|
92
|
+
max_steps: 30
|
|
93
|
+
""")
|
|
94
|
+
|
|
95
|
+
# Write child config that extends base
|
|
96
|
+
child_path.write_text("""
|
|
97
|
+
extends: base.yaml
|
|
98
|
+
orchestrator:
|
|
99
|
+
lm: gpt-5
|
|
100
|
+
prompt: prompts/aggressive.yaml
|
|
101
|
+
""")
|
|
102
|
+
|
|
103
|
+
config = load_yaml_config(child_path)
|
|
104
|
+
assert config["executor"]["adapter"] == "codex_mcp"
|
|
105
|
+
assert config["executor"]["timeout"] == 3600
|
|
106
|
+
assert config["orchestrator"]["lm"] == "gpt-5" # overridden
|
|
107
|
+
assert config["orchestrator"]["max_steps"] == 30 # inherited
|
|
108
|
+
assert config["orchestrator"]["prompt"] == "prompts/aggressive.yaml" # new
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_load_config_full_chain():
|
|
112
|
+
"""Test full config loading with precedence."""
|
|
113
|
+
import os
|
|
114
|
+
|
|
115
|
+
# Save and clear WEAVE_PROJECT to test config precedence
|
|
116
|
+
orig_weave = os.environ.pop("WEAVE_PROJECT", None)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
120
|
+
tmpdir = Path(tmpdir)
|
|
121
|
+
|
|
122
|
+
# Write config.toml
|
|
123
|
+
toml_path = tmpdir / "config.toml"
|
|
124
|
+
toml_path.write_text("""
|
|
125
|
+
[weave]
|
|
126
|
+
project = "my-weave-project"
|
|
127
|
+
|
|
128
|
+
[executor]
|
|
129
|
+
adapter = "codex_mcp"
|
|
130
|
+
""")
|
|
131
|
+
|
|
132
|
+
# Write yaml config
|
|
133
|
+
yaml_path = tmpdir / "experiment.yaml"
|
|
134
|
+
yaml_path.write_text("""
|
|
135
|
+
orchestrator:
|
|
136
|
+
lm: claude-sonnet
|
|
137
|
+
max_steps: 100
|
|
138
|
+
""")
|
|
139
|
+
|
|
140
|
+
# Load with override (use non-existent env_path to prevent loading cwd's .env)
|
|
141
|
+
config = load_config(
|
|
142
|
+
config_path=yaml_path,
|
|
143
|
+
toml_path=toml_path,
|
|
144
|
+
env_path=tmpdir / ".env.nonexistent",
|
|
145
|
+
overrides=["orchestrator.max_steps=200"],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Check precedence: override > yaml > toml > default
|
|
149
|
+
assert config.weave.project == "my-weave-project" # from toml
|
|
150
|
+
assert config.executor.adapter == "codex_mcp" # from toml
|
|
151
|
+
assert config.orchestrator.lm == "claude-sonnet" # from yaml
|
|
152
|
+
assert config.orchestrator.max_steps == 200 # from override
|
|
153
|
+
finally:
|
|
154
|
+
# Restore WEAVE_PROJECT
|
|
155
|
+
if orig_weave is not None:
|
|
156
|
+
os.environ["WEAVE_PROJECT"] = orig_weave
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Tests for core models and state management."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from zwarm.core.models import (
|
|
9
|
+
ConversationSession,
|
|
10
|
+
Event,
|
|
11
|
+
Message,
|
|
12
|
+
SessionMode,
|
|
13
|
+
SessionStatus,
|
|
14
|
+
Task,
|
|
15
|
+
TaskStatus,
|
|
16
|
+
event_session_completed,
|
|
17
|
+
event_session_started,
|
|
18
|
+
event_task_created,
|
|
19
|
+
)
|
|
20
|
+
from zwarm.core.state import StateManager
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestMessage:
|
|
24
|
+
def test_create_message(self):
|
|
25
|
+
msg = Message(role="user", content="Hello")
|
|
26
|
+
assert msg.role == "user"
|
|
27
|
+
assert msg.content == "Hello"
|
|
28
|
+
assert msg.timestamp is not None
|
|
29
|
+
|
|
30
|
+
def test_message_serialization(self):
|
|
31
|
+
msg = Message(role="assistant", content="Hi there")
|
|
32
|
+
data = msg.to_dict()
|
|
33
|
+
restored = Message.from_dict(data)
|
|
34
|
+
assert restored.role == msg.role
|
|
35
|
+
assert restored.content == msg.content
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestConversationSession:
|
|
39
|
+
def test_create_session(self):
|
|
40
|
+
session = ConversationSession(
|
|
41
|
+
adapter="codex_mcp",
|
|
42
|
+
mode=SessionMode.SYNC,
|
|
43
|
+
task_description="Test task",
|
|
44
|
+
)
|
|
45
|
+
assert session.id is not None
|
|
46
|
+
assert session.adapter == "codex_mcp"
|
|
47
|
+
assert session.mode == SessionMode.SYNC
|
|
48
|
+
assert session.status == SessionStatus.ACTIVE
|
|
49
|
+
|
|
50
|
+
def test_add_message(self):
|
|
51
|
+
session = ConversationSession()
|
|
52
|
+
session.add_message("user", "Hello")
|
|
53
|
+
session.add_message("assistant", "Hi there")
|
|
54
|
+
assert len(session.messages) == 2
|
|
55
|
+
assert session.messages[0].role == "user"
|
|
56
|
+
assert session.messages[1].role == "assistant"
|
|
57
|
+
|
|
58
|
+
def test_complete_session(self):
|
|
59
|
+
session = ConversationSession()
|
|
60
|
+
session.complete("Done!")
|
|
61
|
+
assert session.status == SessionStatus.COMPLETED
|
|
62
|
+
assert session.completed_at is not None
|
|
63
|
+
assert session.exit_message == "Done!"
|
|
64
|
+
|
|
65
|
+
def test_fail_session(self):
|
|
66
|
+
session = ConversationSession()
|
|
67
|
+
session.fail("Error occurred")
|
|
68
|
+
assert session.status == SessionStatus.FAILED
|
|
69
|
+
assert session.exit_message == "Error occurred"
|
|
70
|
+
|
|
71
|
+
def test_session_serialization(self):
|
|
72
|
+
session = ConversationSession(
|
|
73
|
+
adapter="claude_code",
|
|
74
|
+
mode=SessionMode.ASYNC,
|
|
75
|
+
task_description="Build feature",
|
|
76
|
+
model="claude-sonnet",
|
|
77
|
+
)
|
|
78
|
+
session.add_message("user", "Start")
|
|
79
|
+
session.conversation_id = "conv-123"
|
|
80
|
+
|
|
81
|
+
data = session.to_dict()
|
|
82
|
+
restored = ConversationSession.from_dict(data)
|
|
83
|
+
|
|
84
|
+
assert restored.id == session.id
|
|
85
|
+
assert restored.adapter == "claude_code"
|
|
86
|
+
assert restored.mode == SessionMode.ASYNC
|
|
87
|
+
assert restored.conversation_id == "conv-123"
|
|
88
|
+
assert len(restored.messages) == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestTask:
|
|
92
|
+
def test_create_task(self):
|
|
93
|
+
task = Task(description="Fix the bug")
|
|
94
|
+
assert task.id is not None
|
|
95
|
+
assert task.status == TaskStatus.PENDING
|
|
96
|
+
|
|
97
|
+
def test_task_lifecycle(self):
|
|
98
|
+
task = Task(description="Implement feature")
|
|
99
|
+
|
|
100
|
+
# Start
|
|
101
|
+
task.start("session-123")
|
|
102
|
+
assert task.status == TaskStatus.IN_PROGRESS
|
|
103
|
+
assert task.session_id == "session-123"
|
|
104
|
+
|
|
105
|
+
# Complete
|
|
106
|
+
task.complete("Feature implemented")
|
|
107
|
+
assert task.status == TaskStatus.COMPLETED
|
|
108
|
+
assert task.result == "Feature implemented"
|
|
109
|
+
|
|
110
|
+
def test_task_serialization(self):
|
|
111
|
+
task = Task(description="Test task", parent_task_id="parent-123")
|
|
112
|
+
task.start("session-456")
|
|
113
|
+
|
|
114
|
+
data = task.to_dict()
|
|
115
|
+
restored = Task.from_dict(data)
|
|
116
|
+
|
|
117
|
+
assert restored.id == task.id
|
|
118
|
+
assert restored.description == "Test task"
|
|
119
|
+
assert restored.parent_task_id == "parent-123"
|
|
120
|
+
assert restored.session_id == "session-456"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestEvent:
|
|
124
|
+
def test_create_event(self):
|
|
125
|
+
event = Event(
|
|
126
|
+
kind="test_event",
|
|
127
|
+
session_id="session-123",
|
|
128
|
+
payload={"key": "value"},
|
|
129
|
+
)
|
|
130
|
+
assert event.id is not None
|
|
131
|
+
assert event.kind == "test_event"
|
|
132
|
+
|
|
133
|
+
def test_event_factories(self):
|
|
134
|
+
session = ConversationSession(task_description="Test")
|
|
135
|
+
event = event_session_started(session)
|
|
136
|
+
assert event.kind == "session_started"
|
|
137
|
+
assert event.session_id == session.id
|
|
138
|
+
|
|
139
|
+
task = Task(description="Do something")
|
|
140
|
+
event = event_task_created(task)
|
|
141
|
+
assert event.kind == "task_created"
|
|
142
|
+
assert event.task_id == task.id
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestStateManager:
|
|
146
|
+
def test_init_creates_directories(self):
|
|
147
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
148
|
+
state_dir = Path(tmpdir) / ".zwarm"
|
|
149
|
+
manager = StateManager(state_dir)
|
|
150
|
+
manager.init()
|
|
151
|
+
|
|
152
|
+
assert state_dir.exists()
|
|
153
|
+
assert (state_dir / "sessions").exists()
|
|
154
|
+
assert (state_dir / "orchestrator").exists()
|
|
155
|
+
assert (state_dir / "events.jsonl").exists()
|
|
156
|
+
|
|
157
|
+
def test_session_crud(self):
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
159
|
+
manager = StateManager(Path(tmpdir) / ".zwarm")
|
|
160
|
+
manager.init()
|
|
161
|
+
|
|
162
|
+
# Add
|
|
163
|
+
session = ConversationSession(task_description="Test")
|
|
164
|
+
manager.add_session(session)
|
|
165
|
+
|
|
166
|
+
# Get
|
|
167
|
+
retrieved = manager.get_session(session.id)
|
|
168
|
+
assert retrieved is not None
|
|
169
|
+
assert retrieved.task_description == "Test"
|
|
170
|
+
|
|
171
|
+
# Update
|
|
172
|
+
session.add_message("user", "Hello")
|
|
173
|
+
manager.update_session(session)
|
|
174
|
+
|
|
175
|
+
# List
|
|
176
|
+
sessions = manager.list_sessions()
|
|
177
|
+
assert len(sessions) == 1
|
|
178
|
+
|
|
179
|
+
# Filter by status
|
|
180
|
+
active = manager.list_sessions(status="active")
|
|
181
|
+
assert len(active) == 1
|
|
182
|
+
|
|
183
|
+
def test_task_crud(self):
|
|
184
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
185
|
+
manager = StateManager(Path(tmpdir) / ".zwarm")
|
|
186
|
+
manager.init()
|
|
187
|
+
|
|
188
|
+
# Add
|
|
189
|
+
task = Task(description="Build feature")
|
|
190
|
+
manager.add_task(task)
|
|
191
|
+
|
|
192
|
+
# Get
|
|
193
|
+
retrieved = manager.get_task(task.id)
|
|
194
|
+
assert retrieved is not None
|
|
195
|
+
|
|
196
|
+
# Update
|
|
197
|
+
task.start("session-123")
|
|
198
|
+
manager.update_task(task)
|
|
199
|
+
|
|
200
|
+
# List
|
|
201
|
+
tasks = manager.list_tasks(status="in_progress")
|
|
202
|
+
assert len(tasks) == 1
|
|
203
|
+
|
|
204
|
+
def test_event_logging(self):
|
|
205
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
206
|
+
manager = StateManager(Path(tmpdir) / ".zwarm")
|
|
207
|
+
manager.init()
|
|
208
|
+
|
|
209
|
+
# Log events
|
|
210
|
+
session = ConversationSession()
|
|
211
|
+
manager.log_event(event_session_started(session))
|
|
212
|
+
manager.log_event(event_session_completed(session))
|
|
213
|
+
|
|
214
|
+
# Read events
|
|
215
|
+
events = manager.get_events()
|
|
216
|
+
assert len(events) == 2
|
|
217
|
+
assert events[0].kind == "session_completed" # Most recent first
|
|
218
|
+
|
|
219
|
+
# Filter by session
|
|
220
|
+
events = manager.get_events(session_id=session.id)
|
|
221
|
+
assert len(events) == 2
|
|
222
|
+
|
|
223
|
+
# Filter by kind
|
|
224
|
+
events = manager.get_events(kind="session_started")
|
|
225
|
+
assert len(events) == 1
|
|
226
|
+
|
|
227
|
+
def test_orchestrator_messages(self):
|
|
228
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
229
|
+
manager = StateManager(Path(tmpdir) / ".zwarm")
|
|
230
|
+
manager.init()
|
|
231
|
+
|
|
232
|
+
messages = [
|
|
233
|
+
{"role": "system", "content": "You are an orchestrator"},
|
|
234
|
+
{"role": "user", "content": "Build a feature"},
|
|
235
|
+
]
|
|
236
|
+
manager.save_orchestrator_messages(messages)
|
|
237
|
+
|
|
238
|
+
loaded = manager.load_orchestrator_messages()
|
|
239
|
+
assert len(loaded) == 2
|
|
240
|
+
assert loaded[0]["role"] == "system"
|
|
241
|
+
|
|
242
|
+
def test_state_persistence(self):
|
|
243
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
244
|
+
state_dir = Path(tmpdir) / ".zwarm"
|
|
245
|
+
|
|
246
|
+
# Create and save state
|
|
247
|
+
manager1 = StateManager(state_dir)
|
|
248
|
+
manager1.init()
|
|
249
|
+
|
|
250
|
+
session = ConversationSession(task_description="Persistent session")
|
|
251
|
+
manager1.add_session(session)
|
|
252
|
+
|
|
253
|
+
task = Task(description="Persistent task")
|
|
254
|
+
manager1.add_task(task)
|
|
255
|
+
|
|
256
|
+
# Load in new manager
|
|
257
|
+
manager2 = StateManager(state_dir)
|
|
258
|
+
manager2.load()
|
|
259
|
+
|
|
260
|
+
assert manager2.get_session(session.id) is not None
|
|
261
|
+
assert manager2.get_task(task.id) is not None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
pytest.main([__file__, "-v"])
|