gopherhole_openclaw_a2a 0.3.12 → 0.3.14
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.
- package/dist/index.js +63 -0
- package/dist/src/connection.d.ts +27 -0
- package/dist/src/connection.js +7 -0
- package/dist/src/connection.test.d.ts +5 -0
- package/dist/src/connection.test.js +224 -0
- package/index.ts +66 -0
- package/package.json +6 -2
- package/src/connection.test.ts +298 -0
- package/src/connection.ts +47 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +9 -0
package/dist/index.js
CHANGED
|
@@ -141,6 +141,69 @@ const plugin = {
|
|
|
141
141
|
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: `Unknown action: ${action}` }) }] };
|
|
142
142
|
},
|
|
143
143
|
});
|
|
144
|
+
// Register a tool for location-based agent discovery
|
|
145
|
+
api.registerTool?.({
|
|
146
|
+
name: 'a2a_discover_nearby',
|
|
147
|
+
description: 'Find A2A agents near a geographic location',
|
|
148
|
+
parameters: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
lat: {
|
|
152
|
+
type: 'number',
|
|
153
|
+
description: 'Latitude of search center',
|
|
154
|
+
},
|
|
155
|
+
lng: {
|
|
156
|
+
type: 'number',
|
|
157
|
+
description: 'Longitude of search center',
|
|
158
|
+
},
|
|
159
|
+
radius: {
|
|
160
|
+
type: 'number',
|
|
161
|
+
description: 'Search radius in kilometers (default: 10, max: 500)',
|
|
162
|
+
},
|
|
163
|
+
tag: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'Filter by tag (e.g., "retail", "food")',
|
|
166
|
+
},
|
|
167
|
+
category: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: 'Filter by category',
|
|
170
|
+
},
|
|
171
|
+
limit: {
|
|
172
|
+
type: 'number',
|
|
173
|
+
description: 'Maximum number of results (default: 20, max: 50)',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
required: ['lat', 'lng'],
|
|
177
|
+
},
|
|
178
|
+
execute: async (_id, params) => {
|
|
179
|
+
const lat = params.lat;
|
|
180
|
+
const lng = params.lng;
|
|
181
|
+
const radius = params.radius;
|
|
182
|
+
const tag = params.tag;
|
|
183
|
+
const category = params.category;
|
|
184
|
+
const limit = params.limit;
|
|
185
|
+
const manager = getA2AConnectionManager();
|
|
186
|
+
if (!manager) {
|
|
187
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'A2A channel not running' }) }] };
|
|
188
|
+
}
|
|
189
|
+
if (!manager.isGopherHoleConnected()) {
|
|
190
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'Not connected to GopherHole' }) }] };
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const agents = await manager.discoverNearby({ lat, lng, radius, tag, category, limit });
|
|
194
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
195
|
+
status: 'ok',
|
|
196
|
+
center: { lat, lng },
|
|
197
|
+
radius: radius || 10,
|
|
198
|
+
count: agents.length,
|
|
199
|
+
agents
|
|
200
|
+
}) }] };
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: err.message }) }] };
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
});
|
|
144
207
|
},
|
|
145
208
|
};
|
|
146
209
|
export default plugin;
|
package/dist/src/connection.d.ts
CHANGED
|
@@ -80,6 +80,7 @@ export declare class A2AConnectionManager {
|
|
|
80
80
|
skillTag?: string;
|
|
81
81
|
contentMode?: string;
|
|
82
82
|
sort?: string;
|
|
83
|
+
owner?: string;
|
|
83
84
|
verified?: boolean;
|
|
84
85
|
limit?: number;
|
|
85
86
|
offset?: number;
|
|
@@ -92,6 +93,32 @@ export declare class A2AConnectionManager {
|
|
|
92
93
|
tenantName?: string;
|
|
93
94
|
avgRating?: number;
|
|
94
95
|
}>>;
|
|
96
|
+
/**
|
|
97
|
+
* Discover agents near a geographic location
|
|
98
|
+
*/
|
|
99
|
+
discoverNearby(options: {
|
|
100
|
+
lat: number;
|
|
101
|
+
lng: number;
|
|
102
|
+
radius?: number;
|
|
103
|
+
tag?: string;
|
|
104
|
+
category?: string;
|
|
105
|
+
limit?: number;
|
|
106
|
+
offset?: number;
|
|
107
|
+
}): Promise<Array<{
|
|
108
|
+
id: string;
|
|
109
|
+
name: string;
|
|
110
|
+
description?: string;
|
|
111
|
+
verified?: boolean;
|
|
112
|
+
tenantName?: string;
|
|
113
|
+
avgRating?: number;
|
|
114
|
+
location?: {
|
|
115
|
+
name: string;
|
|
116
|
+
lat: number;
|
|
117
|
+
lng: number;
|
|
118
|
+
country: string;
|
|
119
|
+
};
|
|
120
|
+
distance?: number;
|
|
121
|
+
}>>;
|
|
95
122
|
/**
|
|
96
123
|
* List connection status (for backward compatibility)
|
|
97
124
|
*/
|
package/dist/src/connection.js
CHANGED
|
@@ -333,6 +333,13 @@ export class A2AConnectionManager {
|
|
|
333
333
|
const result = await this.a2aRpc('x-gopherhole/agents.discover', options);
|
|
334
334
|
return result?.agents || [];
|
|
335
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Discover agents near a geographic location
|
|
338
|
+
*/
|
|
339
|
+
async discoverNearby(options) {
|
|
340
|
+
const result = await this.a2aRpc('x-gopherhole/agents.discover.nearby', options);
|
|
341
|
+
return result?.agents || [];
|
|
342
|
+
}
|
|
336
343
|
/**
|
|
337
344
|
* List connection status (for backward compatibility)
|
|
338
345
|
*/
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for OpenClaw plugin discovery methods
|
|
3
|
+
* Tests the new discovery params: tag, skillTag, contentMode, sort, offset, scope
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { A2AConnectionManager } from './connection';
|
|
7
|
+
// Mock fetch
|
|
8
|
+
const mockFetch = vi.fn();
|
|
9
|
+
global.fetch = mockFetch;
|
|
10
|
+
// Mock @gopherhole/sdk
|
|
11
|
+
vi.mock('@gopherhole/sdk', () => ({
|
|
12
|
+
GopherHole: vi.fn().mockImplementation(() => ({
|
|
13
|
+
connect: vi.fn(),
|
|
14
|
+
disconnect: vi.fn(),
|
|
15
|
+
on: vi.fn(),
|
|
16
|
+
connected: false,
|
|
17
|
+
id: null,
|
|
18
|
+
})),
|
|
19
|
+
getTaskResponseText: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
describe('A2AConnectionManager Discovery', () => {
|
|
22
|
+
let manager;
|
|
23
|
+
const mockApiKey = 'gph_test_key';
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
manager = new A2AConnectionManager({
|
|
26
|
+
enabled: true,
|
|
27
|
+
apiKey: mockApiKey,
|
|
28
|
+
bridgeUrl: 'wss://test.gopherhole.ai/ws',
|
|
29
|
+
});
|
|
30
|
+
mockFetch.mockReset();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
const mockDiscoverResponse = {
|
|
36
|
+
agents: [
|
|
37
|
+
{
|
|
38
|
+
id: 'agent-1',
|
|
39
|
+
name: 'Test Agent',
|
|
40
|
+
description: 'A test agent',
|
|
41
|
+
verified: true,
|
|
42
|
+
tenantName: 'TestTenant',
|
|
43
|
+
avgRating: 4.5,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const setupMockRpcResponse = (result) => {
|
|
48
|
+
mockFetch.mockResolvedValueOnce({
|
|
49
|
+
ok: true,
|
|
50
|
+
json: () => Promise.resolve({ result }),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
describe('discoverAgents() with new params', () => {
|
|
54
|
+
it('should call discover with tag param', async () => {
|
|
55
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
56
|
+
await manager.discoverAgents({ tag: 'ai' });
|
|
57
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
58
|
+
method: 'POST',
|
|
59
|
+
body: expect.stringContaining('"tag":"ai"'),
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
it('should call discover with skillTag param', async () => {
|
|
63
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
64
|
+
await manager.discoverAgents({ skillTag: 'nlp' });
|
|
65
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
66
|
+
body: expect.stringContaining('"skillTag":"nlp"'),
|
|
67
|
+
}));
|
|
68
|
+
});
|
|
69
|
+
it('should call discover with contentMode param', async () => {
|
|
70
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
71
|
+
await manager.discoverAgents({ contentMode: 'text/markdown' });
|
|
72
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
73
|
+
body: expect.stringContaining('"contentMode":"text/markdown"'),
|
|
74
|
+
}));
|
|
75
|
+
});
|
|
76
|
+
it('should call discover with sort param', async () => {
|
|
77
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
78
|
+
await manager.discoverAgents({ sort: 'rating' });
|
|
79
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
80
|
+
body: expect.stringContaining('"sort":"rating"'),
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
it('should call discover with offset param', async () => {
|
|
84
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
85
|
+
await manager.discoverAgents({ offset: 20 });
|
|
86
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
87
|
+
body: expect.stringContaining('"offset":20'),
|
|
88
|
+
}));
|
|
89
|
+
});
|
|
90
|
+
it('should call discover with scope=tenant', async () => {
|
|
91
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
92
|
+
await manager.discoverAgents({ scope: 'tenant' });
|
|
93
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
94
|
+
body: expect.stringContaining('"scope":"tenant"'),
|
|
95
|
+
}));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('discoverAgents() with combined params', () => {
|
|
99
|
+
it('should combine multiple new params', async () => {
|
|
100
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
101
|
+
await manager.discoverAgents({
|
|
102
|
+
tag: 'ai',
|
|
103
|
+
skillTag: 'nlp',
|
|
104
|
+
sort: 'popular',
|
|
105
|
+
offset: 10,
|
|
106
|
+
});
|
|
107
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
108
|
+
expect(body.params.tag).toBe('ai');
|
|
109
|
+
expect(body.params.skillTag).toBe('nlp');
|
|
110
|
+
expect(body.params.sort).toBe('popular');
|
|
111
|
+
expect(body.params.offset).toBe(10);
|
|
112
|
+
});
|
|
113
|
+
it('should combine query with new params', async () => {
|
|
114
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
115
|
+
await manager.discoverAgents({
|
|
116
|
+
query: 'weather',
|
|
117
|
+
tag: 'api',
|
|
118
|
+
contentMode: 'application/json',
|
|
119
|
+
sort: 'recent',
|
|
120
|
+
limit: 25,
|
|
121
|
+
});
|
|
122
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
123
|
+
expect(body.params.query).toBe('weather');
|
|
124
|
+
expect(body.params.tag).toBe('api');
|
|
125
|
+
expect(body.params.contentMode).toBe('application/json');
|
|
126
|
+
expect(body.params.sort).toBe('recent');
|
|
127
|
+
expect(body.params.limit).toBe(25);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('discoverAgents() edge cases', () => {
|
|
131
|
+
it('should handle empty results', async () => {
|
|
132
|
+
setupMockRpcResponse({ agents: [] });
|
|
133
|
+
const result = await manager.discoverAgents({ tag: 'nonexistent' });
|
|
134
|
+
expect(result).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
it('should handle RPC errors gracefully', async () => {
|
|
137
|
+
mockFetch.mockResolvedValueOnce({
|
|
138
|
+
ok: true,
|
|
139
|
+
json: () => Promise.resolve({ error: { message: 'Not found' } }),
|
|
140
|
+
});
|
|
141
|
+
const result = await manager.discoverAgents({ tag: 'test' });
|
|
142
|
+
expect(result).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
it('should handle network errors', async () => {
|
|
145
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
146
|
+
const result = await manager.discoverAgents({ tag: 'test' });
|
|
147
|
+
expect(result).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
it('should return empty array when apiKey not configured', async () => {
|
|
150
|
+
const managerNoKey = new A2AConnectionManager({
|
|
151
|
+
enabled: true,
|
|
152
|
+
// No apiKey
|
|
153
|
+
});
|
|
154
|
+
const result = await managerNoKey.discoverAgents({ tag: 'test' });
|
|
155
|
+
expect(result).toEqual([]);
|
|
156
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('discoverAgents() sort values', () => {
|
|
160
|
+
it('should accept rating sort', async () => {
|
|
161
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
162
|
+
await manager.discoverAgents({ sort: 'rating' });
|
|
163
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
164
|
+
expect(body.params.sort).toBe('rating');
|
|
165
|
+
});
|
|
166
|
+
it('should accept popular sort', async () => {
|
|
167
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
168
|
+
await manager.discoverAgents({ sort: 'popular' });
|
|
169
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
170
|
+
expect(body.params.sort).toBe('popular');
|
|
171
|
+
});
|
|
172
|
+
it('should accept recent sort', async () => {
|
|
173
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
174
|
+
await manager.discoverAgents({ sort: 'recent' });
|
|
175
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
176
|
+
expect(body.params.sort).toBe('recent');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe('discoverAgents() response mapping', () => {
|
|
180
|
+
it('should map response correctly', async () => {
|
|
181
|
+
setupMockRpcResponse({
|
|
182
|
+
agents: [
|
|
183
|
+
{
|
|
184
|
+
id: 'agent-1',
|
|
185
|
+
name: 'Test Agent',
|
|
186
|
+
description: 'A test agent',
|
|
187
|
+
verified: true,
|
|
188
|
+
tenantName: 'TestTenant',
|
|
189
|
+
avgRating: 4.5,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
const result = await manager.discoverAgents({ query: 'test' });
|
|
194
|
+
expect(result).toEqual([
|
|
195
|
+
{
|
|
196
|
+
id: 'agent-1',
|
|
197
|
+
name: 'Test Agent',
|
|
198
|
+
description: 'A test agent',
|
|
199
|
+
verified: true,
|
|
200
|
+
tenantName: 'TestTenant',
|
|
201
|
+
avgRating: 4.5,
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('listAvailableAgents()', () => {
|
|
207
|
+
it('should list available agents', async () => {
|
|
208
|
+
setupMockRpcResponse({
|
|
209
|
+
agents: [
|
|
210
|
+
{
|
|
211
|
+
id: 'agent-1',
|
|
212
|
+
name: 'Test Agent',
|
|
213
|
+
description: 'A test agent',
|
|
214
|
+
verified: true,
|
|
215
|
+
accessType: 'same-tenant',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
const result = await manager.listAvailableAgents();
|
|
220
|
+
expect(result).toHaveLength(1);
|
|
221
|
+
expect(result[0].accessType).toBe('same-tenant');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
package/index.ts
CHANGED
|
@@ -159,6 +159,72 @@ const plugin = {
|
|
|
159
159
|
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: `Unknown action: ${action}` }) }] };
|
|
160
160
|
},
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
// Register a tool for location-based agent discovery
|
|
164
|
+
api.registerTool?.({
|
|
165
|
+
name: 'a2a_discover_nearby',
|
|
166
|
+
description: 'Find A2A agents near a geographic location',
|
|
167
|
+
parameters: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
lat: {
|
|
171
|
+
type: 'number',
|
|
172
|
+
description: 'Latitude of search center',
|
|
173
|
+
},
|
|
174
|
+
lng: {
|
|
175
|
+
type: 'number',
|
|
176
|
+
description: 'Longitude of search center',
|
|
177
|
+
},
|
|
178
|
+
radius: {
|
|
179
|
+
type: 'number',
|
|
180
|
+
description: 'Search radius in kilometers (default: 10, max: 500)',
|
|
181
|
+
},
|
|
182
|
+
tag: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'Filter by tag (e.g., "retail", "food")',
|
|
185
|
+
},
|
|
186
|
+
category: {
|
|
187
|
+
type: 'string',
|
|
188
|
+
description: 'Filter by category',
|
|
189
|
+
},
|
|
190
|
+
limit: {
|
|
191
|
+
type: 'number',
|
|
192
|
+
description: 'Maximum number of results (default: 20, max: 50)',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
required: ['lat', 'lng'],
|
|
196
|
+
},
|
|
197
|
+
execute: async (_id, params) => {
|
|
198
|
+
const lat = params.lat as number;
|
|
199
|
+
const lng = params.lng as number;
|
|
200
|
+
const radius = params.radius as number | undefined;
|
|
201
|
+
const tag = params.tag as string | undefined;
|
|
202
|
+
const category = params.category as string | undefined;
|
|
203
|
+
const limit = params.limit as number | undefined;
|
|
204
|
+
|
|
205
|
+
const manager = getA2AConnectionManager();
|
|
206
|
+
if (!manager) {
|
|
207
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'A2A channel not running' }) }] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!manager.isGopherHoleConnected()) {
|
|
211
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'Not connected to GopherHole' }) }] };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const agents = await manager.discoverNearby({ lat, lng, radius, tag, category, limit });
|
|
216
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
217
|
+
status: 'ok',
|
|
218
|
+
center: { lat, lng },
|
|
219
|
+
radius: radius || 10,
|
|
220
|
+
count: agents.length,
|
|
221
|
+
agents
|
|
222
|
+
}) }] };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: (err as Error).message }) }] };
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
});
|
|
162
228
|
},
|
|
163
229
|
};
|
|
164
230
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gopherhole_openclaw_a2a",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "GopherHole A2A plugin for OpenClaw - connect your AI agent to the GopherHole network",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc",
|
|
10
10
|
"clean": "rm -rf dist",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
11
13
|
"prepublishOnly": "npm run build"
|
|
12
14
|
},
|
|
13
15
|
"clawdbot": {
|
|
@@ -35,8 +37,10 @@
|
|
|
35
37
|
"uuid": "^10.0.0"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"@types/node": "^25.5.0",
|
|
38
41
|
"@types/uuid": "^10.0.0",
|
|
39
|
-
"typescript": "^5.9.3"
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"vitest": "^1.6.0"
|
|
40
44
|
},
|
|
41
45
|
"peerDependencies": {
|
|
42
46
|
"openclaw": "*"
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for OpenClaw plugin discovery methods
|
|
3
|
+
* Tests the new discovery params: tag, skillTag, contentMode, sort, offset, scope
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { A2AConnectionManager } from './connection';
|
|
8
|
+
|
|
9
|
+
// Mock fetch
|
|
10
|
+
const mockFetch = vi.fn();
|
|
11
|
+
global.fetch = mockFetch;
|
|
12
|
+
|
|
13
|
+
// Mock @gopherhole/sdk
|
|
14
|
+
vi.mock('@gopherhole/sdk', () => ({
|
|
15
|
+
GopherHole: vi.fn().mockImplementation(() => ({
|
|
16
|
+
connect: vi.fn(),
|
|
17
|
+
disconnect: vi.fn(),
|
|
18
|
+
on: vi.fn(),
|
|
19
|
+
connected: false,
|
|
20
|
+
id: null,
|
|
21
|
+
})),
|
|
22
|
+
getTaskResponseText: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('A2AConnectionManager Discovery', () => {
|
|
26
|
+
let manager: A2AConnectionManager;
|
|
27
|
+
const mockApiKey = 'gph_test_key';
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
manager = new A2AConnectionManager({
|
|
31
|
+
enabled: true,
|
|
32
|
+
apiKey: mockApiKey,
|
|
33
|
+
bridgeUrl: 'wss://test.gopherhole.ai/ws',
|
|
34
|
+
});
|
|
35
|
+
mockFetch.mockReset();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const mockDiscoverResponse = {
|
|
43
|
+
agents: [
|
|
44
|
+
{
|
|
45
|
+
id: 'agent-1',
|
|
46
|
+
name: 'Test Agent',
|
|
47
|
+
description: 'A test agent',
|
|
48
|
+
verified: true,
|
|
49
|
+
tenantName: 'TestTenant',
|
|
50
|
+
avgRating: 4.5,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const setupMockRpcResponse = (result: any) => {
|
|
56
|
+
mockFetch.mockResolvedValueOnce({
|
|
57
|
+
ok: true,
|
|
58
|
+
json: () => Promise.resolve({ result }),
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('discoverAgents() with new params', () => {
|
|
63
|
+
it('should call discover with tag param', async () => {
|
|
64
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
65
|
+
|
|
66
|
+
await manager.discoverAgents({ tag: 'ai' });
|
|
67
|
+
|
|
68
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
69
|
+
expect.any(String),
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
method: 'POST',
|
|
72
|
+
body: expect.stringContaining('"tag":"ai"'),
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should call discover with skillTag param', async () => {
|
|
78
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
79
|
+
|
|
80
|
+
await manager.discoverAgents({ skillTag: 'nlp' });
|
|
81
|
+
|
|
82
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
83
|
+
expect.any(String),
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
body: expect.stringContaining('"skillTag":"nlp"'),
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should call discover with contentMode param', async () => {
|
|
91
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
92
|
+
|
|
93
|
+
await manager.discoverAgents({ contentMode: 'text/markdown' });
|
|
94
|
+
|
|
95
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
96
|
+
expect.any(String),
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
body: expect.stringContaining('"contentMode":"text/markdown"'),
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should call discover with sort param', async () => {
|
|
104
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
105
|
+
|
|
106
|
+
await manager.discoverAgents({ sort: 'rating' });
|
|
107
|
+
|
|
108
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
109
|
+
expect.any(String),
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
body: expect.stringContaining('"sort":"rating"'),
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should call discover with offset param', async () => {
|
|
117
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
118
|
+
|
|
119
|
+
await manager.discoverAgents({ offset: 20 });
|
|
120
|
+
|
|
121
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
122
|
+
expect.any(String),
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
body: expect.stringContaining('"offset":20'),
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should call discover with scope=tenant', async () => {
|
|
130
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
131
|
+
|
|
132
|
+
await manager.discoverAgents({ scope: 'tenant' });
|
|
133
|
+
|
|
134
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
135
|
+
expect.any(String),
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
body: expect.stringContaining('"scope":"tenant"'),
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('discoverAgents() with combined params', () => {
|
|
144
|
+
it('should combine multiple new params', async () => {
|
|
145
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
146
|
+
|
|
147
|
+
await manager.discoverAgents({
|
|
148
|
+
tag: 'ai',
|
|
149
|
+
skillTag: 'nlp',
|
|
150
|
+
sort: 'popular',
|
|
151
|
+
offset: 10,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
155
|
+
expect(body.params.tag).toBe('ai');
|
|
156
|
+
expect(body.params.skillTag).toBe('nlp');
|
|
157
|
+
expect(body.params.sort).toBe('popular');
|
|
158
|
+
expect(body.params.offset).toBe(10);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should combine query with new params', async () => {
|
|
162
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
163
|
+
|
|
164
|
+
await manager.discoverAgents({
|
|
165
|
+
query: 'weather',
|
|
166
|
+
tag: 'api',
|
|
167
|
+
contentMode: 'application/json',
|
|
168
|
+
sort: 'recent',
|
|
169
|
+
limit: 25,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
173
|
+
expect(body.params.query).toBe('weather');
|
|
174
|
+
expect(body.params.tag).toBe('api');
|
|
175
|
+
expect(body.params.contentMode).toBe('application/json');
|
|
176
|
+
expect(body.params.sort).toBe('recent');
|
|
177
|
+
expect(body.params.limit).toBe(25);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('discoverAgents() edge cases', () => {
|
|
182
|
+
it('should handle empty results', async () => {
|
|
183
|
+
setupMockRpcResponse({ agents: [] });
|
|
184
|
+
|
|
185
|
+
const result = await manager.discoverAgents({ tag: 'nonexistent' });
|
|
186
|
+
|
|
187
|
+
expect(result).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should handle RPC errors gracefully', async () => {
|
|
191
|
+
mockFetch.mockResolvedValueOnce({
|
|
192
|
+
ok: true,
|
|
193
|
+
json: () => Promise.resolve({ error: { message: 'Not found' } }),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const result = await manager.discoverAgents({ tag: 'test' });
|
|
197
|
+
|
|
198
|
+
expect(result).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle network errors', async () => {
|
|
202
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
203
|
+
|
|
204
|
+
const result = await manager.discoverAgents({ tag: 'test' });
|
|
205
|
+
|
|
206
|
+
expect(result).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should return empty array when apiKey not configured', async () => {
|
|
210
|
+
const managerNoKey = new A2AConnectionManager({
|
|
211
|
+
enabled: true,
|
|
212
|
+
// No apiKey
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const result = await managerNoKey.discoverAgents({ tag: 'test' });
|
|
216
|
+
|
|
217
|
+
expect(result).toEqual([]);
|
|
218
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('discoverAgents() sort values', () => {
|
|
223
|
+
it('should accept rating sort', async () => {
|
|
224
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
225
|
+
await manager.discoverAgents({ sort: 'rating' });
|
|
226
|
+
|
|
227
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
228
|
+
expect(body.params.sort).toBe('rating');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should accept popular sort', async () => {
|
|
232
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
233
|
+
await manager.discoverAgents({ sort: 'popular' });
|
|
234
|
+
|
|
235
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
236
|
+
expect(body.params.sort).toBe('popular');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should accept recent sort', async () => {
|
|
240
|
+
setupMockRpcResponse(mockDiscoverResponse);
|
|
241
|
+
await manager.discoverAgents({ sort: 'recent' });
|
|
242
|
+
|
|
243
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
244
|
+
expect(body.params.sort).toBe('recent');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('discoverAgents() response mapping', () => {
|
|
249
|
+
it('should map response correctly', async () => {
|
|
250
|
+
setupMockRpcResponse({
|
|
251
|
+
agents: [
|
|
252
|
+
{
|
|
253
|
+
id: 'agent-1',
|
|
254
|
+
name: 'Test Agent',
|
|
255
|
+
description: 'A test agent',
|
|
256
|
+
verified: true,
|
|
257
|
+
tenantName: 'TestTenant',
|
|
258
|
+
avgRating: 4.5,
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await manager.discoverAgents({ query: 'test' });
|
|
264
|
+
|
|
265
|
+
expect(result).toEqual([
|
|
266
|
+
{
|
|
267
|
+
id: 'agent-1',
|
|
268
|
+
name: 'Test Agent',
|
|
269
|
+
description: 'A test agent',
|
|
270
|
+
verified: true,
|
|
271
|
+
tenantName: 'TestTenant',
|
|
272
|
+
avgRating: 4.5,
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('listAvailableAgents()', () => {
|
|
279
|
+
it('should list available agents', async () => {
|
|
280
|
+
setupMockRpcResponse({
|
|
281
|
+
agents: [
|
|
282
|
+
{
|
|
283
|
+
id: 'agent-1',
|
|
284
|
+
name: 'Test Agent',
|
|
285
|
+
description: 'A test agent',
|
|
286
|
+
verified: true,
|
|
287
|
+
accessType: 'same-tenant',
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const result = await manager.listAvailableAgents();
|
|
293
|
+
|
|
294
|
+
expect(result).toHaveLength(1);
|
|
295
|
+
expect(result[0].accessType).toBe('same-tenant');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
package/src/connection.ts
CHANGED
|
@@ -427,7 +427,8 @@ export class A2AConnectionManager {
|
|
|
427
427
|
skillTag?: string;
|
|
428
428
|
contentMode?: string;
|
|
429
429
|
sort?: string;
|
|
430
|
-
|
|
430
|
+
owner?: string; // Filter by organization/tenant name
|
|
431
|
+
verified?: boolean; // Only show agents from verified organizations
|
|
431
432
|
limit?: number;
|
|
432
433
|
offset?: number;
|
|
433
434
|
scope?: string;
|
|
@@ -451,6 +452,51 @@ export class A2AConnectionManager {
|
|
|
451
452
|
return result?.agents || [];
|
|
452
453
|
}
|
|
453
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Discover agents near a geographic location
|
|
457
|
+
*/
|
|
458
|
+
async discoverNearby(options: {
|
|
459
|
+
lat: number;
|
|
460
|
+
lng: number;
|
|
461
|
+
radius?: number;
|
|
462
|
+
tag?: string;
|
|
463
|
+
category?: string;
|
|
464
|
+
limit?: number;
|
|
465
|
+
offset?: number;
|
|
466
|
+
}): Promise<Array<{
|
|
467
|
+
id: string;
|
|
468
|
+
name: string;
|
|
469
|
+
description?: string;
|
|
470
|
+
verified?: boolean;
|
|
471
|
+
tenantName?: string;
|
|
472
|
+
avgRating?: number;
|
|
473
|
+
location?: {
|
|
474
|
+
name: string;
|
|
475
|
+
lat: number;
|
|
476
|
+
lng: number;
|
|
477
|
+
country: string;
|
|
478
|
+
};
|
|
479
|
+
distance?: number;
|
|
480
|
+
}>> {
|
|
481
|
+
const result = await this.a2aRpc<{ agents: Array<{
|
|
482
|
+
id: string;
|
|
483
|
+
name: string;
|
|
484
|
+
description?: string;
|
|
485
|
+
verified?: boolean;
|
|
486
|
+
tenantName?: string;
|
|
487
|
+
avgRating?: number;
|
|
488
|
+
location?: {
|
|
489
|
+
name: string;
|
|
490
|
+
lat: number;
|
|
491
|
+
lng: number;
|
|
492
|
+
country: string;
|
|
493
|
+
};
|
|
494
|
+
distance?: number;
|
|
495
|
+
}> }>('x-gopherhole/agents.discover.nearby', options);
|
|
496
|
+
|
|
497
|
+
return result?.agents || [];
|
|
498
|
+
}
|
|
499
|
+
|
|
454
500
|
/**
|
|
455
501
|
* List connection status (for backward compatibility)
|
|
456
502
|
*/
|
package/tsconfig.json
CHANGED