agent-teams-cli 0.1.1__tar.gz

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.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-teams-cli
3
+ Version: 0.1.1
4
+ Summary: File-based agent team coordination for OpenClaw
5
+ Author: Marcus
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # agent-teams
11
+
12
+ File-based agent team coordination for [OpenClaw](https://github.com/nichochar/openclaw). Inspired by Claude Code's teams architecture.
13
+
14
+ ## Features
15
+
16
+ - **Team management** — create/delete teams, add/remove members
17
+ - **Mailbox IPC** — file-based inboxes with cursor-based polling
18
+ - **Shared task list** — file-locked atomic task claims
19
+ - **XML context injection** — poll output formatted for agent prompt injection
20
+ - **Zero dependencies** — pure Python, no servers, no databases
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install -e .
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ # Create a team
32
+ agent-teams create my-team --members lead researcher builder
33
+
34
+ # Send a message
35
+ agent-teams send lead@my-team researcher@my-team --text "Find papers on X"
36
+
37
+ # Poll inbox (XML for context injection)
38
+ agent-teams poll researcher@my-team
39
+
40
+ # Create and manage tasks
41
+ agent-teams task-create my-team -s "Research task" --assign-to researcher --assign-by lead
42
+ agent-teams task-claim my-team 1 researcher
43
+ agent-teams task-complete my-team 1 researcher -r "Done!"
44
+ ```
45
+
46
+ See [SKILL.md](../../skills/agent-teams/SKILL.md) for full documentation.
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,41 @@
1
+ # agent-teams
2
+
3
+ File-based agent team coordination for [OpenClaw](https://github.com/nichochar/openclaw). Inspired by Claude Code's teams architecture.
4
+
5
+ ## Features
6
+
7
+ - **Team management** — create/delete teams, add/remove members
8
+ - **Mailbox IPC** — file-based inboxes with cursor-based polling
9
+ - **Shared task list** — file-locked atomic task claims
10
+ - **XML context injection** — poll output formatted for agent prompt injection
11
+ - **Zero dependencies** — pure Python, no servers, no databases
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install -e .
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # Create a team
23
+ agent-teams create my-team --members lead researcher builder
24
+
25
+ # Send a message
26
+ agent-teams send lead@my-team researcher@my-team --text "Find papers on X"
27
+
28
+ # Poll inbox (XML for context injection)
29
+ agent-teams poll researcher@my-team
30
+
31
+ # Create and manage tasks
32
+ agent-teams task-create my-team -s "Research task" --assign-to researcher --assign-by lead
33
+ agent-teams task-claim my-team 1 researcher
34
+ agent-teams task-complete my-team 1 researcher -r "Done!"
35
+ ```
36
+
37
+ See [SKILL.md](../../skills/agent-teams/SKILL.md) for full documentation.
38
+
39
+ ## License
40
+
41
+ MIT
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agent-teams-cli"
7
+ version = "0.1.1"
8
+ description = "File-based agent team coordination for OpenClaw"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Marcus"}]
13
+ dependencies = []
14
+
15
+ [project.scripts]
16
+ agent-teams = "agent_teams.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Agent Teams - file-based agent coordination for OpenClaw."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,137 @@
1
+ """CLI for agent-teams."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from . import core
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(prog="agent-teams", description="Agent team coordination")
11
+ parser.add_argument("--json", action="store_true", help="JSON output")
12
+ sub = parser.add_subparsers(dest="command")
13
+
14
+ # team create
15
+ p = sub.add_parser("create", help="Create a team")
16
+ p.add_argument("team", help="Team name")
17
+ p.add_argument("--members", nargs="*", default=[], help="Initial members")
18
+
19
+ # team delete
20
+ p = sub.add_parser("delete", help="Delete a team")
21
+ p.add_argument("team")
22
+
23
+ # team list
24
+ sub.add_parser("list", help="List teams")
25
+
26
+ # team info
27
+ p = sub.add_parser("info", help="Team info")
28
+ p.add_argument("team")
29
+
30
+ # add-member
31
+ p = sub.add_parser("add-member", help="Add member to team")
32
+ p.add_argument("team")
33
+ p.add_argument("name")
34
+
35
+ # remove-member
36
+ p = sub.add_parser("remove-member", help="Remove member from team")
37
+ p.add_argument("team")
38
+ p.add_argument("name")
39
+
40
+ # send
41
+ p = sub.add_parser("send", help="Send a message")
42
+ p.add_argument("sender", help="sender@team")
43
+ p.add_argument("recipient", help="recipient@team")
44
+ p.add_argument("--text", "-t", required=True)
45
+ p.add_argument("--type", default="message", dest="msg_type")
46
+
47
+ # broadcast
48
+ p = sub.add_parser("broadcast", help="Broadcast to team")
49
+ p.add_argument("sender", help="sender@team")
50
+ p.add_argument("--text", "-t", required=True)
51
+
52
+ # poll
53
+ p = sub.add_parser("poll", help="Poll inbox for new messages")
54
+ p.add_argument("identity", help="agent@team")
55
+ p.add_argument("--format", choices=["xml", "json"], default="xml")
56
+
57
+ # inbox
58
+ p = sub.add_parser("inbox", help="Read full inbox")
59
+ p.add_argument("identity", help="agent@team")
60
+
61
+ # task create
62
+ p = sub.add_parser("task-create", help="Create a task")
63
+ p.add_argument("team")
64
+ p.add_argument("--subject", "-s", required=True)
65
+ p.add_argument("--description", "-d", default="")
66
+ p.add_argument("--assign-to", default="")
67
+ p.add_argument("--assign-by", default="")
68
+
69
+ # task claim
70
+ p = sub.add_parser("task-claim", help="Claim a task")
71
+ p.add_argument("team")
72
+ p.add_argument("task_id")
73
+ p.add_argument("agent")
74
+
75
+ # task complete
76
+ p = sub.add_parser("task-complete", help="Complete a task")
77
+ p.add_argument("team")
78
+ p.add_argument("task_id")
79
+ p.add_argument("agent")
80
+ p.add_argument("--result", "-r", default="")
81
+
82
+ # task list
83
+ p = sub.add_parser("task-list", help="List tasks")
84
+ p.add_argument("team")
85
+ p.add_argument("--status", choices=["pending", "in_progress", "completed"])
86
+
87
+ args = parser.parse_args()
88
+ use_json = args.json
89
+
90
+ def output(data):
91
+ if use_json:
92
+ print(json.dumps(data, indent=2))
93
+ else:
94
+ print(json.dumps(data, indent=2))
95
+
96
+ try:
97
+ if args.command == "create":
98
+ output(core.create_team(args.team, args.members))
99
+ elif args.command == "delete":
100
+ ok = core.delete_team(args.team)
101
+ output({"deleted": ok})
102
+ elif args.command == "list":
103
+ output(core.list_teams())
104
+ elif args.command == "info":
105
+ output(core.team_info(args.team))
106
+ elif args.command == "add-member":
107
+ output(core.add_member(args.team, args.name))
108
+ elif args.command == "remove-member":
109
+ output(core.remove_member(args.team, args.name))
110
+ elif args.command == "send":
111
+ output(core.send_message(args.sender, args.recipient, args.text, args.msg_type))
112
+ elif args.command == "broadcast":
113
+ output(core.broadcast(args.sender, args.text))
114
+ elif args.command == "poll":
115
+ result = core.poll_inbox(args.identity, args.format)
116
+ if result:
117
+ print(result)
118
+ elif args.command == "inbox":
119
+ output(core.read_inbox(args.identity))
120
+ elif args.command == "task-create":
121
+ output(core.create_task(args.team, args.subject, args.description, args.assign_to, args.assign_by))
122
+ elif args.command == "task-claim":
123
+ output(core.claim_task(args.team, args.task_id, args.agent))
124
+ elif args.command == "task-complete":
125
+ output(core.complete_task(args.team, args.task_id, args.agent, args.result))
126
+ elif args.command == "task-list":
127
+ output(core.list_tasks(args.team, args.status))
128
+ else:
129
+ parser.print_help()
130
+ sys.exit(1)
131
+ except Exception as e:
132
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
133
+ sys.exit(1)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
@@ -0,0 +1,303 @@
1
+ """Core library for agent-teams: team management, mailbox IPC, task coordination."""
2
+
3
+ import json
4
+ import os
5
+ import fcntl
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+ from typing import Optional
11
+
12
+ TEAMS_ROOT = Path.home() / ".openclaw" / "teams"
13
+
14
+ def _now() -> str:
15
+ return datetime.now(timezone.utc).isoformat()
16
+
17
+ def _parse_identity(identity: str) -> tuple[str, str]:
18
+ """Parse 'name@team' into (name, team)."""
19
+ if "@" not in identity:
20
+ raise ValueError(f"Invalid identity '{identity}', expected name@team")
21
+ name, team = identity.split("@", 1)
22
+ return name, team
23
+
24
+ def _team_dir(team: str) -> Path:
25
+ return TEAMS_ROOT / team
26
+
27
+ def _inbox_path(team: str, agent: str) -> Path:
28
+ return _team_dir(team) / "inboxes" / f"{agent}.json"
29
+
30
+ def _tasks_dir(team: str) -> Path:
31
+ return _team_dir(team) / "tasks"
32
+
33
+ def _config_path(team: str) -> Path:
34
+ return _team_dir(team) / "config.json"
35
+
36
+ def _cursor_path(team: str, agent: str) -> Path:
37
+ return _team_dir(team) / "inboxes" / f".{agent}.cursor"
38
+
39
+ # ── Team Management ──
40
+
41
+ def create_team(team: str, members: Optional[list[str]] = None) -> dict:
42
+ d = _team_dir(team)
43
+ d.mkdir(parents=True, exist_ok=True)
44
+ (d / "inboxes").mkdir(exist_ok=True)
45
+ (d / "tasks").mkdir(exist_ok=True)
46
+ cp = _config_path(team)
47
+ if cp.exists():
48
+ # Don't overwrite existing team config
49
+ config = json.loads(cp.read_text())
50
+ # Merge in any new members
51
+ for m in (members or []):
52
+ if m not in config["members"]:
53
+ config["members"].append(m)
54
+ cp.write_text(json.dumps(config, indent=2))
55
+ else:
56
+ config = {"name": team, "members": members or [], "created": _now()}
57
+ cp.write_text(json.dumps(config, indent=2))
58
+ # Create inboxes for initial members
59
+ for m in (members or []):
60
+ _inbox_path(team, m).write_text("[]")
61
+ return config
62
+
63
+ def delete_team(team: str) -> bool:
64
+ import shutil
65
+ d = _team_dir(team)
66
+ if d.exists():
67
+ shutil.rmtree(d)
68
+ return True
69
+ return False
70
+
71
+ def add_member(team: str, name: str) -> dict:
72
+ cp = _config_path(team)
73
+ config = json.loads(cp.read_text())
74
+ if name not in config["members"]:
75
+ config["members"].append(name)
76
+ cp.write_text(json.dumps(config, indent=2))
77
+ _inbox_path(team, name).write_text("[]")
78
+ return config
79
+
80
+ def remove_member(team: str, name: str) -> dict:
81
+ cp = _config_path(team)
82
+ config = json.loads(cp.read_text())
83
+ config["members"] = [m for m in config["members"] if m != name]
84
+ cp.write_text(json.dumps(config, indent=2))
85
+ inbox = _inbox_path(team, name)
86
+ if inbox.exists():
87
+ inbox.unlink()
88
+ cursor = _cursor_path(team, name)
89
+ if cursor.exists():
90
+ cursor.unlink()
91
+ return config
92
+
93
+ def list_teams() -> list[dict]:
94
+ if not TEAMS_ROOT.exists():
95
+ return []
96
+ teams = []
97
+ for d in sorted(TEAMS_ROOT.iterdir()):
98
+ cp = d / "config.json"
99
+ if cp.exists():
100
+ teams.append(json.loads(cp.read_text()))
101
+ return teams
102
+
103
+ def team_info(team: str) -> dict:
104
+ return json.loads(_config_path(team).read_text())
105
+
106
+ # ── Mailbox ──
107
+
108
+ def send_message(from_id: str, to_id: str, text: str, msg_type: str = "message") -> dict:
109
+ from_name, from_team = _parse_identity(from_id)
110
+ to_name, to_team = _parse_identity(to_id)
111
+ if from_team != to_team:
112
+ raise ValueError("Cross-team messaging not supported")
113
+ msg = {
114
+ "id": str(uuid.uuid4())[:8],
115
+ "from": from_name,
116
+ "to": to_name,
117
+ "type": msg_type,
118
+ "text": text,
119
+ "timestamp": _now(),
120
+ }
121
+ _append_to_inbox(to_team, to_name, msg)
122
+ return msg
123
+
124
+ def broadcast(from_id: str, text: str, msg_type: str = "broadcast") -> list[dict]:
125
+ from_name, team = _parse_identity(from_id)
126
+ config = team_info(team)
127
+ msgs = []
128
+ for member in config["members"]:
129
+ if member != from_name:
130
+ msg = {
131
+ "id": str(uuid.uuid4())[:8],
132
+ "from": from_name,
133
+ "to": member,
134
+ "type": msg_type,
135
+ "text": text,
136
+ "timestamp": _now(),
137
+ }
138
+ _append_to_inbox(team, member, msg)
139
+ msgs.append(msg)
140
+ return msgs
141
+
142
+ def _append_to_inbox(team: str, agent: str, msg: dict):
143
+ path = _inbox_path(team, agent)
144
+ path.parent.mkdir(parents=True, exist_ok=True)
145
+ # File-locked append
146
+ with open(path, "a+") as f:
147
+ fcntl.flock(f, fcntl.LOCK_EX)
148
+ try:
149
+ f.seek(0)
150
+ content = f.read().strip()
151
+ inbox = json.loads(content) if content else []
152
+ inbox.append(msg)
153
+ f.seek(0)
154
+ f.truncate()
155
+ f.write(json.dumps(inbox, indent=2))
156
+ finally:
157
+ fcntl.flock(f, fcntl.LOCK_UN)
158
+
159
+ def poll_inbox(identity: str, format: str = "xml") -> str:
160
+ """Poll inbox for new messages since last read. Returns XML or JSON."""
161
+ name, team = _parse_identity(identity)
162
+ inbox_path = _inbox_path(team, name)
163
+ cursor_path = _cursor_path(team, name)
164
+
165
+ if not inbox_path.exists():
166
+ return "" if format == "xml" else "[]"
167
+
168
+ with open(inbox_path, "r") as f:
169
+ fcntl.flock(f, fcntl.LOCK_SH)
170
+ try:
171
+ content = f.read().strip()
172
+ messages = json.loads(content) if content else []
173
+ finally:
174
+ fcntl.flock(f, fcntl.LOCK_UN)
175
+
176
+ # Read cursor (index of last read message)
177
+ cursor = 0
178
+ if cursor_path.exists():
179
+ cursor = int(cursor_path.read_text().strip())
180
+
181
+ new_msgs = messages[cursor:]
182
+ # Update cursor
183
+ cursor_path.write_text(str(len(messages)))
184
+
185
+ if not new_msgs:
186
+ return "" if format == "xml" else "[]"
187
+
188
+ if format == "json":
189
+ return json.dumps(new_msgs, indent=2)
190
+
191
+ # XML format for context injection
192
+ parts = []
193
+ for msg in new_msgs:
194
+ parts.append(
195
+ f'<teammate-message from="{msg.get("from", "unknown")}" '
196
+ f'team="{team}" type="{msg.get("type", "message")}" '
197
+ f'timestamp="{msg.get("timestamp", "")}">\n'
198
+ f'{msg.get("text", "")}\n'
199
+ f'</teammate-message>'
200
+ )
201
+ return "\n".join(parts)
202
+
203
+ def read_inbox(identity: str) -> list[dict]:
204
+ """Read all messages in inbox without advancing cursor."""
205
+ name, team = _parse_identity(identity)
206
+ path = _inbox_path(team, name)
207
+ if not path.exists():
208
+ return []
209
+ return json.loads(path.read_text() or "[]")
210
+
211
+ # ── Tasks ──
212
+
213
+ def create_task(team: str, subject: str, description: str = "", assigned_to: str = "", assigned_by: str = "") -> dict:
214
+ tasks_dir = _tasks_dir(team)
215
+ tasks_dir.mkdir(parents=True, exist_ok=True)
216
+ lock_path = tasks_dir / ".lock"
217
+
218
+ with open(lock_path, "w") as lock:
219
+ fcntl.flock(lock, fcntl.LOCK_EX)
220
+ try:
221
+ existing = [f for f in tasks_dir.glob("*.json")]
222
+ task_id = str(len(existing) + 1)
223
+ task = {
224
+ "id": task_id,
225
+ "subject": subject,
226
+ "description": description,
227
+ "status": "pending",
228
+ "assigned_to": assigned_to or None,
229
+ "assigned_by": assigned_by or None,
230
+ "created": _now(),
231
+ "claimed_at": None,
232
+ "completed_at": None,
233
+ "result": None,
234
+ }
235
+ (tasks_dir / f"{task_id}.json").write_text(json.dumps(task, indent=2))
236
+ finally:
237
+ fcntl.flock(lock, fcntl.LOCK_UN)
238
+
239
+ # If assigned, send task_assignment message
240
+ if assigned_to and assigned_by:
241
+ send_message(
242
+ f"{assigned_by}@{team}",
243
+ f"{assigned_to}@{team}",
244
+ json.dumps({"type": "task_assignment", "taskId": task_id, "subject": subject, "description": description}),
245
+ msg_type="task_assignment"
246
+ )
247
+
248
+ return task
249
+
250
+ def claim_task(team: str, task_id: str, agent: str) -> dict:
251
+ tasks_dir = _tasks_dir(team)
252
+ lock_path = tasks_dir / ".lock"
253
+ task_path = tasks_dir / f"{task_id}.json"
254
+
255
+ if not task_path.exists():
256
+ raise ValueError(f"Task {task_id} not found")
257
+
258
+ with open(lock_path, "w") as lock:
259
+ fcntl.flock(lock, fcntl.LOCK_EX)
260
+ try:
261
+ task = json.loads(task_path.read_text())
262
+ if task["status"] != "pending":
263
+ raise ValueError(f"Task {task_id} is {task['status']}, cannot claim")
264
+ task["status"] = "in_progress"
265
+ task["assigned_to"] = agent
266
+ task["claimed_at"] = _now()
267
+ task_path.write_text(json.dumps(task, indent=2))
268
+ finally:
269
+ fcntl.flock(lock, fcntl.LOCK_UN)
270
+ return task
271
+
272
+ def complete_task(team: str, task_id: str, agent: str, result: str = "") -> dict:
273
+ tasks_dir = _tasks_dir(team)
274
+ lock_path = tasks_dir / ".lock"
275
+ task_path = tasks_dir / f"{task_id}.json"
276
+
277
+ if not task_path.exists():
278
+ raise ValueError(f"Task {task_id} not found")
279
+
280
+ with open(lock_path, "w") as lock:
281
+ fcntl.flock(lock, fcntl.LOCK_EX)
282
+ try:
283
+ task = json.loads(task_path.read_text())
284
+ if task["status"] != "in_progress":
285
+ raise ValueError(f"Task {task_id} is {task['status']}, cannot complete")
286
+ task["status"] = "completed"
287
+ task["completed_at"] = _now()
288
+ task["result"] = result
289
+ task_path.write_text(json.dumps(task, indent=2))
290
+ finally:
291
+ fcntl.flock(lock, fcntl.LOCK_UN)
292
+ return task
293
+
294
+ def list_tasks(team: str, status: Optional[str] = None) -> list[dict]:
295
+ tasks_dir = _tasks_dir(team)
296
+ if not tasks_dir.exists():
297
+ return []
298
+ tasks = []
299
+ for f in sorted(tasks_dir.glob("*.json")):
300
+ task = json.loads(f.read_text())
301
+ if status is None or task["status"] == status:
302
+ tasks.append(task)
303
+ return tasks
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-teams-cli
3
+ Version: 0.1.1
4
+ Summary: File-based agent team coordination for OpenClaw
5
+ Author: Marcus
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # agent-teams
11
+
12
+ File-based agent team coordination for [OpenClaw](https://github.com/nichochar/openclaw). Inspired by Claude Code's teams architecture.
13
+
14
+ ## Features
15
+
16
+ - **Team management** — create/delete teams, add/remove members
17
+ - **Mailbox IPC** — file-based inboxes with cursor-based polling
18
+ - **Shared task list** — file-locked atomic task claims
19
+ - **XML context injection** — poll output formatted for agent prompt injection
20
+ - **Zero dependencies** — pure Python, no servers, no databases
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install -e .
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ # Create a team
32
+ agent-teams create my-team --members lead researcher builder
33
+
34
+ # Send a message
35
+ agent-teams send lead@my-team researcher@my-team --text "Find papers on X"
36
+
37
+ # Poll inbox (XML for context injection)
38
+ agent-teams poll researcher@my-team
39
+
40
+ # Create and manage tasks
41
+ agent-teams task-create my-team -s "Research task" --assign-to researcher --assign-by lead
42
+ agent-teams task-claim my-team 1 researcher
43
+ agent-teams task-complete my-team 1 researcher -r "Done!"
44
+ ```
45
+
46
+ See [SKILL.md](../../skills/agent-teams/SKILL.md) for full documentation.
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/agent_teams/__init__.py
4
+ src/agent_teams/cli.py
5
+ src/agent_teams/core.py
6
+ src/agent_teams_cli.egg-info/PKG-INFO
7
+ src/agent_teams_cli.egg-info/SOURCES.txt
8
+ src/agent_teams_cli.egg-info/dependency_links.txt
9
+ src/agent_teams_cli.egg-info/entry_points.txt
10
+ src/agent_teams_cli.egg-info/top_level.txt
11
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-teams = agent_teams.cli:main
@@ -0,0 +1,147 @@
1
+ """Tests for agent-teams core functionality."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import pytest
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ # Patch TEAMS_ROOT before importing core
11
+ _tmpdir = tempfile.mkdtemp()
12
+ import agent_teams.core as core
13
+ core.TEAMS_ROOT = Path(_tmpdir) / "teams"
14
+
15
+
16
+ def setup_function():
17
+ """Clean up between tests."""
18
+ import shutil
19
+ if core.TEAMS_ROOT.exists():
20
+ shutil.rmtree(core.TEAMS_ROOT)
21
+
22
+
23
+ def test_create_and_info():
24
+ config = core.create_team("test-team", ["alice", "bob"])
25
+ assert config["name"] == "test-team"
26
+ assert set(config["members"]) == {"alice", "bob"}
27
+ info = core.team_info("test-team")
28
+ assert info["name"] == "test-team"
29
+
30
+
31
+ def test_list_teams():
32
+ core.create_team("a-team")
33
+ core.create_team("b-team")
34
+ teams = core.list_teams()
35
+ assert len(teams) == 2
36
+
37
+
38
+ def test_add_remove_member():
39
+ core.create_team("test-team", ["alice"])
40
+ core.add_member("test-team", "bob")
41
+ info = core.team_info("test-team")
42
+ assert "bob" in info["members"]
43
+ core.remove_member("test-team", "bob")
44
+ info = core.team_info("test-team")
45
+ assert "bob" not in info["members"]
46
+
47
+
48
+ def test_delete_team():
49
+ core.create_team("doomed")
50
+ assert core.delete_team("doomed")
51
+ assert not core.delete_team("nonexistent")
52
+
53
+
54
+ def test_send_and_poll():
55
+ core.create_team("msg-team", ["alice", "bob"])
56
+ core.send_message("alice@msg-team", "bob@msg-team", "hello bob")
57
+ core.send_message("alice@msg-team", "bob@msg-team", "second msg")
58
+
59
+ xml = core.poll_inbox("bob@msg-team", "xml")
60
+ assert '<teammate-message from="alice"' in xml
61
+ assert "hello bob" in xml
62
+ assert "second msg" in xml
63
+
64
+ # Second poll should return empty (cursor advanced)
65
+ xml2 = core.poll_inbox("bob@msg-team", "xml")
66
+ assert xml2 == ""
67
+
68
+
69
+ def test_poll_json():
70
+ core.create_team("j-team", ["a", "b"])
71
+ core.send_message("a@j-team", "b@j-team", "test")
72
+ result = core.poll_inbox("b@j-team", "json")
73
+ msgs = json.loads(result)
74
+ assert len(msgs) == 1
75
+ assert msgs[0]["text"] == "test"
76
+
77
+
78
+ def test_broadcast():
79
+ core.create_team("bc-team", ["lead", "w1", "w2"])
80
+ msgs = core.broadcast("lead@bc-team", "all hands")
81
+ assert len(msgs) == 2
82
+ # Both workers got the message
83
+ assert core.poll_inbox("w1@bc-team") != ""
84
+ assert core.poll_inbox("w2@bc-team") != ""
85
+ # Lead didn't get it
86
+ assert core.poll_inbox("lead@bc-team") == ""
87
+
88
+
89
+ def test_task_lifecycle():
90
+ core.create_team("task-team", ["lead", "worker"])
91
+ task = core.create_task("task-team", "Do the thing", "details here")
92
+ assert task["status"] == "pending"
93
+ assert task["id"] == "1"
94
+
95
+ claimed = core.claim_task("task-team", "1", "worker")
96
+ assert claimed["status"] == "in_progress"
97
+
98
+ completed = core.complete_task("task-team", "1", "worker", "done!")
99
+ assert completed["status"] == "completed"
100
+ assert completed["result"] == "done!"
101
+
102
+
103
+ def test_task_double_claim():
104
+ core.create_team("race-team", ["a", "b"])
105
+ core.create_task("race-team", "contested task")
106
+ core.claim_task("race-team", "1", "a")
107
+ with pytest.raises(ValueError, match="in_progress"):
108
+ core.claim_task("race-team", "1", "b")
109
+
110
+
111
+ def test_task_list_filter():
112
+ core.create_team("filter-team", ["w"])
113
+ core.create_task("filter-team", "task 1")
114
+ core.create_task("filter-team", "task 2")
115
+ core.claim_task("filter-team", "1", "w")
116
+
117
+ all_tasks = core.list_tasks("filter-team")
118
+ assert len(all_tasks) == 2
119
+ pending = core.list_tasks("filter-team", "pending")
120
+ assert len(pending) == 1
121
+ in_prog = core.list_tasks("filter-team", "in_progress")
122
+ assert len(in_prog) == 1
123
+
124
+
125
+ def test_task_assignment_sends_message():
126
+ core.create_team("assign-team", ["lead", "worker"])
127
+ core.create_task("assign-team", "assigned task", "do it", assigned_to="worker", assigned_by="lead")
128
+ xml = core.poll_inbox("worker@assign-team")
129
+ assert "task_assignment" in xml
130
+ assert "assigned task" in xml
131
+
132
+
133
+ def test_message_types():
134
+ core.create_team("type-team", ["a", "b"])
135
+ core.send_message("a@type-team", "b@type-team", "shutting down", "shutdown_request")
136
+ xml = core.poll_inbox("b@type-team")
137
+ assert 'type="shutdown_request"' in xml
138
+
139
+
140
+ def test_read_inbox():
141
+ core.create_team("read-team", ["a", "b"])
142
+ core.send_message("a@read-team", "b@read-team", "hey")
143
+ msgs = core.read_inbox("b@read-team")
144
+ assert len(msgs) == 1
145
+ # read_inbox doesn't advance cursor
146
+ xml = core.poll_inbox("b@read-team")
147
+ assert "hey" in xml