agentping 0.1.0__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.
- agentping-0.1.0/.gitignore +40 -0
- agentping-0.1.0/LICENSE +21 -0
- agentping-0.1.0/PKG-INFO +77 -0
- agentping-0.1.0/README.md +52 -0
- agentping-0.1.0/pyproject.toml +44 -0
- agentping-0.1.0/src/agentping/__init__.py +3 -0
- agentping-0.1.0/src/agentping/cli.py +101 -0
- agentping-0.1.0/src/agentping/config.py +57 -0
- agentping-0.1.0/src/agentping/server.py +152 -0
- agentping-0.1.0/src/agentping/ws_client.py +203 -0
- agentping-0.1.0/tests/__init__.py +0 -0
- agentping-0.1.0/tests/conftest.py +28 -0
- agentping-0.1.0/tests/test_config.py +77 -0
- agentping-0.1.0/tests/test_server.py +176 -0
- agentping-0.1.0/tests/test_ws_client.py +116 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.eggs/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Environment files
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
.env.production
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.vscode/
|
|
22
|
+
.idea/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# Test / Coverage
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
.coverage
|
|
33
|
+
htmlcov/
|
|
34
|
+
|
|
35
|
+
# Node
|
|
36
|
+
node_modules/
|
|
37
|
+
.next/
|
|
38
|
+
|
|
39
|
+
# Claude
|
|
40
|
+
.claude/
|
agentping-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 AgentRelay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
agentping-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentping
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that bridges AI coding agents to developers via AgentPing
|
|
5
|
+
Author: Saadman Soor
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agent,ai,approval,claude,copilot,cursor,mcp,notification
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: mcp[cli]>=1.0.0
|
|
19
|
+
Requires-Dist: pydantic>=2.4.0
|
|
20
|
+
Requires-Dist: websockets>=11.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# AgentPing — MCP Server
|
|
27
|
+
|
|
28
|
+
Thin MCP server that bridges any MCP-compatible AI coding agent (Copilot, Cursor, Claude Code, Cline, Aider, Windsurf) to you via the AgentPing cloud relay.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install agentping
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
agentping setup
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Paste your API key when prompted. Get your key at [app.agentping.dev](https://app.agentping.dev).
|
|
43
|
+
|
|
44
|
+
## IDE Configuration
|
|
45
|
+
|
|
46
|
+
Add to your VS Code / Cursor MCP config (`.vscode/mcp.json` or `mcp.json`):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"agentping": {
|
|
52
|
+
"command": "agentping",
|
|
53
|
+
"env": {
|
|
54
|
+
"AGENTPING_API_KEY": "sk-ap_live_..."
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## MCP Tools
|
|
62
|
+
|
|
63
|
+
| Tool | Purpose | Blocks? |
|
|
64
|
+
|------|---------|---------|
|
|
65
|
+
| `bridge_ping` | Test connectivity | No |
|
|
66
|
+
| `bridge_send_approval` | Ask dev to approve/reject | No |
|
|
67
|
+
| `bridge_ask_question` | Free-form question | No |
|
|
68
|
+
| `bridge_wait_response` | Block until reply | Yes |
|
|
69
|
+
| `bridge_check_response` | Non-blocking poll | No |
|
|
70
|
+
| `bridge_approve_and_wait` | Send + wait combo | Yes |
|
|
71
|
+
| `bridge_send_update` | Progress notification | No |
|
|
72
|
+
| `bridge_task_complete` | Task finished notification | No |
|
|
73
|
+
| `bridge_list_pending` | List unanswered requests | No |
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# AgentPing — MCP Server
|
|
2
|
+
|
|
3
|
+
Thin MCP server that bridges any MCP-compatible AI coding agent (Copilot, Cursor, Claude Code, Cline, Aider, Windsurf) to you via the AgentPing cloud relay.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentping
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
agentping setup
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Paste your API key when prompted. Get your key at [app.agentping.dev](https://app.agentping.dev).
|
|
18
|
+
|
|
19
|
+
## IDE Configuration
|
|
20
|
+
|
|
21
|
+
Add to your VS Code / Cursor MCP config (`.vscode/mcp.json` or `mcp.json`):
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"agentping": {
|
|
27
|
+
"command": "agentping",
|
|
28
|
+
"env": {
|
|
29
|
+
"AGENTPING_API_KEY": "sk-ap_live_..."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## MCP Tools
|
|
37
|
+
|
|
38
|
+
| Tool | Purpose | Blocks? |
|
|
39
|
+
|------|---------|---------|
|
|
40
|
+
| `bridge_ping` | Test connectivity | No |
|
|
41
|
+
| `bridge_send_approval` | Ask dev to approve/reject | No |
|
|
42
|
+
| `bridge_ask_question` | Free-form question | No |
|
|
43
|
+
| `bridge_wait_response` | Block until reply | Yes |
|
|
44
|
+
| `bridge_check_response` | Non-blocking poll | No |
|
|
45
|
+
| `bridge_approve_and_wait` | Send + wait combo | Yes |
|
|
46
|
+
| `bridge_send_update` | Progress notification | No |
|
|
47
|
+
| `bridge_task_complete` | Task finished notification | No |
|
|
48
|
+
| `bridge_list_pending` | List unanswered requests | No |
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentping"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server that bridges AI coding agents to developers via AgentPing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Saadman Soor" }]
|
|
13
|
+
keywords = ["mcp", "ai", "agent", "copilot", "cursor", "claude", "notification", "approval"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"mcp[cli]>=1.0.0",
|
|
26
|
+
"websockets>=11.0",
|
|
27
|
+
"pydantic>=2.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=7.4.0",
|
|
33
|
+
"pytest-asyncio>=0.21.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
agentping = "agentping.cli:main"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/agentping"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
asyncio_mode = "auto"
|
|
44
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""CLI entry point — setup wizard and MCP server runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from agentping.config import DEFAULT_RELAY_URL, get_api_key, load_config, save_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def setup() -> None:
|
|
12
|
+
"""Interactive setup wizard."""
|
|
13
|
+
print("=" * 50)
|
|
14
|
+
print(" AgentPing Setup")
|
|
15
|
+
print("=" * 50)
|
|
16
|
+
print()
|
|
17
|
+
print("Get your API key at: https://app.agentping.dev/settings")
|
|
18
|
+
print()
|
|
19
|
+
|
|
20
|
+
existing = load_config()
|
|
21
|
+
|
|
22
|
+
# API Key
|
|
23
|
+
current_key = existing.get("api_key", "")
|
|
24
|
+
masked = f"{current_key[:8]}...{current_key[-4:]}" if len(current_key) > 12 else "(not set)"
|
|
25
|
+
api_key = input(f"API Key [{masked}]: ").strip()
|
|
26
|
+
if not api_key:
|
|
27
|
+
api_key = current_key
|
|
28
|
+
|
|
29
|
+
if not api_key:
|
|
30
|
+
print("\nError: API key is required.")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
if not api_key.startswith("sk-ap_"):
|
|
34
|
+
print("\nWarning: Key doesn't start with 'sk-ap_'. Make sure you copied the full key.")
|
|
35
|
+
|
|
36
|
+
# Agent name
|
|
37
|
+
current_name = existing.get("agent_name", "")
|
|
38
|
+
agent_name = input(f"Agent name [{current_name or 'auto-detect'}]: ").strip()
|
|
39
|
+
if not agent_name:
|
|
40
|
+
agent_name = current_name
|
|
41
|
+
|
|
42
|
+
# Relay URL
|
|
43
|
+
current_url = existing.get("relay_url", DEFAULT_RELAY_URL)
|
|
44
|
+
relay_url = input(f"Relay URL [{current_url}]: ").strip()
|
|
45
|
+
if not relay_url:
|
|
46
|
+
relay_url = current_url
|
|
47
|
+
|
|
48
|
+
# Save
|
|
49
|
+
config = {
|
|
50
|
+
"api_key": api_key,
|
|
51
|
+
"agent_name": agent_name,
|
|
52
|
+
"relay_url": relay_url,
|
|
53
|
+
}
|
|
54
|
+
save_config(config)
|
|
55
|
+
|
|
56
|
+
print()
|
|
57
|
+
print("Configuration saved to ~/.agentping/config.json")
|
|
58
|
+
print()
|
|
59
|
+
print("Next steps:")
|
|
60
|
+
print(" 1. Add AgentPing to your IDE's MCP config:")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
mcp_config = {
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"agentping": {
|
|
66
|
+
"command": "agentping",
|
|
67
|
+
"env": {
|
|
68
|
+
"AGENTPING_API_KEY": api_key,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
print(f" {json.dumps(mcp_config, indent=2)}")
|
|
74
|
+
print()
|
|
75
|
+
print(" 2. Restart your IDE / MCP host.")
|
|
76
|
+
print(" 3. The agent can now use bridge_* tools to reach you!")
|
|
77
|
+
print()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
"""CLI entry point."""
|
|
82
|
+
if len(sys.argv) > 1 and sys.argv[1] == "setup":
|
|
83
|
+
setup()
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
|
|
87
|
+
print("Usage:")
|
|
88
|
+
print(" agentping Run the MCP server (for IDE integration)")
|
|
89
|
+
print(" agentping setup Configure API key and settings")
|
|
90
|
+
print(" agentping help Show this help message")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Default: run as MCP server
|
|
94
|
+
api_key = get_api_key()
|
|
95
|
+
if not api_key:
|
|
96
|
+
print("Error: No API key configured.")
|
|
97
|
+
print("Run `agentping setup` or set AGENTPING_API_KEY env var.")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
from agentping.server import main as server_main
|
|
101
|
+
server_main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Configuration for the AgentPing MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_CONFIG_DIR = Path.home() / ".agentping"
|
|
10
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
11
|
+
|
|
12
|
+
DEFAULT_RELAY_URL = "wss://relay.agentping.dev/ws"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_api_key() -> str:
|
|
16
|
+
"""Get API key from env var or config file."""
|
|
17
|
+
key = os.environ.get("AGENTPING_API_KEY", "")
|
|
18
|
+
if key:
|
|
19
|
+
return key
|
|
20
|
+
config = load_config()
|
|
21
|
+
return config.get("api_key", "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_relay_url() -> str:
|
|
25
|
+
"""Get relay WebSocket URL from env var or config file."""
|
|
26
|
+
url = os.environ.get("AGENTPING_RELAY_URL", "")
|
|
27
|
+
if url:
|
|
28
|
+
return url
|
|
29
|
+
config = load_config()
|
|
30
|
+
return config.get("relay_url", DEFAULT_RELAY_URL)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_agent_name() -> str:
|
|
34
|
+
"""Get agent name from env var or config file."""
|
|
35
|
+
name = os.environ.get("AGENTPING_AGENT_NAME", "")
|
|
36
|
+
if name:
|
|
37
|
+
return name
|
|
38
|
+
config = load_config()
|
|
39
|
+
return config.get("agent_name", "unknown")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_config() -> dict:
|
|
43
|
+
"""Load config from ~/.agentping/config.json."""
|
|
44
|
+
if not _CONFIG_FILE.exists():
|
|
45
|
+
return {}
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(_CONFIG_FILE.read_text())
|
|
48
|
+
except (json.JSONDecodeError, OSError):
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_config(config: dict) -> None:
|
|
53
|
+
"""Save config to ~/.agentping/config.json."""
|
|
54
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
_CONFIG_FILE.write_text(json.dumps(config, indent=2))
|
|
56
|
+
# Restrict permissions — config contains API key
|
|
57
|
+
_CONFIG_FILE.chmod(0o600)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""MCP server exposing 9 bridge tools via the AgentPing relay."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
from agentping.config import get_agent_name, get_api_key, get_relay_url
|
|
10
|
+
from agentping.ws_client import RelayClient
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("agentping")
|
|
13
|
+
mcp = FastMCP("AgentPing", description="Bridge between AI coding agents and developers")
|
|
14
|
+
_client: Optional[RelayClient] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _get_client() -> RelayClient:
|
|
18
|
+
global _client
|
|
19
|
+
if _client is None:
|
|
20
|
+
api_key = get_api_key()
|
|
21
|
+
if not api_key:
|
|
22
|
+
raise RuntimeError("No API key. Run `agentping setup` or set AGENTPING_API_KEY.")
|
|
23
|
+
_client = RelayClient(get_relay_url(), api_key, get_agent_name())
|
|
24
|
+
await _client.connect()
|
|
25
|
+
return _client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _err(msg: str) -> Dict[str, Any]:
|
|
29
|
+
return {"status": "error", "error": msg}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _send(msg_type: str, title: str, **kwargs) -> Dict[str, Any]:
|
|
33
|
+
"""Helper: connect, send message, return result."""
|
|
34
|
+
try:
|
|
35
|
+
client = await _get_client()
|
|
36
|
+
if not await client.wait_connected(timeout=10.0):
|
|
37
|
+
return _err("Not connected to relay")
|
|
38
|
+
mid = await client.send_message(message_type=msg_type, title=title, **kwargs)
|
|
39
|
+
if mid:
|
|
40
|
+
return {"status": "sent", "request_id": mid} if msg_type in ("approval", "question") \
|
|
41
|
+
else {"status": "sent", "message_id": mid}
|
|
42
|
+
return _err(f"Failed to send {msg_type} (no ack)")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return _err(str(e))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
async def bridge_ping() -> Dict[str, Any]:
|
|
49
|
+
"""Test connectivity to the AgentPing relay service."""
|
|
50
|
+
try:
|
|
51
|
+
client = await _get_client()
|
|
52
|
+
if await client.wait_connected(timeout=10.0):
|
|
53
|
+
return {"status": "connected", "relay": client._relay_url,
|
|
54
|
+
"connection_id": client._connection_id}
|
|
55
|
+
return _err("Could not connect to relay within 10 seconds")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
return _err(str(e))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
async def bridge_send_approval(
|
|
62
|
+
title: str, description: str = "", options: Optional[List[str]] = None,
|
|
63
|
+
code_context: str = "", urgency: str = "medium",
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Send an approval request to the developer. Returns a request_id for polling."""
|
|
66
|
+
return await _send("approval", title, description=description or None,
|
|
67
|
+
code_context=code_context or None, options=options, urgency=urgency)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@mcp.tool()
|
|
71
|
+
async def bridge_ask_question(
|
|
72
|
+
question: str, context: str = "", options: Optional[List[str]] = None,
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""Ask the developer a free-form question. Returns a request_id for polling."""
|
|
75
|
+
return await _send("question", question, description=context or None, options=options)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
async def bridge_wait_response(request_id: str, timeout: int = 300) -> Dict[str, Any]:
|
|
80
|
+
"""Block until the developer responds to a request."""
|
|
81
|
+
try:
|
|
82
|
+
client = await _get_client()
|
|
83
|
+
result = await client.wait_response(request_id, timeout=float(timeout))
|
|
84
|
+
if result.get("has_response"):
|
|
85
|
+
return {"status": result["status"], "response": result.get("response_text"),
|
|
86
|
+
"responder": "developer"}
|
|
87
|
+
return {"status": "timeout", "response": None, "responder": None}
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return _err(str(e))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.tool()
|
|
93
|
+
async def bridge_check_response(request_id: str) -> Dict[str, Any]:
|
|
94
|
+
"""Non-blocking check if the developer has responded."""
|
|
95
|
+
try:
|
|
96
|
+
client = await _get_client()
|
|
97
|
+
result = await client.check_response(request_id)
|
|
98
|
+
if result is None:
|
|
99
|
+
return _err("Failed to check response (relay timeout)")
|
|
100
|
+
return {"has_response": result.get("has_response", False),
|
|
101
|
+
"status": result.get("status", "pending"),
|
|
102
|
+
"response": result.get("response_text")}
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return _err(str(e))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@mcp.tool()
|
|
108
|
+
async def bridge_approve_and_wait(
|
|
109
|
+
title: str, description: str = "", options: Optional[List[str]] = None,
|
|
110
|
+
code_context: str = "", urgency: str = "medium", timeout: int = 300,
|
|
111
|
+
) -> Dict[str, Any]:
|
|
112
|
+
"""Send an approval request and wait for response (combo of send + wait)."""
|
|
113
|
+
result = await bridge_send_approval(title, description, options, code_context, urgency)
|
|
114
|
+
if result.get("status") == "error":
|
|
115
|
+
return result
|
|
116
|
+
return await bridge_wait_response(result["request_id"], timeout)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@mcp.tool()
|
|
120
|
+
async def bridge_send_update(
|
|
121
|
+
title: str, details: str = "", progress_pct: Optional[int] = None, status: str = "in_progress",
|
|
122
|
+
) -> Dict[str, Any]:
|
|
123
|
+
"""Send a progress update to the developer (fire-and-forget, no response needed)."""
|
|
124
|
+
return await _send("update", title, description=details or None, progress_pct=progress_pct,
|
|
125
|
+
urgency="low", metadata={"status_label": status} if status else None)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@mcp.tool()
|
|
129
|
+
async def bridge_task_complete(
|
|
130
|
+
title: str, summary: str = "", files_changed: Optional[List[str]] = None,
|
|
131
|
+
) -> Dict[str, Any]:
|
|
132
|
+
"""Notify the developer that a task is complete (fire-and-forget)."""
|
|
133
|
+
return await _send("task_complete", title, description=summary or None,
|
|
134
|
+
files_changed=files_changed, urgency="low")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
async def bridge_list_pending() -> Dict[str, Any]:
|
|
139
|
+
"""List all unanswered requests waiting for developer response."""
|
|
140
|
+
try:
|
|
141
|
+
client = await _get_client()
|
|
142
|
+
if not await client.wait_connected(timeout=10.0):
|
|
143
|
+
return _err("Not connected to relay")
|
|
144
|
+
msgs = await client.list_pending()
|
|
145
|
+
return {"status": "ok", "pending": msgs, "count": len(msgs)}
|
|
146
|
+
except Exception as e:
|
|
147
|
+
return _err(str(e))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main() -> None:
|
|
151
|
+
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
|
|
152
|
+
mcp.run()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""WebSocket client with auto-reconnect, heartbeat, and message queuing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import websockets
|
|
13
|
+
from websockets.exceptions import ConnectionClosed
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("agentping")
|
|
16
|
+
_BASE_BACKOFF, _MAX_BACKOFF, _MAX_QUEUE = 1.0, 30.0, 100
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RelayClient:
|
|
20
|
+
"""WebSocket client for the AgentPing relay service."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, relay_url: str, api_key: str, agent_name: str = "unknown") -> None:
|
|
23
|
+
self._relay_url = relay_url
|
|
24
|
+
self._api_key = api_key
|
|
25
|
+
self._agent_name = agent_name
|
|
26
|
+
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
|
27
|
+
self._connection_id: Optional[str] = None
|
|
28
|
+
self._user_id: Optional[str] = None
|
|
29
|
+
self._connected = asyncio.Event()
|
|
30
|
+
self._closing = False
|
|
31
|
+
self._queue: List[Dict[str, Any]] = []
|
|
32
|
+
self._response_waiters: Dict[str, asyncio.Future] = {}
|
|
33
|
+
self._reconnect_attempt = 0
|
|
34
|
+
|
|
35
|
+
async def connect(self) -> None:
|
|
36
|
+
self._closing = False
|
|
37
|
+
asyncio.ensure_future(self._connection_loop())
|
|
38
|
+
|
|
39
|
+
async def close(self) -> None:
|
|
40
|
+
self._closing = True
|
|
41
|
+
self._connected.clear()
|
|
42
|
+
if self._ws:
|
|
43
|
+
try: await self._ws.close()
|
|
44
|
+
except Exception: pass
|
|
45
|
+
|
|
46
|
+
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
|
47
|
+
try:
|
|
48
|
+
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
|
|
49
|
+
return True
|
|
50
|
+
except asyncio.TimeoutError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_connected(self) -> bool:
|
|
55
|
+
return self._connected.is_set()
|
|
56
|
+
|
|
57
|
+
async def send_message(self, message_type: str, title: str, **kwargs) -> Optional[str]:
|
|
58
|
+
"""Send a message to the relay. Returns message_id on ack, or None."""
|
|
59
|
+
msg_id = str(uuid4())
|
|
60
|
+
frame = self._envelope("send_message", msg_id, payload={
|
|
61
|
+
"message_type": message_type, "title": title, **{k: v for k, v in kwargs.items()}
|
|
62
|
+
})
|
|
63
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
64
|
+
self._response_waiters[msg_id] = fut
|
|
65
|
+
await self._send_or_queue(frame)
|
|
66
|
+
try:
|
|
67
|
+
result = await asyncio.wait_for(fut, timeout=30.0)
|
|
68
|
+
return result.get("message_id")
|
|
69
|
+
except asyncio.TimeoutError:
|
|
70
|
+
self._response_waiters.pop(msg_id, None)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
async def check_response(self, message_id: str) -> Optional[Dict]:
|
|
74
|
+
"""Non-blocking poll for a response."""
|
|
75
|
+
return await self._request("check_response", message_id=message_id)
|
|
76
|
+
|
|
77
|
+
async def wait_response(self, message_id: str, timeout: float = 300.0) -> Dict:
|
|
78
|
+
"""Block until response received, polling every 5s."""
|
|
79
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
80
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
81
|
+
result = await self.check_response(message_id)
|
|
82
|
+
if result and result.get("has_response"):
|
|
83
|
+
return result
|
|
84
|
+
await asyncio.sleep(min(5.0, deadline - asyncio.get_event_loop().time()))
|
|
85
|
+
return {"has_response": False, "status": "timeout", "message_id": message_id}
|
|
86
|
+
|
|
87
|
+
async def list_pending(self) -> List[Dict]:
|
|
88
|
+
"""List all pending messages."""
|
|
89
|
+
result = await self._request("list_pending")
|
|
90
|
+
return result.get("messages", []) if result else []
|
|
91
|
+
|
|
92
|
+
# --- Internal helpers ---
|
|
93
|
+
|
|
94
|
+
async def _request(self, msg_type: str, **extra) -> Optional[Dict]:
|
|
95
|
+
msg_id = str(uuid4())
|
|
96
|
+
frame = self._envelope(msg_type, msg_id, **extra)
|
|
97
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
98
|
+
self._response_waiters[msg_id] = fut
|
|
99
|
+
await self._send_or_queue(frame)
|
|
100
|
+
try:
|
|
101
|
+
return await asyncio.wait_for(fut, timeout=15.0)
|
|
102
|
+
except asyncio.TimeoutError:
|
|
103
|
+
self._response_waiters.pop(msg_id, None)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
async def _connection_loop(self) -> None:
|
|
107
|
+
while not self._closing:
|
|
108
|
+
try:
|
|
109
|
+
headers = {"Authorization": f"Bearer {self._api_key}",
|
|
110
|
+
"X-Agent-Name": self._agent_name}
|
|
111
|
+
async with websockets.connect(
|
|
112
|
+
self._relay_url, extra_headers=headers,
|
|
113
|
+
ping_interval=None, close_timeout=5,
|
|
114
|
+
) as ws:
|
|
115
|
+
self._ws = ws
|
|
116
|
+
ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
|
117
|
+
if ack.get("type") == "connection_error":
|
|
118
|
+
logger.error("Rejected: %s (code %s)", ack.get("error"), ack.get("code"))
|
|
119
|
+
if ack.get("code") in (4001, 4002, 4004):
|
|
120
|
+
self._closing = True
|
|
121
|
+
return
|
|
122
|
+
break
|
|
123
|
+
if ack.get("type") == "connection_ack":
|
|
124
|
+
self._connection_id = ack.get("connection_id")
|
|
125
|
+
self._user_id = ack.get("user_id")
|
|
126
|
+
self._reconnect_attempt = 0
|
|
127
|
+
self._connected.set()
|
|
128
|
+
logger.info("Connected (id=%s, plan=%s, remaining=%s)",
|
|
129
|
+
self._connection_id, ack.get("plan"),
|
|
130
|
+
ack.get("messages_remaining"))
|
|
131
|
+
await self._flush_queue()
|
|
132
|
+
await self._receive_loop(ws)
|
|
133
|
+
except (ConnectionClosed, OSError, asyncio.TimeoutError) as e:
|
|
134
|
+
logger.warning("Connection lost: %s", e)
|
|
135
|
+
except Exception:
|
|
136
|
+
logger.exception("Connection loop error")
|
|
137
|
+
self._connected.clear()
|
|
138
|
+
self._ws = None
|
|
139
|
+
if self._closing:
|
|
140
|
+
break
|
|
141
|
+
backoff = min(_BASE_BACKOFF * (2 ** self._reconnect_attempt), _MAX_BACKOFF)
|
|
142
|
+
jitter = backoff * (0.5 + random.random() * 0.5)
|
|
143
|
+
self._reconnect_attempt += 1
|
|
144
|
+
logger.info("Reconnecting in %.1fs (attempt %d)", jitter, self._reconnect_attempt)
|
|
145
|
+
await asyncio.sleep(jitter)
|
|
146
|
+
|
|
147
|
+
async def _receive_loop(self, ws) -> None:
|
|
148
|
+
async for raw in ws:
|
|
149
|
+
try: data = json.loads(raw)
|
|
150
|
+
except json.JSONDecodeError: continue
|
|
151
|
+
t = data.get("type")
|
|
152
|
+
if t == "ping":
|
|
153
|
+
await ws.send(json.dumps(self._envelope("pong", str(uuid4()))))
|
|
154
|
+
elif t == "message_ack":
|
|
155
|
+
self._resolve(data.get("ref_id"), data)
|
|
156
|
+
elif t in ("check_response_result", "pending_list"):
|
|
157
|
+
self._resolve_any(data)
|
|
158
|
+
elif t == "response_received":
|
|
159
|
+
logger.info("Response for %s: %s", data.get("message_id"), data.get("status"))
|
|
160
|
+
elif t == "error":
|
|
161
|
+
self._resolve(data.get("ref_id"), {"error": data.get("error")})
|
|
162
|
+
logger.warning("Relay error: %s", data.get("error"))
|
|
163
|
+
|
|
164
|
+
def _resolve(self, key: Optional[str], data: Dict) -> None:
|
|
165
|
+
if key:
|
|
166
|
+
fut = self._response_waiters.pop(key, None)
|
|
167
|
+
if fut and not fut.done():
|
|
168
|
+
fut.set_result(data)
|
|
169
|
+
|
|
170
|
+
def _resolve_any(self, data: Dict) -> None:
|
|
171
|
+
"""Resolve the oldest pending waiter."""
|
|
172
|
+
self._resolve(data.get("id"), data)
|
|
173
|
+
if not any(True for _ in []): # id didn't match, try FIFO
|
|
174
|
+
for key in list(self._response_waiters):
|
|
175
|
+
fut = self._response_waiters.get(key)
|
|
176
|
+
if fut and not fut.done():
|
|
177
|
+
self._response_waiters.pop(key)
|
|
178
|
+
fut.set_result(data)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
async def _send_or_queue(self, frame: Dict) -> None:
|
|
182
|
+
if self._connected.is_set() and self._ws:
|
|
183
|
+
try:
|
|
184
|
+
await self._ws.send(json.dumps(frame))
|
|
185
|
+
return
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
if len(self._queue) >= _MAX_QUEUE:
|
|
189
|
+
self._queue.pop(0)
|
|
190
|
+
self._queue.append(frame)
|
|
191
|
+
|
|
192
|
+
async def _flush_queue(self) -> None:
|
|
193
|
+
while self._queue and self._ws:
|
|
194
|
+
frame = self._queue.pop(0)
|
|
195
|
+
try: await self._ws.send(json.dumps(frame))
|
|
196
|
+
except Exception:
|
|
197
|
+
self._queue.insert(0, frame)
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _envelope(msg_type: str, msg_id: str, **kwargs) -> Dict:
|
|
202
|
+
return {"type": msg_type, "id": msg_id,
|
|
203
|
+
"timestamp": datetime.now(timezone.utc).isoformat(), **kwargs}
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Test fixtures and mcp module mock for Python <3.10 environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
# Mock the mcp module if not installed (Python <3.10)
|
|
9
|
+
if "mcp" not in sys.modules:
|
|
10
|
+
mcp_mock = MagicMock()
|
|
11
|
+
|
|
12
|
+
# Mock FastMCP so @mcp.tool() decorators pass through
|
|
13
|
+
class FakeFastMCP:
|
|
14
|
+
def __init__(self, *args, **kwargs):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
def tool(self, *args, **kwargs):
|
|
18
|
+
def decorator(fn):
|
|
19
|
+
return fn
|
|
20
|
+
return decorator
|
|
21
|
+
|
|
22
|
+
def run(self):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
mcp_mock.server.fastmcp.FastMCP = FakeFastMCP
|
|
26
|
+
sys.modules["mcp"] = mcp_mock
|
|
27
|
+
sys.modules["mcp.server"] = mcp_mock.server
|
|
28
|
+
sys.modules["mcp.server.fastmcp"] = mcp_mock.server.fastmcp
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for config loading and saving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from agentping.config import (
|
|
12
|
+
DEFAULT_RELAY_URL,
|
|
13
|
+
get_agent_name,
|
|
14
|
+
get_api_key,
|
|
15
|
+
get_relay_url,
|
|
16
|
+
load_config,
|
|
17
|
+
save_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestConfig:
|
|
22
|
+
def test_get_api_key_from_env(self):
|
|
23
|
+
with patch.dict(os.environ, {"AGENTPING_API_KEY": "sk-ap_live_test123"}):
|
|
24
|
+
assert get_api_key() == "sk-ap_live_test123"
|
|
25
|
+
|
|
26
|
+
def test_get_relay_url_from_env(self):
|
|
27
|
+
with patch.dict(os.environ, {"AGENTPING_RELAY_URL": "wss://custom.relay.dev/ws"}):
|
|
28
|
+
assert get_relay_url() == "wss://custom.relay.dev/ws"
|
|
29
|
+
|
|
30
|
+
def test_get_relay_url_default(self):
|
|
31
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
32
|
+
with patch("agentping.config.load_config", return_value={}):
|
|
33
|
+
assert get_relay_url() == DEFAULT_RELAY_URL
|
|
34
|
+
|
|
35
|
+
def test_get_agent_name_from_env(self):
|
|
36
|
+
with patch.dict(os.environ, {"AGENTPING_AGENT_NAME": "claude-code"}):
|
|
37
|
+
assert get_agent_name() == "claude-code"
|
|
38
|
+
|
|
39
|
+
def test_get_agent_name_default(self):
|
|
40
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
41
|
+
with patch("agentping.config.load_config", return_value={}):
|
|
42
|
+
assert get_agent_name() == "unknown"
|
|
43
|
+
|
|
44
|
+
def test_save_and_load_config(self, tmp_path):
|
|
45
|
+
config_file = tmp_path / "config.json"
|
|
46
|
+
config_dir = tmp_path
|
|
47
|
+
|
|
48
|
+
with patch("agentping.config._CONFIG_DIR", config_dir), \
|
|
49
|
+
patch("agentping.config._CONFIG_FILE", config_file):
|
|
50
|
+
save_config({"api_key": "sk-ap_test", "relay_url": "wss://test/ws"})
|
|
51
|
+
|
|
52
|
+
loaded = load_config()
|
|
53
|
+
assert loaded["api_key"] == "sk-ap_test"
|
|
54
|
+
assert loaded["relay_url"] == "wss://test/ws"
|
|
55
|
+
|
|
56
|
+
# Check file permissions (0o600)
|
|
57
|
+
mode = config_file.stat().st_mode & 0o777
|
|
58
|
+
assert mode == 0o600
|
|
59
|
+
|
|
60
|
+
def test_load_config_missing_file(self, tmp_path):
|
|
61
|
+
config_file = tmp_path / "nonexistent.json"
|
|
62
|
+
with patch("agentping.config._CONFIG_FILE", config_file):
|
|
63
|
+
assert load_config() == {}
|
|
64
|
+
|
|
65
|
+
def test_load_config_invalid_json(self, tmp_path):
|
|
66
|
+
config_file = tmp_path / "config.json"
|
|
67
|
+
config_file.write_text("not json")
|
|
68
|
+
with patch("agentping.config._CONFIG_FILE", config_file):
|
|
69
|
+
assert load_config() == {}
|
|
70
|
+
|
|
71
|
+
def test_env_overrides_config_file(self, tmp_path):
|
|
72
|
+
config_file = tmp_path / "config.json"
|
|
73
|
+
config_file.write_text(json.dumps({"api_key": "from-file"}))
|
|
74
|
+
|
|
75
|
+
with patch("agentping.config._CONFIG_FILE", config_file), \
|
|
76
|
+
patch.dict(os.environ, {"AGENTPING_API_KEY": "from-env"}):
|
|
77
|
+
assert get_api_key() == "from-env"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests for MCP server tool functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import agentping.server as server_module
|
|
10
|
+
from agentping.server import (
|
|
11
|
+
bridge_ask_question,
|
|
12
|
+
bridge_check_response,
|
|
13
|
+
bridge_list_pending,
|
|
14
|
+
bridge_ping,
|
|
15
|
+
bridge_send_approval,
|
|
16
|
+
bridge_send_update,
|
|
17
|
+
bridge_task_complete,
|
|
18
|
+
bridge_wait_response,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(autouse=True)
|
|
23
|
+
def reset_client():
|
|
24
|
+
"""Reset the global client before each test."""
|
|
25
|
+
server_module._client = None
|
|
26
|
+
yield
|
|
27
|
+
server_module._client = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def mock_client():
|
|
32
|
+
"""Create a mock RelayClient."""
|
|
33
|
+
client = AsyncMock()
|
|
34
|
+
client._relay_url = "wss://test/ws"
|
|
35
|
+
client._connection_id = "conn-123"
|
|
36
|
+
client.is_connected = True
|
|
37
|
+
client.wait_connected = AsyncMock(return_value=True)
|
|
38
|
+
return client
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestBridgePing:
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_ping_connected(self, mock_client):
|
|
44
|
+
with patch.object(server_module, "_client", mock_client):
|
|
45
|
+
result = await bridge_ping()
|
|
46
|
+
assert result["status"] == "connected"
|
|
47
|
+
assert result["connection_id"] == "conn-123"
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_ping_not_connected(self, mock_client):
|
|
51
|
+
mock_client.wait_connected = AsyncMock(return_value=False)
|
|
52
|
+
with patch.object(server_module, "_client", mock_client):
|
|
53
|
+
result = await bridge_ping()
|
|
54
|
+
assert result["status"] == "error"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestBridgeSendApproval:
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
async def test_send_approval_success(self, mock_client):
|
|
60
|
+
mock_client.send_message = AsyncMock(return_value="msg-456")
|
|
61
|
+
with patch.object(server_module, "_client", mock_client):
|
|
62
|
+
result = await bridge_send_approval(
|
|
63
|
+
title="Refactor auth module",
|
|
64
|
+
description="Extract into service",
|
|
65
|
+
urgency="high",
|
|
66
|
+
)
|
|
67
|
+
assert result["status"] == "sent"
|
|
68
|
+
assert result["request_id"] == "msg-456"
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_send_approval_no_ack(self, mock_client):
|
|
72
|
+
mock_client.send_message = AsyncMock(return_value=None)
|
|
73
|
+
with patch.object(server_module, "_client", mock_client):
|
|
74
|
+
result = await bridge_send_approval(title="Test")
|
|
75
|
+
assert result["status"] == "error"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestBridgeAskQuestion:
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_ask_question_success(self, mock_client):
|
|
81
|
+
mock_client.send_message = AsyncMock(return_value="msg-789")
|
|
82
|
+
with patch.object(server_module, "_client", mock_client):
|
|
83
|
+
result = await bridge_ask_question(
|
|
84
|
+
question="Which DB driver?",
|
|
85
|
+
options=["psycopg2", "asyncpg"],
|
|
86
|
+
)
|
|
87
|
+
assert result["status"] == "sent"
|
|
88
|
+
assert result["request_id"] == "msg-789"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestBridgeWaitResponse:
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_wait_response_approved(self, mock_client):
|
|
94
|
+
mock_client.wait_response = AsyncMock(return_value={
|
|
95
|
+
"has_response": True,
|
|
96
|
+
"status": "approved",
|
|
97
|
+
"response_text": None,
|
|
98
|
+
})
|
|
99
|
+
with patch.object(server_module, "_client", mock_client):
|
|
100
|
+
result = await bridge_wait_response(request_id="msg-456", timeout=10)
|
|
101
|
+
assert result["status"] == "approved"
|
|
102
|
+
assert result["responder"] == "developer"
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_wait_response_timeout(self, mock_client):
|
|
106
|
+
mock_client.wait_response = AsyncMock(return_value={
|
|
107
|
+
"has_response": False,
|
|
108
|
+
"status": "timeout",
|
|
109
|
+
"message_id": "msg-456",
|
|
110
|
+
})
|
|
111
|
+
with patch.object(server_module, "_client", mock_client):
|
|
112
|
+
result = await bridge_wait_response(request_id="msg-456", timeout=1)
|
|
113
|
+
assert result["status"] == "timeout"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestBridgeCheckResponse:
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_check_response_pending(self, mock_client):
|
|
119
|
+
mock_client.check_response = AsyncMock(return_value={
|
|
120
|
+
"has_response": False,
|
|
121
|
+
"status": "pending",
|
|
122
|
+
})
|
|
123
|
+
with patch.object(server_module, "_client", mock_client):
|
|
124
|
+
result = await bridge_check_response(request_id="msg-456")
|
|
125
|
+
assert result["has_response"] is False
|
|
126
|
+
assert result["status"] == "pending"
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
async def test_check_response_replied(self, mock_client):
|
|
130
|
+
mock_client.check_response = AsyncMock(return_value={
|
|
131
|
+
"has_response": True,
|
|
132
|
+
"status": "replied",
|
|
133
|
+
"response_text": "Use asyncpg",
|
|
134
|
+
})
|
|
135
|
+
with patch.object(server_module, "_client", mock_client):
|
|
136
|
+
result = await bridge_check_response(request_id="msg-456")
|
|
137
|
+
assert result["has_response"] is True
|
|
138
|
+
assert result["response"] == "Use asyncpg"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestBridgeSendUpdate:
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_send_update(self, mock_client):
|
|
144
|
+
mock_client.send_message = AsyncMock(return_value="msg-upd")
|
|
145
|
+
with patch.object(server_module, "_client", mock_client):
|
|
146
|
+
result = await bridge_send_update(
|
|
147
|
+
title="Running tests",
|
|
148
|
+
progress_pct=65,
|
|
149
|
+
)
|
|
150
|
+
assert result["status"] == "sent"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestBridgeTaskComplete:
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_task_complete(self, mock_client):
|
|
156
|
+
mock_client.send_message = AsyncMock(return_value="msg-done")
|
|
157
|
+
with patch.object(server_module, "_client", mock_client):
|
|
158
|
+
result = await bridge_task_complete(
|
|
159
|
+
title="Auth refactoring complete",
|
|
160
|
+
files_changed=["auth.py", "tests/test_auth.py"],
|
|
161
|
+
)
|
|
162
|
+
assert result["status"] == "sent"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestBridgeListPending:
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_list_pending(self, mock_client):
|
|
168
|
+
mock_client.list_pending = AsyncMock(return_value=[
|
|
169
|
+
{"message_id": "1", "type": "approval", "title": "Approve refactor"},
|
|
170
|
+
{"message_id": "2", "type": "question", "title": "Which driver?"},
|
|
171
|
+
])
|
|
172
|
+
with patch.object(server_module, "_client", mock_client):
|
|
173
|
+
result = await bridge_list_pending()
|
|
174
|
+
assert result["status"] == "ok"
|
|
175
|
+
assert result["count"] == 2
|
|
176
|
+
assert len(result["pending"]) == 2
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Tests for WebSocket client — message construction, queue, and state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from agentping.ws_client import RelayClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestRelayClientState:
|
|
15
|
+
def test_initial_state(self):
|
|
16
|
+
client = RelayClient("wss://test/ws", "sk-ap_test", "test-agent")
|
|
17
|
+
assert not client.is_connected
|
|
18
|
+
assert client._queue == []
|
|
19
|
+
assert client._reconnect_attempt == 0
|
|
20
|
+
|
|
21
|
+
def test_envelope(self):
|
|
22
|
+
env = RelayClient._envelope("send_message", "msg-1", payload={"title": "test"})
|
|
23
|
+
assert env["type"] == "send_message"
|
|
24
|
+
assert env["id"] == "msg-1"
|
|
25
|
+
assert "timestamp" in env
|
|
26
|
+
assert env["payload"]["title"] == "test"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestMessageQueue:
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_queue_when_disconnected(self):
|
|
32
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
33
|
+
# Not connected, so message should be queued
|
|
34
|
+
frame = {"type": "send_message", "id": "1"}
|
|
35
|
+
await client._send_or_queue(frame)
|
|
36
|
+
assert len(client._queue) == 1
|
|
37
|
+
assert client._queue[0] == frame
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_queue_max_size(self):
|
|
41
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
42
|
+
for i in range(105):
|
|
43
|
+
await client._send_or_queue({"type": "send_message", "id": str(i)})
|
|
44
|
+
# Should cap at 100, dropping oldest
|
|
45
|
+
assert len(client._queue) == 100
|
|
46
|
+
assert client._queue[0]["id"] == "5" # first 5 dropped
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_send_when_connected(self):
|
|
50
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
51
|
+
mock_ws = AsyncMock()
|
|
52
|
+
client._ws = mock_ws
|
|
53
|
+
client._connected.set()
|
|
54
|
+
|
|
55
|
+
frame = {"type": "send_message", "id": "1"}
|
|
56
|
+
await client._send_or_queue(frame)
|
|
57
|
+
|
|
58
|
+
mock_ws.send.assert_called_once_with(json.dumps(frame))
|
|
59
|
+
assert len(client._queue) == 0
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_queue_on_send_failure(self):
|
|
63
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
64
|
+
mock_ws = AsyncMock()
|
|
65
|
+
mock_ws.send.side_effect = Exception("connection lost")
|
|
66
|
+
client._ws = mock_ws
|
|
67
|
+
client._connected.set()
|
|
68
|
+
|
|
69
|
+
frame = {"type": "send_message", "id": "1"}
|
|
70
|
+
await client._send_or_queue(frame)
|
|
71
|
+
|
|
72
|
+
# Should fall back to queue
|
|
73
|
+
assert len(client._queue) == 1
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_flush_queue(self):
|
|
77
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
78
|
+
client._queue = [
|
|
79
|
+
{"type": "send_message", "id": "1"},
|
|
80
|
+
{"type": "send_message", "id": "2"},
|
|
81
|
+
]
|
|
82
|
+
mock_ws = AsyncMock()
|
|
83
|
+
client._ws = mock_ws
|
|
84
|
+
|
|
85
|
+
await client._flush_queue()
|
|
86
|
+
|
|
87
|
+
assert len(client._queue) == 0
|
|
88
|
+
assert mock_ws.send.call_count == 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestCloseAndCleanup:
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_close(self):
|
|
94
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
95
|
+
client._connected.set()
|
|
96
|
+
mock_ws = AsyncMock()
|
|
97
|
+
client._ws = mock_ws
|
|
98
|
+
|
|
99
|
+
await client.close()
|
|
100
|
+
|
|
101
|
+
assert client._closing is True
|
|
102
|
+
assert not client.is_connected
|
|
103
|
+
mock_ws.close.assert_called_once()
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_wait_connected_timeout(self):
|
|
107
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
108
|
+
result = await client.wait_connected(timeout=0.1)
|
|
109
|
+
assert result is False
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_wait_connected_already_connected(self):
|
|
113
|
+
client = RelayClient("wss://test/ws", "sk-ap_test")
|
|
114
|
+
client._connected.set()
|
|
115
|
+
result = await client.wait_connected(timeout=1.0)
|
|
116
|
+
assert result is True
|