clawport-ui 0.5.0 → 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 +225 -8
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'fs'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
2
3
|
import { join, basename } from 'path'
|
|
3
4
|
import bundledRegistry from '@/lib/agents.json'
|
|
4
5
|
import type { Agent } from '@/lib/types'
|
|
@@ -110,6 +111,25 @@ function scanSubAgentDir(dirPath: string): { fileName: string; content: string }
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
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
|
+
|
|
113
133
|
// ---------------------------------------------------------------------------
|
|
114
134
|
// Auto-discovery
|
|
115
135
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +166,7 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
146
166
|
|
|
147
167
|
const discovered: AgentEntry[] = []
|
|
148
168
|
let colorIndex = 0
|
|
169
|
+
const directReportIds: string[] = []
|
|
149
170
|
|
|
150
171
|
// --- Root agent ---
|
|
151
172
|
let rootId = 'main'
|
|
@@ -176,9 +197,6 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
176
197
|
}
|
|
177
198
|
}
|
|
178
199
|
|
|
179
|
-
// Collect all agent IDs that report to root (including those without SOUL.md but with sub-agents)
|
|
180
|
-
const directReportIds: string[] = []
|
|
181
|
-
|
|
182
200
|
discovered.push({
|
|
183
201
|
id: rootId,
|
|
184
202
|
name: rootName,
|
|
@@ -196,6 +214,7 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
196
214
|
|
|
197
215
|
// We'll fill directReportIds as we discover agents below
|
|
198
216
|
for (const dirName of allAgentDirs) {
|
|
217
|
+
if (dirName === rootId) continue // root-matching dir scanned in main loop
|
|
199
218
|
const hasSoul = existsSync(join(agentsDir, dirName, 'SOUL.md'))
|
|
200
219
|
const hasSubAgents = existsSync(join(agentsDir, dirName, 'sub-agents'))
|
|
201
220
|
const hasMembers = existsSync(join(agentsDir, dirName, 'members'))
|
|
@@ -207,8 +226,61 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
207
226
|
|
|
208
227
|
// --- Top-level agents ---
|
|
209
228
|
for (const dirName of allAgentDirs) {
|
|
210
|
-
//
|
|
211
|
-
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
|
+
}
|
|
212
284
|
|
|
213
285
|
const soulFile = join(agentsDir, dirName, 'SOUL.md')
|
|
214
286
|
const hasSoul = existsSync(soulFile)
|
|
@@ -276,6 +348,32 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
276
348
|
})
|
|
277
349
|
}
|
|
278
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
|
+
|
|
279
377
|
discovered.push({
|
|
280
378
|
id: dirName,
|
|
281
379
|
name,
|
|
@@ -295,16 +393,118 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
295
393
|
return discovered.length > 0 ? discovered : null
|
|
296
394
|
}
|
|
297
395
|
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// CLI-based multi-workspace discovery (openclaw agents list)
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
/**
|
|
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).
|
|
418
|
+
*/
|
|
419
|
+
export function listCliAgents(openclawBin: string): CliAgentEntry[] | null {
|
|
420
|
+
try {
|
|
421
|
+
const raw = execSync(`${openclawBin} agents list --json`, {
|
|
422
|
+
encoding: 'utf-8',
|
|
423
|
+
timeout: 10000,
|
|
424
|
+
})
|
|
425
|
+
const parsed = JSON.parse(raw)
|
|
426
|
+
const agents: unknown[] = Array.isArray(parsed) ? parsed : []
|
|
427
|
+
if (agents.length === 0) return null
|
|
428
|
+
return agents as CliAgentEntry[]
|
|
429
|
+
} catch {
|
|
430
|
+
return null
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
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.
|
|
442
|
+
*/
|
|
443
|
+
function mergeExtraWorkspaces(
|
|
444
|
+
existing: AgentEntry[],
|
|
445
|
+
cliAgents: CliAgentEntry[],
|
|
446
|
+
primaryWorkspace: string,
|
|
447
|
+
): AgentEntry[] {
|
|
448
|
+
const existingIds = new Set(existing.map(a => a.id))
|
|
449
|
+
const added: AgentEntry[] = []
|
|
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)
|
|
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)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return [...existing, ...added]
|
|
493
|
+
}
|
|
494
|
+
|
|
298
495
|
/**
|
|
299
496
|
* Load the agent registry.
|
|
300
497
|
*
|
|
301
498
|
* Resolution order:
|
|
302
499
|
* 1. $WORKSPACE_PATH/clawport/agents.json (user's own config)
|
|
303
500
|
* 2. Auto-discovered from $WORKSPACE_PATH (agents/ directory scan)
|
|
304
|
-
*
|
|
501
|
+
* + merged with other workspaces from `openclaw agents list --json`
|
|
502
|
+
* 3. CLI-only discovery (scans each agent's workspace)
|
|
503
|
+
* 4. Bundled lib/agents.json (default example registry)
|
|
305
504
|
*/
|
|
306
505
|
export function loadRegistry(): AgentEntry[] {
|
|
307
506
|
const workspacePath = process.env.WORKSPACE_PATH
|
|
507
|
+
const openclawBin = process.env.OPENCLAW_BIN
|
|
308
508
|
|
|
309
509
|
if (workspacePath) {
|
|
310
510
|
// 1. User-provided override
|
|
@@ -318,11 +518,28 @@ export function loadRegistry(): AgentEntry[] {
|
|
|
318
518
|
}
|
|
319
519
|
}
|
|
320
520
|
|
|
321
|
-
// 2. Auto-discover from workspace
|
|
521
|
+
// 2. Auto-discover from primary workspace filesystem
|
|
322
522
|
const discovered = discoverAgents(workspacePath)
|
|
523
|
+
|
|
524
|
+
// 2b. Merge agents from other workspaces known to the CLI
|
|
525
|
+
if (discovered && openclawBin) {
|
|
526
|
+
const cliAgents = listCliAgents(openclawBin)
|
|
527
|
+
if (cliAgents && cliAgents.length > 1) {
|
|
528
|
+
return mergeExtraWorkspaces(discovered, cliAgents, workspacePath)
|
|
529
|
+
}
|
|
530
|
+
return discovered
|
|
531
|
+
}
|
|
323
532
|
if (discovered) return discovered
|
|
533
|
+
|
|
534
|
+
// 3. CLI-only: no primary workspace agents, scan each CLI agent's workspace
|
|
535
|
+
if (openclawBin) {
|
|
536
|
+
const cliAgents = listCliAgents(openclawBin)
|
|
537
|
+
if (cliAgents) {
|
|
538
|
+
return mergeExtraWorkspaces([], cliAgents, '')
|
|
539
|
+
}
|
|
540
|
+
}
|
|
324
541
|
}
|
|
325
542
|
|
|
326
|
-
//
|
|
543
|
+
// 4. Bundled fallback
|
|
327
544
|
return bundledRegistry as AgentEntry[]
|
|
328
545
|
}
|
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
|
+
})
|