clawport-ui 0.5.1 → 0.5.2
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/lib/agents-registry.ts +186 -67
- package/lib/agents.test.ts +554 -1
- package/lib/teams.test.ts +305 -0
- package/package.json +1 -1
package/lib/agents-registry.ts
CHANGED
|
@@ -111,6 +111,25 @@ function scanSubAgentDir(dirPath: string): { fileName: string; content: string }
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Scan a parent agent directory for child subdirectories containing SOUL.md.
|
|
116
|
+
* Skips sub-agents/ and members/ (already handled by scanSubAgentDir).
|
|
117
|
+
*/
|
|
118
|
+
function scanSubdirAgents(parentDir: string): { dirName: string; content: string }[] {
|
|
119
|
+
if (!existsSync(parentDir)) return []
|
|
120
|
+
try {
|
|
121
|
+
return readdirSync(parentDir, { withFileTypes: true })
|
|
122
|
+
.filter(e => e.isDirectory() && e.name !== 'sub-agents' && e.name !== 'members')
|
|
123
|
+
.map(e => {
|
|
124
|
+
const content = safeRead(join(parentDir, e.name, 'SOUL.md'))
|
|
125
|
+
return content ? { dirName: e.name, content } : null
|
|
126
|
+
})
|
|
127
|
+
.filter((x): x is { dirName: string; content: string } => x !== null)
|
|
128
|
+
} catch {
|
|
129
|
+
return []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
// ---------------------------------------------------------------------------
|
|
115
134
|
// Auto-discovery
|
|
116
135
|
// ---------------------------------------------------------------------------
|
|
@@ -147,6 +166,7 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
147
166
|
|
|
148
167
|
const discovered: AgentEntry[] = []
|
|
149
168
|
let colorIndex = 0
|
|
169
|
+
const directReportIds: string[] = []
|
|
150
170
|
|
|
151
171
|
// --- Root agent ---
|
|
152
172
|
let rootId = 'main'
|
|
@@ -177,9 +197,6 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
177
197
|
}
|
|
178
198
|
}
|
|
179
199
|
|
|
180
|
-
// Collect all agent IDs that report to root (including those without SOUL.md but with sub-agents)
|
|
181
|
-
const directReportIds: string[] = []
|
|
182
|
-
|
|
183
200
|
discovered.push({
|
|
184
201
|
id: rootId,
|
|
185
202
|
name: rootName,
|
|
@@ -197,6 +214,7 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
197
214
|
|
|
198
215
|
// We'll fill directReportIds as we discover agents below
|
|
199
216
|
for (const dirName of allAgentDirs) {
|
|
217
|
+
if (dirName === rootId) continue // root-matching dir scanned in main loop
|
|
200
218
|
const hasSoul = existsSync(join(agentsDir, dirName, 'SOUL.md'))
|
|
201
219
|
const hasSubAgents = existsSync(join(agentsDir, dirName, 'sub-agents'))
|
|
202
220
|
const hasMembers = existsSync(join(agentsDir, dirName, 'members'))
|
|
@@ -208,8 +226,61 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
208
226
|
|
|
209
227
|
// --- Top-level agents ---
|
|
210
228
|
for (const dirName of allAgentDirs) {
|
|
211
|
-
//
|
|
212
|
-
if (hasRoot && dirName === rootId)
|
|
229
|
+
// Root-matching directory: scan for sub-agents but don't create a duplicate root entry
|
|
230
|
+
if (hasRoot && dirName === rootId) {
|
|
231
|
+
const rootAgentDir = join(agentsDir, dirName)
|
|
232
|
+
const rootSubFiles = [
|
|
233
|
+
...scanSubAgentDir(join(rootAgentDir, 'sub-agents')),
|
|
234
|
+
...scanSubAgentDir(join(rootAgentDir, 'members')),
|
|
235
|
+
]
|
|
236
|
+
const rootSubIds = new Set<string>()
|
|
237
|
+
for (const sub of rootSubFiles) {
|
|
238
|
+
const subId = `${rootId}-${basename(sub.fileName, '.md').toLowerCase()}`
|
|
239
|
+
rootSubIds.add(subId)
|
|
240
|
+
directReportIds.push(subId)
|
|
241
|
+
const subParsed = parseSoulHeading(sub.content)
|
|
242
|
+
const subName = subParsed.name || slugToName(basename(sub.fileName, '.md'))
|
|
243
|
+
const subTitle = subParsed.title || 'Agent'
|
|
244
|
+
discovered.push({
|
|
245
|
+
id: subId,
|
|
246
|
+
name: subName,
|
|
247
|
+
title: subTitle,
|
|
248
|
+
reportsTo: rootId,
|
|
249
|
+
directReports: [],
|
|
250
|
+
soulPath: null,
|
|
251
|
+
voiceId: null,
|
|
252
|
+
color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
|
|
253
|
+
emoji: subName.charAt(0).toUpperCase(),
|
|
254
|
+
tools: ['read', 'write'],
|
|
255
|
+
memoryPath: null,
|
|
256
|
+
description: `${subName} agent.`,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const sub of scanSubdirAgents(rootAgentDir)) {
|
|
261
|
+
const subId = `${rootId}-${sub.dirName}`
|
|
262
|
+
if (rootSubIds.has(subId)) continue
|
|
263
|
+
directReportIds.push(subId)
|
|
264
|
+
const subParsed = parseSoulHeading(sub.content)
|
|
265
|
+
const subName = subParsed.name || slugToName(sub.dirName)
|
|
266
|
+
const subTitle = subParsed.title || 'Agent'
|
|
267
|
+
discovered.push({
|
|
268
|
+
id: subId,
|
|
269
|
+
name: subName,
|
|
270
|
+
title: subTitle,
|
|
271
|
+
reportsTo: rootId,
|
|
272
|
+
directReports: [],
|
|
273
|
+
soulPath: `agents/${rootId}/${sub.dirName}/SOUL.md`,
|
|
274
|
+
voiceId: null,
|
|
275
|
+
color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
|
|
276
|
+
emoji: subName.charAt(0).toUpperCase(),
|
|
277
|
+
tools: ['read', 'write'],
|
|
278
|
+
memoryPath: null,
|
|
279
|
+
description: `${subName} agent.`,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
213
284
|
|
|
214
285
|
const soulFile = join(agentsDir, dirName, 'SOUL.md')
|
|
215
286
|
const hasSoul = existsSync(soulFile)
|
|
@@ -277,6 +348,32 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
277
348
|
})
|
|
278
349
|
}
|
|
279
350
|
|
|
351
|
+
// Discover subdirectory agents (child dirs with SOUL.md)
|
|
352
|
+
const subdirAgents = scanSubdirAgents(join(agentsDir, dirName))
|
|
353
|
+
const existingSubIds = new Set(subAgentIds)
|
|
354
|
+
for (const sub of subdirAgents) {
|
|
355
|
+
const subId = `${dirName}-${sub.dirName}`
|
|
356
|
+
if (existingSubIds.has(subId)) continue
|
|
357
|
+
subAgentIds.push(subId)
|
|
358
|
+
const subParsed = parseSoulHeading(sub.content)
|
|
359
|
+
const subName = subParsed.name || slugToName(sub.dirName)
|
|
360
|
+
const subTitle = subParsed.title || 'Agent'
|
|
361
|
+
discovered.push({
|
|
362
|
+
id: subId,
|
|
363
|
+
name: subName,
|
|
364
|
+
title: subTitle,
|
|
365
|
+
reportsTo: dirName,
|
|
366
|
+
directReports: [],
|
|
367
|
+
soulPath: `agents/${dirName}/${sub.dirName}/SOUL.md`,
|
|
368
|
+
voiceId: null,
|
|
369
|
+
color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
|
|
370
|
+
emoji: subName.charAt(0).toUpperCase(),
|
|
371
|
+
tools: ['read', 'write'],
|
|
372
|
+
memoryPath: null,
|
|
373
|
+
description: `${subName} agent.`,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
280
377
|
discovered.push({
|
|
281
378
|
id: dirName,
|
|
282
379
|
name,
|
|
@@ -297,80 +394,98 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
297
394
|
}
|
|
298
395
|
|
|
299
396
|
// ---------------------------------------------------------------------------
|
|
300
|
-
// CLI-based discovery (openclaw agents list)
|
|
397
|
+
// CLI-based multi-workspace discovery (openclaw agents list)
|
|
301
398
|
// ---------------------------------------------------------------------------
|
|
302
399
|
|
|
303
400
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
401
|
+
* Raw entry from `openclaw agents list --json`.
|
|
402
|
+
*
|
|
403
|
+
* Real CLI output shape (verified against OpenClaw docs + live CLI):
|
|
404
|
+
* { id, identityName, identityEmoji, identitySource,
|
|
405
|
+
* workspace, agentDir, model, bindings, isDefault, routes }
|
|
406
|
+
*/
|
|
407
|
+
interface CliAgentEntry {
|
|
408
|
+
id: string
|
|
409
|
+
identityName?: string
|
|
410
|
+
identityEmoji?: string
|
|
411
|
+
workspace?: string
|
|
412
|
+
isDefault?: boolean
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Call `openclaw agents list --json` and return parsed entries.
|
|
417
|
+
* Returns null on any failure (CLI not found, bad JSON, timeout).
|
|
308
418
|
*/
|
|
309
|
-
function
|
|
419
|
+
export function listCliAgents(openclawBin: string): CliAgentEntry[] | null {
|
|
310
420
|
try {
|
|
311
421
|
const raw = execSync(`${openclawBin} agents list --json`, {
|
|
312
422
|
encoding: 'utf-8',
|
|
313
423
|
timeout: 10000,
|
|
314
424
|
})
|
|
315
425
|
const parsed = JSON.parse(raw)
|
|
316
|
-
const agents: unknown[] = Array.isArray(parsed)
|
|
317
|
-
? parsed
|
|
318
|
-
: parsed.agents ?? parsed.data ?? []
|
|
319
|
-
|
|
426
|
+
const agents: unknown[] = Array.isArray(parsed) ? parsed : []
|
|
320
427
|
if (agents.length === 0) return null
|
|
321
|
-
|
|
322
|
-
let colorIndex = 0
|
|
323
|
-
return agents.map((entry) => {
|
|
324
|
-
const a = entry as Record<string, unknown>
|
|
325
|
-
const id = String(a.id ?? a.name ?? a.slug ?? '')
|
|
326
|
-
const name = String(a.name ?? a.displayName ?? slugToName(id))
|
|
327
|
-
const title = String(a.title ?? a.role ?? 'Agent')
|
|
328
|
-
const reportsTo = a.reportsTo != null ? String(a.reportsTo) : null
|
|
329
|
-
const directReports = Array.isArray(a.directReports)
|
|
330
|
-
? a.directReports.map(String)
|
|
331
|
-
: []
|
|
332
|
-
const tools = Array.isArray(a.tools) ? a.tools.map(String) : ['read', 'write']
|
|
333
|
-
|
|
334
|
-
return {
|
|
335
|
-
id,
|
|
336
|
-
name,
|
|
337
|
-
title,
|
|
338
|
-
reportsTo,
|
|
339
|
-
directReports,
|
|
340
|
-
soulPath: typeof a.soulPath === 'string' ? a.soulPath : null,
|
|
341
|
-
voiceId: typeof a.voiceId === 'string' ? a.voiceId : null,
|
|
342
|
-
color: typeof a.color === 'string' ? a.color : DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
|
|
343
|
-
emoji: typeof a.emoji === 'string' ? a.emoji : name.charAt(0).toUpperCase(),
|
|
344
|
-
tools,
|
|
345
|
-
memoryPath: typeof a.memoryPath === 'string' ? a.memoryPath : null,
|
|
346
|
-
description: typeof a.description === 'string' ? a.description : `${name} agent.`,
|
|
347
|
-
}
|
|
348
|
-
})
|
|
428
|
+
return agents as CliAgentEntry[]
|
|
349
429
|
} catch {
|
|
350
430
|
return null
|
|
351
431
|
}
|
|
352
432
|
}
|
|
353
433
|
|
|
354
434
|
/**
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
435
|
+
* Scan additional workspaces found via CLI that differ from the primary
|
|
436
|
+
* WORKSPACE_PATH. For each extra workspace, run discoverAgents() and
|
|
437
|
+
* merge results into the existing registry.
|
|
438
|
+
*
|
|
439
|
+
* Agents from other workspaces are independent (no cross-workspace hierarchy).
|
|
440
|
+
* If the other workspace has a root orchestrator, it becomes a top-level peer
|
|
441
|
+
* in the primary workspace's registry.
|
|
358
442
|
*/
|
|
359
|
-
function
|
|
443
|
+
function mergeExtraWorkspaces(
|
|
444
|
+
existing: AgentEntry[],
|
|
445
|
+
cliAgents: CliAgentEntry[],
|
|
446
|
+
primaryWorkspace: string,
|
|
447
|
+
): AgentEntry[] {
|
|
360
448
|
const existingIds = new Set(existing.map(a => a.id))
|
|
361
449
|
const added: AgentEntry[] = []
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
450
|
+
let colorIndex = existing.length
|
|
451
|
+
|
|
452
|
+
for (const cli of cliAgents) {
|
|
453
|
+
const ws = cli.workspace
|
|
454
|
+
// Skip agents whose workspace matches the primary (already discovered)
|
|
455
|
+
if (!ws || ws === primaryWorkspace) continue
|
|
456
|
+
|
|
457
|
+
// Try discovering agents from this workspace's filesystem
|
|
458
|
+
const discovered = discoverAgents(ws)
|
|
459
|
+
if (discovered) {
|
|
460
|
+
for (const agent of discovered) {
|
|
461
|
+
if (existingIds.has(agent.id)) continue
|
|
462
|
+
// Agents from other workspaces are top-level peers (no cross-workspace hierarchy)
|
|
463
|
+
if (agent.reportsTo && !existingIds.has(agent.reportsTo)) {
|
|
464
|
+
agent.reportsTo = null
|
|
465
|
+
}
|
|
466
|
+
added.push(agent)
|
|
467
|
+
existingIds.add(agent.id)
|
|
373
468
|
}
|
|
469
|
+
} else {
|
|
470
|
+
// Workspace has no agents/ dir — create a minimal entry from CLI identity
|
|
471
|
+
const id = cli.id
|
|
472
|
+
if (existingIds.has(id)) continue
|
|
473
|
+
const name = cli.identityName || slugToName(id)
|
|
474
|
+
added.push({
|
|
475
|
+
id,
|
|
476
|
+
name,
|
|
477
|
+
title: 'Agent',
|
|
478
|
+
reportsTo: null,
|
|
479
|
+
directReports: [],
|
|
480
|
+
soulPath: null,
|
|
481
|
+
voiceId: null,
|
|
482
|
+
color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
|
|
483
|
+
emoji: cli.identityEmoji || name.charAt(0).toUpperCase(),
|
|
484
|
+
tools: ['read', 'write'],
|
|
485
|
+
memoryPath: null,
|
|
486
|
+
description: `${name} agent.`,
|
|
487
|
+
})
|
|
488
|
+
existingIds.add(id)
|
|
374
489
|
}
|
|
375
490
|
}
|
|
376
491
|
|
|
@@ -383,8 +498,8 @@ function mergeCliAgents(existing: AgentEntry[], cliAgents: AgentEntry[]): AgentE
|
|
|
383
498
|
* Resolution order:
|
|
384
499
|
* 1. $WORKSPACE_PATH/clawport/agents.json (user's own config)
|
|
385
500
|
* 2. Auto-discovered from $WORKSPACE_PATH (agents/ directory scan)
|
|
386
|
-
* + merged with `openclaw agents list --json`
|
|
387
|
-
* 3. CLI-only discovery
|
|
501
|
+
* + merged with other workspaces from `openclaw agents list --json`
|
|
502
|
+
* 3. CLI-only discovery (scans each agent's workspace)
|
|
388
503
|
* 4. Bundled lib/agents.json (default example registry)
|
|
389
504
|
*/
|
|
390
505
|
export function loadRegistry(): AgentEntry[] {
|
|
@@ -403,21 +518,25 @@ export function loadRegistry(): AgentEntry[] {
|
|
|
403
518
|
}
|
|
404
519
|
}
|
|
405
520
|
|
|
406
|
-
// 2. Auto-discover from workspace filesystem
|
|
521
|
+
// 2. Auto-discover from primary workspace filesystem
|
|
407
522
|
const discovered = discoverAgents(workspacePath)
|
|
408
523
|
|
|
409
|
-
// 2b. Merge
|
|
524
|
+
// 2b. Merge agents from other workspaces known to the CLI
|
|
410
525
|
if (discovered && openclawBin) {
|
|
411
|
-
const cliAgents =
|
|
412
|
-
if (cliAgents
|
|
526
|
+
const cliAgents = listCliAgents(openclawBin)
|
|
527
|
+
if (cliAgents && cliAgents.length > 1) {
|
|
528
|
+
return mergeExtraWorkspaces(discovered, cliAgents, workspacePath)
|
|
529
|
+
}
|
|
413
530
|
return discovered
|
|
414
531
|
}
|
|
415
532
|
if (discovered) return discovered
|
|
416
533
|
|
|
417
|
-
// 3. CLI-only
|
|
534
|
+
// 3. CLI-only: no primary workspace agents, scan each CLI agent's workspace
|
|
418
535
|
if (openclawBin) {
|
|
419
|
-
const cliAgents =
|
|
420
|
-
if (cliAgents)
|
|
536
|
+
const cliAgents = listCliAgents(openclawBin)
|
|
537
|
+
if (cliAgents) {
|
|
538
|
+
return mergeExtraWorkspaces([], cliAgents, '')
|
|
539
|
+
}
|
|
421
540
|
}
|
|
422
541
|
}
|
|
423
542
|
|
package/lib/agents.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
3
3
|
|
|
4
|
-
const { mockReadFileSync, mockExistsSync, mockReaddirSync, bundledAgents } = vi.hoisted(() => ({
|
|
4
|
+
const { mockReadFileSync, mockExistsSync, mockReaddirSync, mockExecSync, bundledAgents } = vi.hoisted(() => ({
|
|
5
5
|
mockReadFileSync: vi.fn(),
|
|
6
6
|
mockExistsSync: vi.fn(),
|
|
7
7
|
mockReaddirSync: vi.fn(),
|
|
8
|
+
mockExecSync: vi.fn(),
|
|
8
9
|
bundledAgents: [
|
|
9
10
|
{
|
|
10
11
|
id: 'jarvis',
|
|
@@ -115,6 +116,12 @@ vi.mock('fs', () => ({
|
|
|
115
116
|
default: { readFileSync: mockReadFileSync, existsSync: mockExistsSync, readdirSync: mockReaddirSync },
|
|
116
117
|
}))
|
|
117
118
|
|
|
119
|
+
// Mock child_process for CLI discovery
|
|
120
|
+
vi.mock('child_process', () => ({
|
|
121
|
+
execSync: mockExecSync,
|
|
122
|
+
default: { execSync: mockExecSync },
|
|
123
|
+
}))
|
|
124
|
+
|
|
118
125
|
// Mock the bundled agents.json
|
|
119
126
|
vi.mock('@/lib/agents.json', () => ({
|
|
120
127
|
default: bundledAgents,
|
|
@@ -705,6 +712,209 @@ describe('auto-discovery from workspace', () => {
|
|
|
705
712
|
expect(kaze.name).toBe('KAZE')
|
|
706
713
|
expect(kaze.title).toBe('Flight Research Agent')
|
|
707
714
|
})
|
|
715
|
+
|
|
716
|
+
it('discovers subdirectory agents with SOUL.md (outpost pattern)', async () => {
|
|
717
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
718
|
+
|
|
719
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
720
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
721
|
+
if (p === '/tmp/ws/SOUL.md') return false
|
|
722
|
+
if (p === '/tmp/ws/agents') return true
|
|
723
|
+
if (p === '/tmp/ws/agents/outpost/SOUL.md') return true
|
|
724
|
+
if (p === '/tmp/ws/agents/outpost') return true
|
|
725
|
+
if (p === '/tmp/ws/agents/outpost/sub-agents') return false
|
|
726
|
+
if (p === '/tmp/ws/agents/outpost/members') return false
|
|
727
|
+
if (p === '/tmp/ws/agents/outpost/scout/SOUL.md') return true
|
|
728
|
+
if (p === '/tmp/ws/agents/outpost/mirror/SOUL.md') return true
|
|
729
|
+
return false
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
mockReaddirSync.mockImplementation((p: string) => {
|
|
733
|
+
if (String(p) === '/tmp/ws/agents') {
|
|
734
|
+
return [{ name: 'outpost', isDirectory: () => true }]
|
|
735
|
+
}
|
|
736
|
+
if (String(p) === '/tmp/ws/agents/outpost') {
|
|
737
|
+
return [
|
|
738
|
+
{ name: 'scout', isDirectory: () => true },
|
|
739
|
+
{ name: 'mirror', isDirectory: () => true },
|
|
740
|
+
]
|
|
741
|
+
}
|
|
742
|
+
return []
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
746
|
+
if (p === '/tmp/ws/agents/outpost/SOUL.md') return '# OUTPOST — Forward Operations'
|
|
747
|
+
if (p === '/tmp/ws/agents/outpost/scout/SOUL.md') return '# SCOUT — Reconnaissance Agent'
|
|
748
|
+
if (p === '/tmp/ws/agents/outpost/mirror/SOUL.md') return '# MIRROR — Reflection Agent'
|
|
749
|
+
throw new Error('ENOENT')
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
const agents = await getAgents()
|
|
753
|
+
const outpost = agents.find(a => a.id === 'outpost')!
|
|
754
|
+
expect(outpost.name).toBe('OUTPOST')
|
|
755
|
+
expect(outpost.directReports).toContain('outpost-scout')
|
|
756
|
+
expect(outpost.directReports).toContain('outpost-mirror')
|
|
757
|
+
|
|
758
|
+
const scout = agents.find(a => a.id === 'outpost-scout')!
|
|
759
|
+
expect(scout.name).toBe('SCOUT')
|
|
760
|
+
expect(scout.title).toBe('Reconnaissance Agent')
|
|
761
|
+
expect(scout.reportsTo).toBe('outpost')
|
|
762
|
+
expect(scout.soulPath).toBe('agents/outpost/scout/SOUL.md')
|
|
763
|
+
|
|
764
|
+
const mirror = agents.find(a => a.id === 'outpost-mirror')!
|
|
765
|
+
expect(mirror.name).toBe('MIRROR')
|
|
766
|
+
expect(mirror.title).toBe('Reflection Agent')
|
|
767
|
+
expect(mirror.reportsTo).toBe('outpost')
|
|
768
|
+
expect(mirror.soulPath).toBe('agents/outpost/mirror/SOUL.md')
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('scans sub-agents from root-matching directory', async () => {
|
|
772
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
773
|
+
|
|
774
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
775
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
776
|
+
if (p === '/tmp/ws/SOUL.md') return true
|
|
777
|
+
if (p === '/tmp/ws/IDENTITY.md') return true
|
|
778
|
+
if (p === '/tmp/ws/agents') return true
|
|
779
|
+
if (p === '/tmp/ws/agents/jarvis/SOUL.md') return true
|
|
780
|
+
if (p === '/tmp/ws/agents/jarvis') return true
|
|
781
|
+
if (p === '/tmp/ws/agents/jarvis/sub-agents') return true
|
|
782
|
+
if (p === '/tmp/ws/agents/jarvis/sub-agents/SCRIBE.md') return true
|
|
783
|
+
if (p === '/tmp/ws/agents/jarvis/members') return false
|
|
784
|
+
if (p === '/tmp/ws/agents/vera/SOUL.md') return true
|
|
785
|
+
if (p === '/tmp/ws/agents/vera/sub-agents') return false
|
|
786
|
+
if (p === '/tmp/ws/agents/vera/members') return false
|
|
787
|
+
return false
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
mockReaddirSync.mockImplementation((p: string) => {
|
|
791
|
+
if (String(p) === '/tmp/ws/agents') {
|
|
792
|
+
return [
|
|
793
|
+
{ name: 'jarvis', isDirectory: () => true },
|
|
794
|
+
{ name: 'vera', isDirectory: () => true },
|
|
795
|
+
]
|
|
796
|
+
}
|
|
797
|
+
if (String(p) === '/tmp/ws/agents/jarvis/sub-agents') {
|
|
798
|
+
return ['SCRIBE.md']
|
|
799
|
+
}
|
|
800
|
+
return []
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
804
|
+
if (p === '/tmp/ws/IDENTITY.md') return '- **Name:** Jarvis\n- **Emoji:** 🤖'
|
|
805
|
+
if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are'
|
|
806
|
+
if (p === '/tmp/ws/agents/jarvis/SOUL.md') return '# SOUL.md — Jarvis'
|
|
807
|
+
if (p === '/tmp/ws/agents/jarvis/sub-agents/SCRIBE.md') return '# SCRIBE — Documentation Writer'
|
|
808
|
+
if (p === '/tmp/ws/agents/vera/SOUL.md') return '# SOUL.md — VERA'
|
|
809
|
+
throw new Error('ENOENT')
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
const agents = await getAgents()
|
|
813
|
+
const root = agents.find(a => a.id === 'jarvis')!
|
|
814
|
+
expect(root.reportsTo).toBeNull()
|
|
815
|
+
expect(root.directReports).toContain('jarvis-scribe')
|
|
816
|
+
expect(root.directReports).toContain('vera')
|
|
817
|
+
|
|
818
|
+
const scribe = agents.find(a => a.id === 'jarvis-scribe')!
|
|
819
|
+
expect(scribe.name).toBe('SCRIBE')
|
|
820
|
+
expect(scribe.title).toBe('Documentation Writer')
|
|
821
|
+
expect(scribe.reportsTo).toBe('jarvis')
|
|
822
|
+
|
|
823
|
+
expect(agents.filter(a => a.id === 'jarvis')).toHaveLength(1)
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
it('ignores data directories without SOUL.md', async () => {
|
|
827
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
828
|
+
|
|
829
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
830
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
831
|
+
if (p === '/tmp/ws/SOUL.md') return false
|
|
832
|
+
if (p === '/tmp/ws/agents') return true
|
|
833
|
+
if (p === '/tmp/ws/agents/robin/SOUL.md') return true
|
|
834
|
+
if (p === '/tmp/ws/agents/robin/sub-agents') return false
|
|
835
|
+
if (p === '/tmp/ws/agents/robin/members') return false
|
|
836
|
+
return false
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
mockReaddirSync.mockImplementation((p: string) => {
|
|
840
|
+
if (String(p) === '/tmp/ws/agents') {
|
|
841
|
+
return [{ name: 'robin', isDirectory: () => true }]
|
|
842
|
+
}
|
|
843
|
+
if (String(p) === '/tmp/ws/agents/robin') {
|
|
844
|
+
return [
|
|
845
|
+
{ name: 'briefs', isDirectory: () => true },
|
|
846
|
+
{ name: 'state.json', isDirectory: () => false },
|
|
847
|
+
]
|
|
848
|
+
}
|
|
849
|
+
return []
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
853
|
+
if (p === '/tmp/ws/agents/robin/SOUL.md') return '# ROBIN — Field Intel Operator'
|
|
854
|
+
throw new Error('ENOENT')
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
const agents = await getAgents()
|
|
858
|
+
const robin = agents.find(a => a.id === 'robin')!
|
|
859
|
+
expect(robin.name).toBe('ROBIN')
|
|
860
|
+
expect(robin.directReports).toEqual([])
|
|
861
|
+
expect(agents.find(a => a.id === 'robin-briefs')).toBeUndefined()
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
it('discovers both flat sub-agents and subdirectory agents', async () => {
|
|
865
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
866
|
+
|
|
867
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
868
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
869
|
+
if (p === '/tmp/ws/SOUL.md') return false
|
|
870
|
+
if (p === '/tmp/ws/agents') return true
|
|
871
|
+
if (p === '/tmp/ws/agents/herald/SOUL.md') return true
|
|
872
|
+
if (p === '/tmp/ws/agents/herald') return true
|
|
873
|
+
if (p === '/tmp/ws/agents/herald/sub-agents') return true
|
|
874
|
+
if (p === '/tmp/ws/agents/herald/sub-agents/MAVEN.md') return true
|
|
875
|
+
if (p === '/tmp/ws/agents/herald/members') return false
|
|
876
|
+
if (p === '/tmp/ws/agents/herald/quill/SOUL.md') return true
|
|
877
|
+
return false
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
mockReaddirSync.mockImplementation((p: string) => {
|
|
881
|
+
if (String(p) === '/tmp/ws/agents') {
|
|
882
|
+
return [{ name: 'herald', isDirectory: () => true }]
|
|
883
|
+
}
|
|
884
|
+
if (String(p) === '/tmp/ws/agents/herald/sub-agents') {
|
|
885
|
+
return ['MAVEN.md']
|
|
886
|
+
}
|
|
887
|
+
if (String(p) === '/tmp/ws/agents/herald') {
|
|
888
|
+
return [
|
|
889
|
+
{ name: 'sub-agents', isDirectory: () => true },
|
|
890
|
+
{ name: 'quill', isDirectory: () => true },
|
|
891
|
+
]
|
|
892
|
+
}
|
|
893
|
+
return []
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
897
|
+
if (p === '/tmp/ws/agents/herald/SOUL.md') return '# SOUL.md — HERALD'
|
|
898
|
+
if (p === '/tmp/ws/agents/herald/sub-agents/MAVEN.md') return '# MAVEN — LinkedIn Strategist'
|
|
899
|
+
if (p === '/tmp/ws/agents/herald/quill/SOUL.md') return '# QUILL — Content Writer'
|
|
900
|
+
throw new Error('ENOENT')
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
const agents = await getAgents()
|
|
904
|
+
const herald = agents.find(a => a.id === 'herald')!
|
|
905
|
+
expect(herald.directReports).toContain('herald-maven')
|
|
906
|
+
expect(herald.directReports).toContain('herald-quill')
|
|
907
|
+
|
|
908
|
+
const maven = agents.find(a => a.id === 'herald-maven')!
|
|
909
|
+
expect(maven.name).toBe('MAVEN')
|
|
910
|
+
expect(maven.reportsTo).toBe('herald')
|
|
911
|
+
expect(maven.soulPath).toBeNull()
|
|
912
|
+
|
|
913
|
+
const quill = agents.find(a => a.id === 'herald-quill')!
|
|
914
|
+
expect(quill.name).toBe('QUILL')
|
|
915
|
+
expect(quill.reportsTo).toBe('herald')
|
|
916
|
+
expect(quill.soulPath).toBe('agents/herald/quill/SOUL.md')
|
|
917
|
+
})
|
|
708
918
|
})
|
|
709
919
|
|
|
710
920
|
// ---------------------------------------------------------------------------
|
|
@@ -855,3 +1065,346 @@ describe('getAgent', () => {
|
|
|
855
1065
|
expect(jarvis!.reportsTo).toBeNull()
|
|
856
1066
|
})
|
|
857
1067
|
})
|
|
1068
|
+
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
// CLI-based multi-workspace discovery (openclaw agents list --json)
|
|
1071
|
+
//
|
|
1072
|
+
// Real CLI output format (verified against live `openclaw agents list --json`):
|
|
1073
|
+
// [{ id, identityName, identityEmoji, identitySource,
|
|
1074
|
+
// workspace, agentDir, model, bindings, isDefault, routes }]
|
|
1075
|
+
//
|
|
1076
|
+
// Key insight: OpenClaw "agents" are isolated workspaces, not hierarchical
|
|
1077
|
+
// team members. Each has its own workspace path. ClawPort scans each
|
|
1078
|
+
// workspace for sub-agent hierarchies via discoverAgents().
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
describe('CLI agent discovery (multi-workspace)', () => {
|
|
1082
|
+
/**
|
|
1083
|
+
* Primary workspace at /tmp/ws with root Jarvis + echo agent.
|
|
1084
|
+
* OPENCLAW_BIN set so CLI calls are attempted.
|
|
1085
|
+
*/
|
|
1086
|
+
function setupPrimaryWorkspace() {
|
|
1087
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
1088
|
+
vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
|
|
1089
|
+
|
|
1090
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1091
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
1092
|
+
if (p === '/tmp/ws/SOUL.md') return true
|
|
1093
|
+
if (p === '/tmp/ws/IDENTITY.md') return true
|
|
1094
|
+
if (p === '/tmp/ws/agents') return true
|
|
1095
|
+
if (p === '/tmp/ws/agents/echo/SOUL.md') return true
|
|
1096
|
+
if (p === '/tmp/ws/agents/echo/sub-agents') return false
|
|
1097
|
+
if (p === '/tmp/ws/agents/echo/members') return false
|
|
1098
|
+
return false
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
mockReaddirSync.mockReturnValue([
|
|
1102
|
+
{ name: 'echo', isDirectory: () => true },
|
|
1103
|
+
])
|
|
1104
|
+
|
|
1105
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
1106
|
+
if (p === '/tmp/ws/IDENTITY.md') return '- **Name:** Jarvis\n- **Emoji:** 🤖'
|
|
1107
|
+
if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are'
|
|
1108
|
+
if (p === '/tmp/ws/agents/echo/SOUL.md') return '# ECHO — Community Voice Monitor'
|
|
1109
|
+
throw new Error('ENOENT')
|
|
1110
|
+
})
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** CLI output for a single agent (matches the primary workspace) */
|
|
1114
|
+
function cliOutput(entries: Array<{
|
|
1115
|
+
id: string
|
|
1116
|
+
identityName?: string
|
|
1117
|
+
identityEmoji?: string
|
|
1118
|
+
workspace?: string
|
|
1119
|
+
isDefault?: boolean
|
|
1120
|
+
}>) {
|
|
1121
|
+
return JSON.stringify(entries.map(e => ({
|
|
1122
|
+
id: e.id,
|
|
1123
|
+
identityName: e.identityName ?? null,
|
|
1124
|
+
identityEmoji: e.identityEmoji ?? null,
|
|
1125
|
+
identitySource: 'identity',
|
|
1126
|
+
workspace: e.workspace ?? '/tmp/ws',
|
|
1127
|
+
agentDir: `/home/.openclaw/agents/${e.id}/agent`,
|
|
1128
|
+
model: 'anthropic/claude-sonnet-4-6',
|
|
1129
|
+
bindings: 0,
|
|
1130
|
+
isDefault: e.isDefault ?? false,
|
|
1131
|
+
routes: ['default (no explicit rules)'],
|
|
1132
|
+
})))
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
it('skips CLI merge when only one agent exists (same workspace)', async () => {
|
|
1136
|
+
setupPrimaryWorkspace()
|
|
1137
|
+
|
|
1138
|
+
// CLI returns one agent pointing at the same workspace — no extra workspaces to scan
|
|
1139
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1140
|
+
{ id: 'main', identityName: 'Jarvis', identityEmoji: '🤖', workspace: '/tmp/ws', isDefault: true },
|
|
1141
|
+
]))
|
|
1142
|
+
|
|
1143
|
+
const agents = await getAgents()
|
|
1144
|
+
const ids = agents.map(a => a.id)
|
|
1145
|
+
expect(ids).toContain('jarvis')
|
|
1146
|
+
expect(ids).toContain('echo')
|
|
1147
|
+
// Should NOT have a duplicate 'main' entry since only 1 CLI agent (no merge triggered)
|
|
1148
|
+
})
|
|
1149
|
+
|
|
1150
|
+
it('discovers agents from a second workspace via CLI', async () => {
|
|
1151
|
+
// Primary workspace has Jarvis + echo
|
|
1152
|
+
// CLI shows a second agent "work" with a different workspace at /tmp/ws-work
|
|
1153
|
+
// That workspace has a SOUL.md + a "helper" agent
|
|
1154
|
+
setupPrimaryWorkspace()
|
|
1155
|
+
|
|
1156
|
+
// Extend existsSync to handle the second workspace
|
|
1157
|
+
const origExists = mockExistsSync.getMockImplementation()!
|
|
1158
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1159
|
+
// Second workspace paths
|
|
1160
|
+
if (p === '/tmp/ws-work/SOUL.md') return true
|
|
1161
|
+
if (p === '/tmp/ws-work/IDENTITY.md') return true
|
|
1162
|
+
if (p === '/tmp/ws-work/agents') return true
|
|
1163
|
+
if (p === '/tmp/ws-work/agents/helper/SOUL.md') return true
|
|
1164
|
+
if (p === '/tmp/ws-work/agents/helper/sub-agents') return false
|
|
1165
|
+
if (p === '/tmp/ws-work/agents/helper/members') return false
|
|
1166
|
+
if (p === '/tmp/ws-work/clawport/agents.json') return false
|
|
1167
|
+
return origExists(p)
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
// Extend readdirSync to handle the second workspace agents/ dir
|
|
1171
|
+
mockReaddirSync.mockImplementation((p: string) => {
|
|
1172
|
+
if (String(p) === '/tmp/ws-work/agents') {
|
|
1173
|
+
return [{ name: 'helper', isDirectory: () => true }]
|
|
1174
|
+
}
|
|
1175
|
+
return [{ name: 'echo', isDirectory: () => true }]
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
const origRead = mockReadFileSync.getMockImplementation()!
|
|
1179
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
1180
|
+
if (p === '/tmp/ws-work/IDENTITY.md') return '- **Name:** WorkBot\n- **Emoji:** 🔧'
|
|
1181
|
+
if (p === '/tmp/ws-work/SOUL.md') return '# SOUL.md - Who You Are'
|
|
1182
|
+
if (p === '/tmp/ws-work/agents/helper/SOUL.md') return '# HELPER — Task Assistant'
|
|
1183
|
+
return origRead(p)
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1187
|
+
{ id: 'main', identityName: 'Jarvis', identityEmoji: '🤖', workspace: '/tmp/ws', isDefault: true },
|
|
1188
|
+
{ id: 'work', identityName: 'WorkBot', identityEmoji: '🔧', workspace: '/tmp/ws-work' },
|
|
1189
|
+
]))
|
|
1190
|
+
|
|
1191
|
+
const agents = await getAgents()
|
|
1192
|
+
const ids = agents.map(a => a.id)
|
|
1193
|
+
// Primary workspace agents
|
|
1194
|
+
expect(ids).toContain('jarvis')
|
|
1195
|
+
expect(ids).toContain('echo')
|
|
1196
|
+
// Second workspace agents discovered via filesystem scan
|
|
1197
|
+
expect(ids).toContain('workbot') // root of second workspace (from IDENTITY.md)
|
|
1198
|
+
expect(ids).toContain('helper')
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
it('second workspace root becomes top-level peer (reportsTo=null)', async () => {
|
|
1202
|
+
setupPrimaryWorkspace()
|
|
1203
|
+
|
|
1204
|
+
const origExists = mockExistsSync.getMockImplementation()!
|
|
1205
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1206
|
+
if (p === '/tmp/ws2/SOUL.md') return true
|
|
1207
|
+
if (p === '/tmp/ws2/IDENTITY.md') return false
|
|
1208
|
+
if (p === '/tmp/ws2/agents') return false
|
|
1209
|
+
if (p === '/tmp/ws2/clawport/agents.json') return false
|
|
1210
|
+
return origExists(p)
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
const origRead = mockReadFileSync.getMockImplementation()!
|
|
1214
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
1215
|
+
if (p === '/tmp/ws2/SOUL.md') return '# SOUL.md — SecondBot'
|
|
1216
|
+
return origRead(p)
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1220
|
+
{ id: 'main', workspace: '/tmp/ws', isDefault: true },
|
|
1221
|
+
{ id: 'second', workspace: '/tmp/ws2' },
|
|
1222
|
+
]))
|
|
1223
|
+
|
|
1224
|
+
const agents = await getAgents()
|
|
1225
|
+
const secondBot = agents.find(a => a.id === 'secondbot')
|
|
1226
|
+
expect(secondBot).toBeDefined()
|
|
1227
|
+
expect(secondBot!.reportsTo).toBeNull() // independent, not under Jarvis
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
it('creates minimal entry when extra workspace has no discoverable agents', async () => {
|
|
1231
|
+
setupPrimaryWorkspace()
|
|
1232
|
+
|
|
1233
|
+
// Second workspace has nothing discoverable (no SOUL.md, no agents/ dir)
|
|
1234
|
+
const origExists = mockExistsSync.getMockImplementation()!
|
|
1235
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1236
|
+
if (p.startsWith('/tmp/ws-empty')) return false
|
|
1237
|
+
return origExists(p)
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1241
|
+
{ id: 'main', workspace: '/tmp/ws', isDefault: true },
|
|
1242
|
+
{ id: 'empty-bot', identityName: 'EmptyBot', identityEmoji: '🤷', workspace: '/tmp/ws-empty' },
|
|
1243
|
+
]))
|
|
1244
|
+
|
|
1245
|
+
const agents = await getAgents()
|
|
1246
|
+
const emptyBot = agents.find(a => a.id === 'empty-bot')
|
|
1247
|
+
expect(emptyBot).toBeDefined()
|
|
1248
|
+
expect(emptyBot!.name).toBe('EmptyBot')
|
|
1249
|
+
expect(emptyBot!.emoji).toBe('🤷')
|
|
1250
|
+
expect(emptyBot!.reportsTo).toBeNull()
|
|
1251
|
+
expect(emptyBot!.tools).toEqual(['read', 'write'])
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
it('does not duplicate agents already found in primary workspace', async () => {
|
|
1255
|
+
setupPrimaryWorkspace()
|
|
1256
|
+
|
|
1257
|
+
// CLI returns two agents both pointing at the SAME workspace
|
|
1258
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1259
|
+
{ id: 'main', workspace: '/tmp/ws', isDefault: true },
|
|
1260
|
+
{ id: 'alias', workspace: '/tmp/ws' }, // same workspace, should be skipped
|
|
1261
|
+
]))
|
|
1262
|
+
|
|
1263
|
+
const agents = await getAgents()
|
|
1264
|
+
// No extra agents added since both workspaces are the same
|
|
1265
|
+
const ids = agents.map(a => a.id)
|
|
1266
|
+
expect(ids).toContain('jarvis')
|
|
1267
|
+
expect(ids).toContain('echo')
|
|
1268
|
+
expect(ids).not.toContain('alias')
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
it('gracefully handles CLI failure (falls back to filesystem only)', async () => {
|
|
1272
|
+
setupPrimaryWorkspace()
|
|
1273
|
+
|
|
1274
|
+
mockExecSync.mockImplementation(() => {
|
|
1275
|
+
throw new Error('command not found: openclaw')
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
const agents = await getAgents()
|
|
1279
|
+
const ids = agents.map(a => a.id)
|
|
1280
|
+
expect(ids).toContain('jarvis')
|
|
1281
|
+
expect(ids).toContain('echo')
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
it('gracefully handles CLI returning invalid JSON', async () => {
|
|
1285
|
+
setupPrimaryWorkspace()
|
|
1286
|
+
mockExecSync.mockReturnValue('not valid json {{{}')
|
|
1287
|
+
|
|
1288
|
+
const agents = await getAgents()
|
|
1289
|
+
expect(agents.map(a => a.id)).toContain('jarvis')
|
|
1290
|
+
expect(agents.map(a => a.id)).toContain('echo')
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
it('gracefully handles CLI returning empty array', async () => {
|
|
1294
|
+
setupPrimaryWorkspace()
|
|
1295
|
+
mockExecSync.mockReturnValue('[]')
|
|
1296
|
+
|
|
1297
|
+
const agents = await getAgents()
|
|
1298
|
+
expect(agents.map(a => a.id)).toContain('jarvis')
|
|
1299
|
+
expect(agents.map(a => a.id)).toContain('echo')
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
it('gracefully handles non-array CLI output', async () => {
|
|
1303
|
+
setupPrimaryWorkspace()
|
|
1304
|
+
mockExecSync.mockReturnValue('{"status":"ok"}')
|
|
1305
|
+
|
|
1306
|
+
const agents = await getAgents()
|
|
1307
|
+
expect(agents.map(a => a.id)).toContain('jarvis')
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
it('does not call CLI when OPENCLAW_BIN is not set', async () => {
|
|
1311
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
1312
|
+
vi.stubEnv('OPENCLAW_BIN', '')
|
|
1313
|
+
|
|
1314
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1315
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
1316
|
+
if (p === '/tmp/ws/SOUL.md') return true
|
|
1317
|
+
if (p === '/tmp/ws/IDENTITY.md') return false
|
|
1318
|
+
if (p === '/tmp/ws/agents') return false
|
|
1319
|
+
return false
|
|
1320
|
+
})
|
|
1321
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
1322
|
+
if (p === '/tmp/ws/SOUL.md') return '# SOUL.md — Root'
|
|
1323
|
+
throw new Error('ENOENT')
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
await getAgents()
|
|
1327
|
+
expect(mockExecSync).not.toHaveBeenCalled()
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
it('falls back to bundled when CLI fails and no filesystem agents', async () => {
|
|
1331
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
1332
|
+
vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
|
|
1333
|
+
mockExistsSync.mockReturnValue(false)
|
|
1334
|
+
mockReaddirSync.mockReturnValue([])
|
|
1335
|
+
mockExecSync.mockImplementation(() => { throw new Error('timeout') })
|
|
1336
|
+
|
|
1337
|
+
const agents = await getAgents()
|
|
1338
|
+
expect(agents.length).toBe(bundledAgents.length)
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
it('CLI-only: scans each workspace when primary has no agents', async () => {
|
|
1342
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
1343
|
+
vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
|
|
1344
|
+
|
|
1345
|
+
// Primary workspace has nothing
|
|
1346
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1347
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
1348
|
+
if (p === '/tmp/ws/SOUL.md') return false
|
|
1349
|
+
if (p === '/tmp/ws/agents') return false
|
|
1350
|
+
// Remote workspace
|
|
1351
|
+
if (p === '/tmp/remote/SOUL.md') return true
|
|
1352
|
+
if (p === '/tmp/remote/IDENTITY.md') return false
|
|
1353
|
+
if (p === '/tmp/remote/agents') return false
|
|
1354
|
+
if (p === '/tmp/remote/clawport/agents.json') return false
|
|
1355
|
+
return false
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
1359
|
+
if (p === '/tmp/remote/SOUL.md') return '# SOUL.md — RemoteBot'
|
|
1360
|
+
throw new Error('ENOENT')
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1364
|
+
{ id: 'remote', identityName: 'RemoteBot', workspace: '/tmp/remote' },
|
|
1365
|
+
]))
|
|
1366
|
+
|
|
1367
|
+
const agents = await getAgents()
|
|
1368
|
+
expect(agents.map(a => a.id)).toContain('remotebot')
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
it('handles three workspaces with independent hierarchies', async () => {
|
|
1372
|
+
setupPrimaryWorkspace()
|
|
1373
|
+
|
|
1374
|
+
// Extend filesystem mocks for two extra workspaces
|
|
1375
|
+
const origExists = mockExistsSync.getMockImplementation()!
|
|
1376
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
1377
|
+
// Workspace B
|
|
1378
|
+
if (p === '/tmp/ws-b/SOUL.md') return true
|
|
1379
|
+
if (p === '/tmp/ws-b/IDENTITY.md') return false
|
|
1380
|
+
if (p === '/tmp/ws-b/agents') return false
|
|
1381
|
+
if (p === '/tmp/ws-b/clawport/agents.json') return false
|
|
1382
|
+
// Workspace C — empty
|
|
1383
|
+
if (p.startsWith('/tmp/ws-c')) return false
|
|
1384
|
+
return origExists(p)
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
const origRead = mockReadFileSync.getMockImplementation()!
|
|
1388
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
1389
|
+
if (p === '/tmp/ws-b/SOUL.md') return '# SOUL.md — BotB'
|
|
1390
|
+
return origRead(p)
|
|
1391
|
+
})
|
|
1392
|
+
|
|
1393
|
+
mockExecSync.mockReturnValue(cliOutput([
|
|
1394
|
+
{ id: 'main', workspace: '/tmp/ws', isDefault: true },
|
|
1395
|
+
{ id: 'b', identityName: 'BotB', workspace: '/tmp/ws-b' },
|
|
1396
|
+
{ id: 'c', identityName: 'BotC', identityEmoji: '🎯', workspace: '/tmp/ws-c' },
|
|
1397
|
+
]))
|
|
1398
|
+
|
|
1399
|
+
const agents = await getAgents()
|
|
1400
|
+
const ids = agents.map(a => a.id)
|
|
1401
|
+
expect(ids).toContain('jarvis') // primary root
|
|
1402
|
+
expect(ids).toContain('echo') // primary sub-agent
|
|
1403
|
+
expect(ids).toContain('botb') // workspace B root (from SOUL.md heading)
|
|
1404
|
+
expect(ids).toContain('c') // workspace C (minimal entry, no SOUL.md)
|
|
1405
|
+
|
|
1406
|
+
const botC = agents.find(a => a.id === 'c')!
|
|
1407
|
+
expect(botC.name).toBe('BotC')
|
|
1408
|
+
expect(botC.emoji).toBe('🎯')
|
|
1409
|
+
})
|
|
1410
|
+
})
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
3
|
+
import { buildTeams, type Team } from './teams'
|
|
4
|
+
import type { Agent } from './types'
|
|
5
|
+
|
|
6
|
+
/** Helper to create a minimal agent */
|
|
7
|
+
function agent(overrides: Partial<Agent> & { id: string }): Agent {
|
|
8
|
+
return {
|
|
9
|
+
name: overrides.id.toUpperCase(),
|
|
10
|
+
title: 'Agent',
|
|
11
|
+
reportsTo: null,
|
|
12
|
+
directReports: [],
|
|
13
|
+
soulPath: null,
|
|
14
|
+
soul: null,
|
|
15
|
+
voiceId: null,
|
|
16
|
+
color: '#000',
|
|
17
|
+
emoji: 'A',
|
|
18
|
+
tools: [],
|
|
19
|
+
crons: [],
|
|
20
|
+
memoryPath: null,
|
|
21
|
+
description: '',
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Basic team grouping
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('buildTeams', () => {
|
|
31
|
+
it('returns null root and empty arrays for empty input', () => {
|
|
32
|
+
const result = buildTeams([])
|
|
33
|
+
expect(result.root).toBeNull()
|
|
34
|
+
expect(result.teams).toEqual([])
|
|
35
|
+
expect(result.soloOps).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns null root when no agent has reportsTo=null', () => {
|
|
39
|
+
const agents = [
|
|
40
|
+
agent({ id: 'a', reportsTo: 'b' }),
|
|
41
|
+
agent({ id: 'b', reportsTo: 'a' }),
|
|
42
|
+
]
|
|
43
|
+
const result = buildTeams(agents)
|
|
44
|
+
expect(result.root).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('identifies root agent (reportsTo=null)', () => {
|
|
48
|
+
const agents = [
|
|
49
|
+
agent({ id: 'root', reportsTo: null, directReports: [] }),
|
|
50
|
+
]
|
|
51
|
+
const result = buildTeams(agents)
|
|
52
|
+
expect(result.root).not.toBeNull()
|
|
53
|
+
expect(result.root!.id).toBe('root')
|
|
54
|
+
expect(result.teams).toEqual([])
|
|
55
|
+
expect(result.soloOps).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('classifies direct reports with children as team managers', () => {
|
|
59
|
+
const agents = [
|
|
60
|
+
agent({ id: 'root', directReports: ['mgr'] }),
|
|
61
|
+
agent({ id: 'mgr', reportsTo: 'root', directReports: ['worker'] }),
|
|
62
|
+
agent({ id: 'worker', reportsTo: 'mgr' }),
|
|
63
|
+
]
|
|
64
|
+
const result = buildTeams(agents)
|
|
65
|
+
expect(result.teams).toHaveLength(1)
|
|
66
|
+
expect(result.teams[0].manager.id).toBe('mgr')
|
|
67
|
+
expect(result.teams[0].members).toHaveLength(1)
|
|
68
|
+
expect(result.teams[0].members[0].id).toBe('worker')
|
|
69
|
+
expect(result.soloOps).toHaveLength(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('classifies direct reports without children as solo ops', () => {
|
|
73
|
+
const agents = [
|
|
74
|
+
agent({ id: 'root', directReports: ['solo1', 'solo2'] }),
|
|
75
|
+
agent({ id: 'solo1', reportsTo: 'root' }),
|
|
76
|
+
agent({ id: 'solo2', reportsTo: 'root' }),
|
|
77
|
+
]
|
|
78
|
+
const result = buildTeams(agents)
|
|
79
|
+
expect(result.teams).toHaveLength(0)
|
|
80
|
+
expect(result.soloOps).toHaveLength(2)
|
|
81
|
+
expect(result.soloOps.map(a => a.id)).toContain('solo1')
|
|
82
|
+
expect(result.soloOps.map(a => a.id)).toContain('solo2')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('separates managers from solo ops correctly', () => {
|
|
86
|
+
const agents = [
|
|
87
|
+
agent({ id: 'root', directReports: ['mgr', 'solo'] }),
|
|
88
|
+
agent({ id: 'mgr', reportsTo: 'root', directReports: ['worker'] }),
|
|
89
|
+
agent({ id: 'worker', reportsTo: 'mgr' }),
|
|
90
|
+
agent({ id: 'solo', reportsTo: 'root' }),
|
|
91
|
+
]
|
|
92
|
+
const result = buildTeams(agents)
|
|
93
|
+
expect(result.teams).toHaveLength(1)
|
|
94
|
+
expect(result.teams[0].manager.id).toBe('mgr')
|
|
95
|
+
expect(result.soloOps).toHaveLength(1)
|
|
96
|
+
expect(result.soloOps[0].id).toBe('solo')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Deep hierarchy traversal
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe('buildTeams — deep hierarchy', () => {
|
|
105
|
+
it('collects all nested members via BFS', () => {
|
|
106
|
+
const agents = [
|
|
107
|
+
agent({ id: 'root', directReports: ['mgr'] }),
|
|
108
|
+
agent({ id: 'mgr', reportsTo: 'root', directReports: ['mid'] }),
|
|
109
|
+
agent({ id: 'mid', reportsTo: 'mgr', directReports: ['leaf'] }),
|
|
110
|
+
agent({ id: 'leaf', reportsTo: 'mid' }),
|
|
111
|
+
]
|
|
112
|
+
const result = buildTeams(agents)
|
|
113
|
+
expect(result.teams).toHaveLength(1)
|
|
114
|
+
const members = result.teams[0].members.map(m => m.id)
|
|
115
|
+
expect(members).toContain('mid')
|
|
116
|
+
expect(members).toContain('leaf')
|
|
117
|
+
expect(members).toHaveLength(2)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('handles wide teams (manager with many direct members)', () => {
|
|
121
|
+
const memberIds = Array.from({ length: 10 }, (_, i) => `m${i}`)
|
|
122
|
+
const agents = [
|
|
123
|
+
agent({ id: 'root', directReports: ['mgr'] }),
|
|
124
|
+
agent({ id: 'mgr', reportsTo: 'root', directReports: memberIds }),
|
|
125
|
+
...memberIds.map(id => agent({ id, reportsTo: 'mgr' })),
|
|
126
|
+
]
|
|
127
|
+
const result = buildTeams(agents)
|
|
128
|
+
expect(result.teams[0].members).toHaveLength(10)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('handles multiple teams with nested members', () => {
|
|
132
|
+
const agents = [
|
|
133
|
+
agent({ id: 'root', directReports: ['mgr-a', 'mgr-b'] }),
|
|
134
|
+
agent({ id: 'mgr-a', reportsTo: 'root', directReports: ['a1', 'a2'] }),
|
|
135
|
+
agent({ id: 'a1', reportsTo: 'mgr-a' }),
|
|
136
|
+
agent({ id: 'a2', reportsTo: 'mgr-a' }),
|
|
137
|
+
agent({ id: 'mgr-b', reportsTo: 'root', directReports: ['b1'] }),
|
|
138
|
+
agent({ id: 'b1', reportsTo: 'mgr-b', directReports: ['b1-sub'] }),
|
|
139
|
+
agent({ id: 'b1-sub', reportsTo: 'b1' }),
|
|
140
|
+
]
|
|
141
|
+
const result = buildTeams(agents)
|
|
142
|
+
expect(result.teams).toHaveLength(2)
|
|
143
|
+
|
|
144
|
+
const teamA = result.teams.find(t => t.manager.id === 'mgr-a')!
|
|
145
|
+
expect(teamA.members.map(m => m.id)).toEqual(['a1', 'a2'])
|
|
146
|
+
|
|
147
|
+
const teamB = result.teams.find(t => t.manager.id === 'mgr-b')!
|
|
148
|
+
expect(teamB.members.map(m => m.id)).toContain('b1')
|
|
149
|
+
expect(teamB.members.map(m => m.id)).toContain('b1-sub')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Cycle protection
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('buildTeams — cycle protection', () => {
|
|
158
|
+
it('does not infinite loop on self-referencing agent', () => {
|
|
159
|
+
const agents = [
|
|
160
|
+
agent({ id: 'root', directReports: ['mgr'] }),
|
|
161
|
+
agent({ id: 'mgr', reportsTo: 'root', directReports: ['mgr'] }), // self-ref
|
|
162
|
+
]
|
|
163
|
+
const result = buildTeams(agents)
|
|
164
|
+
expect(result.teams).toHaveLength(1)
|
|
165
|
+
// mgr references itself, but visited set prevents infinite loop
|
|
166
|
+
// mgr is already the manager so it's in visited — members should be empty
|
|
167
|
+
expect(result.teams[0].members).toHaveLength(0)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not infinite loop on circular directReports', () => {
|
|
171
|
+
const agents = [
|
|
172
|
+
agent({ id: 'root', directReports: ['a'] }),
|
|
173
|
+
agent({ id: 'a', reportsTo: 'root', directReports: ['b'] }),
|
|
174
|
+
agent({ id: 'b', reportsTo: 'a', directReports: ['a'] }), // cycle back to a
|
|
175
|
+
]
|
|
176
|
+
const result = buildTeams(agents)
|
|
177
|
+
expect(result.teams).toHaveLength(1)
|
|
178
|
+
const members = result.teams[0].members.map(m => m.id)
|
|
179
|
+
expect(members).toContain('b')
|
|
180
|
+
// 'a' should NOT appear as a member (it's the manager, and visited prevents re-add)
|
|
181
|
+
expect(members).not.toContain('a')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('handles mutual cycle between two agents', () => {
|
|
185
|
+
const agents = [
|
|
186
|
+
agent({ id: 'root', directReports: ['x'] }),
|
|
187
|
+
agent({ id: 'x', reportsTo: 'root', directReports: ['y'] }),
|
|
188
|
+
agent({ id: 'y', reportsTo: 'x', directReports: ['x'] }),
|
|
189
|
+
]
|
|
190
|
+
// Should not throw or hang
|
|
191
|
+
const result = buildTeams(agents)
|
|
192
|
+
expect(result.teams).toHaveLength(1)
|
|
193
|
+
expect(result.teams[0].members).toHaveLength(1)
|
|
194
|
+
expect(result.teams[0].members[0].id).toBe('y')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('handles longer cycle (a → b → c → a)', () => {
|
|
198
|
+
const agents = [
|
|
199
|
+
agent({ id: 'root', directReports: ['a'] }),
|
|
200
|
+
agent({ id: 'a', reportsTo: 'root', directReports: ['b'] }),
|
|
201
|
+
agent({ id: 'b', reportsTo: 'a', directReports: ['c'] }),
|
|
202
|
+
agent({ id: 'c', reportsTo: 'b', directReports: ['a'] }),
|
|
203
|
+
]
|
|
204
|
+
const result = buildTeams(agents)
|
|
205
|
+
expect(result.teams).toHaveLength(1)
|
|
206
|
+
const memberIds = result.teams[0].members.map(m => m.id)
|
|
207
|
+
expect(memberIds).toContain('b')
|
|
208
|
+
expect(memberIds).toContain('c')
|
|
209
|
+
// 'a' is the manager, should not be in members even with cycle
|
|
210
|
+
expect(memberIds).not.toContain('a')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Edge cases
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe('buildTeams — edge cases', () => {
|
|
219
|
+
it('skips directReports that reference nonexistent agent IDs', () => {
|
|
220
|
+
const agents = [
|
|
221
|
+
agent({ id: 'root', directReports: ['real', 'ghost'] }),
|
|
222
|
+
agent({ id: 'real', reportsTo: 'root' }),
|
|
223
|
+
// 'ghost' does not exist in agents array
|
|
224
|
+
]
|
|
225
|
+
const result = buildTeams(agents)
|
|
226
|
+
expect(result.soloOps).toHaveLength(1)
|
|
227
|
+
expect(result.soloOps[0].id).toBe('real')
|
|
228
|
+
// ghost is silently skipped
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('manager with only nonexistent children is classified as solo op', () => {
|
|
232
|
+
const agents = [
|
|
233
|
+
agent({ id: 'root', directReports: ['mgr'] }),
|
|
234
|
+
agent({ id: 'mgr', reportsTo: 'root', directReports: ['ghost1', 'ghost2'] }),
|
|
235
|
+
]
|
|
236
|
+
// mgr has directReports.length > 0 so it's treated as a manager
|
|
237
|
+
const result = buildTeams(agents)
|
|
238
|
+
expect(result.teams).toHaveLength(1)
|
|
239
|
+
// But the team has no real members (ghosts not found in byId map)
|
|
240
|
+
expect(result.teams[0].members).toHaveLength(0)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('root with no directReports results in empty teams and soloOps', () => {
|
|
244
|
+
const agents = [agent({ id: 'root' })]
|
|
245
|
+
const result = buildTeams(agents)
|
|
246
|
+
expect(result.root!.id).toBe('root')
|
|
247
|
+
expect(result.teams).toHaveLength(0)
|
|
248
|
+
expect(result.soloOps).toHaveLength(0)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('uses first agent with reportsTo=null when multiple roots exist', () => {
|
|
252
|
+
const agents = [
|
|
253
|
+
agent({ id: 'root1' }),
|
|
254
|
+
agent({ id: 'root2' }),
|
|
255
|
+
]
|
|
256
|
+
const result = buildTeams(agents)
|
|
257
|
+
// Array.find returns the first match
|
|
258
|
+
expect(result.root!.id).toBe('root1')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('handles the full bundled agents.json structure', () => {
|
|
262
|
+
// Simulate the real Jarvis org: root + 3 managers + 6 solo ops
|
|
263
|
+
const agents = [
|
|
264
|
+
agent({ id: 'jarvis', directReports: ['vera', 'lumen', 'herald', 'pulse', 'echo', 'sage'] }),
|
|
265
|
+
agent({ id: 'vera', reportsTo: 'jarvis', directReports: ['robin'] }),
|
|
266
|
+
agent({ id: 'robin', reportsTo: 'vera', directReports: ['trace', 'proof'] }),
|
|
267
|
+
agent({ id: 'trace', reportsTo: 'robin' }),
|
|
268
|
+
agent({ id: 'proof', reportsTo: 'robin' }),
|
|
269
|
+
agent({ id: 'lumen', reportsTo: 'jarvis', directReports: ['scout', 'writer'] }),
|
|
270
|
+
agent({ id: 'scout', reportsTo: 'lumen' }),
|
|
271
|
+
agent({ id: 'writer', reportsTo: 'lumen' }),
|
|
272
|
+
agent({ id: 'herald', reportsTo: 'jarvis', directReports: ['quill'] }),
|
|
273
|
+
agent({ id: 'quill', reportsTo: 'herald' }),
|
|
274
|
+
agent({ id: 'pulse', reportsTo: 'jarvis' }),
|
|
275
|
+
agent({ id: 'echo', reportsTo: 'jarvis' }),
|
|
276
|
+
agent({ id: 'sage', reportsTo: 'jarvis' }),
|
|
277
|
+
]
|
|
278
|
+
const result = buildTeams(agents)
|
|
279
|
+
|
|
280
|
+
expect(result.root!.id).toBe('jarvis')
|
|
281
|
+
expect(result.teams).toHaveLength(3)
|
|
282
|
+
expect(result.soloOps).toHaveLength(3)
|
|
283
|
+
|
|
284
|
+
// Team VERA includes robin, trace, proof (nested)
|
|
285
|
+
const teamVera = result.teams.find(t => t.manager.id === 'vera')!
|
|
286
|
+
expect(teamVera.members.map(m => m.id)).toEqual(
|
|
287
|
+
expect.arrayContaining(['robin', 'trace', 'proof']),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
// Team LUMEN includes scout, writer
|
|
291
|
+
const teamLumen = result.teams.find(t => t.manager.id === 'lumen')!
|
|
292
|
+
expect(teamLumen.members.map(m => m.id)).toEqual(
|
|
293
|
+
expect.arrayContaining(['scout', 'writer']),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
// Team HERALD includes quill
|
|
297
|
+
const teamHerald = result.teams.find(t => t.manager.id === 'herald')!
|
|
298
|
+
expect(teamHerald.members.map(m => m.id)).toEqual(['quill'])
|
|
299
|
+
|
|
300
|
+
// Solo ops
|
|
301
|
+
expect(result.soloOps.map(a => a.id)).toEqual(
|
|
302
|
+
expect.arrayContaining(['pulse', 'echo', 'sage']),
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
})
|