clawport-ui 0.4.4 → 0.4.6

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.
@@ -278,10 +278,11 @@ export default function AgentDetailPage({
278
278
  return r.json()
279
279
  }),
280
280
  ])
281
- .then(([agents, c]) => {
281
+ .then(([agents, cronData]) => {
282
+ const cronList: CronJob[] = Array.isArray(cronData) ? cronData : cronData.crons ?? []
282
283
  setAllAgents(agents)
283
284
  setAgent(agents.find((a: Agent) => a.id === id) || null)
284
- setCrons(c.filter((cr: CronJob) => cr.agentId === id))
285
+ setCrons(cronList.filter((cr: CronJob) => cr.agentId === id))
285
286
  })
286
287
  .catch((e) => setError(e.message))
287
288
  .finally(() => setLoading(false))
package/app/page.tsx CHANGED
@@ -138,9 +138,9 @@ export default function HomePage() {
138
138
  return r.json()
139
139
  }),
140
140
  ])
141
- .then(([a, c]) => {
141
+ .then(([a, cronData]) => {
142
142
  setAgents(a)
143
- setCrons(c)
143
+ setCrons(Array.isArray(cronData) ? cronData : cronData.crons ?? [])
144
144
  })
145
145
  .catch((e) => setError(e.message))
146
146
  .finally(() => setLoading(false))
@@ -166,7 +166,7 @@ export function GlobalSearch() {
166
166
  return r.json();
167
167
  })
168
168
  .then((data: unknown) => {
169
- if (Array.isArray(data)) setCrons(data as CronJob[]);
169
+ setCrons(Array.isArray(data) ? data as CronJob[] : (data as { crons?: CronJob[] })?.crons ?? []);
170
170
  })
171
171
  .catch(() => setCrons([]));
172
172
  }, [open]);
@@ -72,11 +72,11 @@ export function NavLinks() {
72
72
  return r.json();
73
73
  })
74
74
  .then((data: unknown) => {
75
- if (Array.isArray(data)) {
76
- const crons = data as CronJob[];
77
- setCronCount(crons.length);
78
- setCronErrorCount(crons.filter((c) => c.status === 'error').length);
79
- }
75
+ const crons: CronJob[] = Array.isArray(data)
76
+ ? data
77
+ : (data as { crons?: CronJob[] })?.crons ?? [];
78
+ setCronCount(crons.length);
79
+ setCronErrorCount(crons.filter((c) => c.status === 'error').length);
80
80
  })
81
81
  .catch(() => {
82
82
  setCronErrorCount(null);
@@ -0,0 +1,416 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Tests for setup detection helpers.
4
+ *
5
+ * These simulate the detection logic that runs during `npm run setup`
6
+ * and `clawport doctor`, covering:
7
+ * - Fresh user: nothing installed
8
+ * - Partial user: OpenClaw installed but not fully configured
9
+ * - Existing user: everything configured and running
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
13
+ import { homedir } from 'os'
14
+ import { join } from 'path'
15
+
16
+ // ── Computed paths (using real homedir) ───────────────────────────
17
+
18
+ const HOME = homedir()
19
+ const OPENCLAW_DIR = join(HOME, '.openclaw')
20
+ const WORKSPACE_PATH = join(OPENCLAW_DIR, 'workspace')
21
+ const CONFIG_PATH = join(OPENCLAW_DIR, 'openclaw.json')
22
+
23
+ // ── Hoisted mocks ─────────────────────────────────────────────────
24
+
25
+ const { mockExistsSync, mockReadFileSync, mockWriteFileSync } = vi.hoisted(() => ({
26
+ mockExistsSync: vi.fn(),
27
+ mockReadFileSync: vi.fn(),
28
+ mockWriteFileSync: vi.fn(),
29
+ }))
30
+
31
+ const { mockExecSync } = vi.hoisted(() => ({
32
+ mockExecSync: vi.fn(),
33
+ }))
34
+
35
+ vi.mock('fs', () => ({
36
+ existsSync: mockExistsSync,
37
+ readFileSync: mockReadFileSync,
38
+ writeFileSync: mockWriteFileSync,
39
+ default: {
40
+ existsSync: mockExistsSync,
41
+ readFileSync: mockReadFileSync,
42
+ writeFileSync: mockWriteFileSync,
43
+ },
44
+ }))
45
+
46
+ vi.mock('child_process', () => ({
47
+ execSync: mockExecSync,
48
+ default: { execSync: mockExecSync },
49
+ }))
50
+
51
+ // ── Imports (after mocks) ─────────────────────────────────────────
52
+
53
+ import {
54
+ detectWorkspacePath,
55
+ detectOpenClawBin,
56
+ detectGatewayToken,
57
+ checkHttpEndpointEnabled,
58
+ enableHttpEndpoint,
59
+ detectAll,
60
+ } from './setup-detection'
61
+
62
+ // ═══════════════════════════════════════════════════════════════════
63
+ // detectWorkspacePath
64
+ // ═══════════════════════════════════════════════════════════════════
65
+
66
+ describe('detectWorkspacePath', () => {
67
+ beforeEach(() => {
68
+ vi.clearAllMocks()
69
+ mockExistsSync.mockReturnValue(false)
70
+ })
71
+
72
+ it('returns null when ~/.openclaw/workspace does not exist', () => {
73
+ expect(detectWorkspacePath()).toBeNull()
74
+ expect(mockExistsSync).toHaveBeenCalledWith(WORKSPACE_PATH)
75
+ })
76
+
77
+ it('returns path when ~/.openclaw/workspace exists', () => {
78
+ mockExistsSync.mockImplementation((p: string) => p === WORKSPACE_PATH)
79
+ expect(detectWorkspacePath()).toBe(WORKSPACE_PATH)
80
+ })
81
+ })
82
+
83
+ // ═══════════════════════════════════════════════════════════════════
84
+ // detectOpenClawBin
85
+ // ═══════════════════════════════════════════════════════════════════
86
+
87
+ describe('detectOpenClawBin', () => {
88
+ beforeEach(() => {
89
+ vi.clearAllMocks()
90
+ })
91
+
92
+ it('returns null when openclaw is not on PATH', () => {
93
+ mockExecSync.mockImplementation(() => {
94
+ throw new Error('not found')
95
+ })
96
+ expect(detectOpenClawBin()).toBeNull()
97
+ })
98
+
99
+ it('returns binary path when openclaw is found', () => {
100
+ mockExecSync.mockReturnValue('/usr/local/bin/openclaw\n')
101
+ expect(detectOpenClawBin()).toBe('/usr/local/bin/openclaw')
102
+ })
103
+
104
+ it('trims whitespace from result', () => {
105
+ mockExecSync.mockReturnValue(' /opt/openclaw/bin/openclaw \n')
106
+ expect(detectOpenClawBin()).toBe('/opt/openclaw/bin/openclaw')
107
+ })
108
+
109
+ it('uses "which" on non-Windows platforms', () => {
110
+ mockExecSync.mockReturnValue('/usr/local/bin/openclaw')
111
+ detectOpenClawBin()
112
+ // On macOS/Linux, should use "which"
113
+ if (process.platform !== 'win32') {
114
+ expect(mockExecSync).toHaveBeenCalledWith(
115
+ 'which openclaw',
116
+ expect.objectContaining({ encoding: 'utf-8' })
117
+ )
118
+ }
119
+ })
120
+ })
121
+
122
+ // ═══════════════════════════════════════════════════════════════════
123
+ // detectGatewayToken
124
+ // ═══════════════════════════════════════════════════════════════════
125
+
126
+ describe('detectGatewayToken', () => {
127
+ beforeEach(() => {
128
+ vi.clearAllMocks()
129
+ mockExistsSync.mockReturnValue(false)
130
+ })
131
+
132
+ it('returns null when openclaw.json does not exist', () => {
133
+ expect(detectGatewayToken()).toBeNull()
134
+ })
135
+
136
+ it('returns token from openclaw.json', () => {
137
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
138
+ mockReadFileSync.mockReturnValue(JSON.stringify({
139
+ gateway: {
140
+ auth: {
141
+ token: 'oc_tok_abc123xyz789',
142
+ },
143
+ },
144
+ }))
145
+
146
+ expect(detectGatewayToken()).toBe('oc_tok_abc123xyz789')
147
+ })
148
+
149
+ it('returns null when gateway.auth.token is missing', () => {
150
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
151
+ mockReadFileSync.mockReturnValue(JSON.stringify({
152
+ gateway: {
153
+ http: { port: 18789 },
154
+ },
155
+ }))
156
+
157
+ expect(detectGatewayToken()).toBeNull()
158
+ })
159
+
160
+ it('returns null when token is not a string', () => {
161
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
162
+ mockReadFileSync.mockReturnValue(JSON.stringify({
163
+ gateway: {
164
+ auth: { token: 12345 },
165
+ },
166
+ }))
167
+
168
+ expect(detectGatewayToken()).toBeNull()
169
+ })
170
+
171
+ it('returns null when openclaw.json is malformed', () => {
172
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
173
+ mockReadFileSync.mockReturnValue('not json')
174
+
175
+ expect(detectGatewayToken()).toBeNull()
176
+ })
177
+
178
+ it('returns null when readFileSync throws', () => {
179
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
180
+ mockReadFileSync.mockImplementation(() => {
181
+ throw new Error('EACCES')
182
+ })
183
+
184
+ expect(detectGatewayToken()).toBeNull()
185
+ })
186
+ })
187
+
188
+ // ═══════════════════════════════════════════════════════════════════
189
+ // checkHttpEndpointEnabled
190
+ // ═══════════════════════════════════════════════════════════════════
191
+
192
+ describe('checkHttpEndpointEnabled', () => {
193
+ beforeEach(() => {
194
+ vi.clearAllMocks()
195
+ mockExistsSync.mockReturnValue(false)
196
+ })
197
+
198
+ it('returns null when openclaw.json does not exist', () => {
199
+ expect(checkHttpEndpointEnabled()).toBeNull()
200
+ })
201
+
202
+ it('returns true when chatCompletions endpoint is enabled', () => {
203
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
204
+ mockReadFileSync.mockReturnValue(JSON.stringify({
205
+ gateway: {
206
+ http: {
207
+ endpoints: {
208
+ chatCompletions: { enabled: true },
209
+ },
210
+ },
211
+ },
212
+ }))
213
+
214
+ expect(checkHttpEndpointEnabled()).toBe(true)
215
+ })
216
+
217
+ it('returns false when chatCompletions endpoint is disabled', () => {
218
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
219
+ mockReadFileSync.mockReturnValue(JSON.stringify({
220
+ gateway: {
221
+ http: {
222
+ endpoints: {
223
+ chatCompletions: { enabled: false },
224
+ },
225
+ },
226
+ },
227
+ }))
228
+
229
+ expect(checkHttpEndpointEnabled()).toBe(false)
230
+ })
231
+
232
+ it('returns false when endpoints key is missing', () => {
233
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
234
+ mockReadFileSync.mockReturnValue(JSON.stringify({
235
+ gateway: { http: {} },
236
+ }))
237
+
238
+ expect(checkHttpEndpointEnabled()).toBe(false)
239
+ })
240
+
241
+ it('returns false when chatCompletions key is missing', () => {
242
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
243
+ mockReadFileSync.mockReturnValue(JSON.stringify({
244
+ gateway: {
245
+ http: { endpoints: {} },
246
+ },
247
+ }))
248
+
249
+ expect(checkHttpEndpointEnabled()).toBe(false)
250
+ })
251
+
252
+ it('returns null for malformed JSON', () => {
253
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
254
+ mockReadFileSync.mockReturnValue('{broken}')
255
+
256
+ expect(checkHttpEndpointEnabled()).toBeNull()
257
+ })
258
+ })
259
+
260
+ // ═══════════════════════════════════════════════════════════════════
261
+ // enableHttpEndpoint
262
+ // ═══════════════════════════════════════════════════════════════════
263
+
264
+ describe('enableHttpEndpoint', () => {
265
+ beforeEach(() => {
266
+ vi.clearAllMocks()
267
+ mockExistsSync.mockReturnValue(false)
268
+ })
269
+
270
+ it('returns false when openclaw.json does not exist', () => {
271
+ expect(enableHttpEndpoint()).toBe(false)
272
+ })
273
+
274
+ it('enables the endpoint and writes back to file', () => {
275
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
276
+ mockReadFileSync.mockReturnValue(JSON.stringify({
277
+ gateway: { auth: { token: 'test' } },
278
+ }))
279
+
280
+ const result = enableHttpEndpoint()
281
+
282
+ expect(result).toBe(true)
283
+ expect(mockWriteFileSync).toHaveBeenCalledTimes(1)
284
+
285
+ // Verify the written content has the endpoint enabled
286
+ const writtenContent = mockWriteFileSync.mock.calls[0][1] as string
287
+ const parsed = JSON.parse(writtenContent.replace(/\n$/, ''))
288
+ expect(parsed.gateway.http.endpoints.chatCompletions.enabled).toBe(true)
289
+ // Preserves existing config
290
+ expect(parsed.gateway.auth.token).toBe('test')
291
+ })
292
+
293
+ it('creates nested structure when gateway.http is missing', () => {
294
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
295
+ mockReadFileSync.mockReturnValue(JSON.stringify({ gateway: {} }))
296
+
297
+ const result = enableHttpEndpoint()
298
+ expect(result).toBe(true)
299
+
300
+ const writtenContent = mockWriteFileSync.mock.calls[0][1] as string
301
+ const parsed = JSON.parse(writtenContent.replace(/\n$/, ''))
302
+ expect(parsed.gateway.http.endpoints.chatCompletions.enabled).toBe(true)
303
+ })
304
+
305
+ it('creates gateway key when missing entirely', () => {
306
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
307
+ mockReadFileSync.mockReturnValue(JSON.stringify({ version: 1 }))
308
+
309
+ const result = enableHttpEndpoint()
310
+ expect(result).toBe(true)
311
+
312
+ const writtenContent = mockWriteFileSync.mock.calls[0][1] as string
313
+ const parsed = JSON.parse(writtenContent.replace(/\n$/, ''))
314
+ expect(parsed.version).toBe(1) // preserves existing
315
+ expect(parsed.gateway.http.endpoints.chatCompletions.enabled).toBe(true)
316
+ })
317
+
318
+ it('returns false when writeFileSync throws', () => {
319
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
320
+ mockReadFileSync.mockReturnValue(JSON.stringify({}))
321
+ mockWriteFileSync.mockImplementation(() => {
322
+ throw new Error('EACCES')
323
+ })
324
+
325
+ expect(enableHttpEndpoint()).toBe(false)
326
+ })
327
+
328
+ it('returns false when readFileSync throws', () => {
329
+ mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
330
+ mockReadFileSync.mockImplementation(() => {
331
+ throw new Error('EACCES')
332
+ })
333
+
334
+ expect(enableHttpEndpoint()).toBe(false)
335
+ })
336
+ })
337
+
338
+ // ═══════════════════════════════════════════════════════════════════
339
+ // detectAll — integration
340
+ // ═══════════════════════════════════════════════════════════════════
341
+
342
+ describe('detectAll', () => {
343
+ beforeEach(() => {
344
+ vi.clearAllMocks()
345
+ mockExistsSync.mockReturnValue(false)
346
+ mockExecSync.mockImplementation(() => {
347
+ throw new Error('not found')
348
+ })
349
+ })
350
+
351
+ it('returns all nulls for a completely fresh system', () => {
352
+ const result = detectAll()
353
+ expect(result.workspacePath).toBeNull()
354
+ expect(result.openclawBin).toBeNull()
355
+ expect(result.gatewayToken).toBeNull()
356
+ expect(result.httpEndpointEnabled).toBeNull()
357
+ })
358
+
359
+ it('returns all values for a fully configured system', () => {
360
+ mockExistsSync.mockImplementation((p: string) => {
361
+ if (p === WORKSPACE_PATH) return true
362
+ if (p === CONFIG_PATH) return true
363
+ return false
364
+ })
365
+ mockExecSync.mockReturnValue('/usr/local/bin/openclaw')
366
+ mockReadFileSync.mockReturnValue(JSON.stringify({
367
+ gateway: {
368
+ auth: { token: 'oc_tok_test123' },
369
+ http: {
370
+ endpoints: {
371
+ chatCompletions: { enabled: true },
372
+ },
373
+ },
374
+ },
375
+ }))
376
+
377
+ const result = detectAll()
378
+ expect(result.workspacePath).toBe(WORKSPACE_PATH)
379
+ expect(result.openclawBin).toBe('/usr/local/bin/openclaw')
380
+ expect(result.gatewayToken).toBe('oc_tok_test123')
381
+ expect(result.httpEndpointEnabled).toBe(true)
382
+ })
383
+
384
+ it('handles partial setup (workspace exists, no binary)', () => {
385
+ mockExistsSync.mockImplementation((p: string) => {
386
+ if (p === WORKSPACE_PATH) return true
387
+ if (p === CONFIG_PATH) return true
388
+ return false
389
+ })
390
+ mockReadFileSync.mockReturnValue(JSON.stringify({
391
+ gateway: {
392
+ auth: { token: 'oc_tok_partial' },
393
+ },
394
+ }))
395
+
396
+ const result = detectAll()
397
+ expect(result.workspacePath).toBe(WORKSPACE_PATH)
398
+ expect(result.openclawBin).toBeNull()
399
+ expect(result.gatewayToken).toBe('oc_tok_partial')
400
+ expect(result.httpEndpointEnabled).toBe(false) // endpoint key missing
401
+ })
402
+
403
+ it('handles workspace without config file', () => {
404
+ mockExistsSync.mockImplementation((p: string) => {
405
+ if (p === WORKSPACE_PATH) return true
406
+ return false
407
+ })
408
+ mockExecSync.mockReturnValue('/usr/local/bin/openclaw')
409
+
410
+ const result = detectAll()
411
+ expect(result.workspacePath).toBe(WORKSPACE_PATH)
412
+ expect(result.openclawBin).toBe('/usr/local/bin/openclaw')
413
+ expect(result.gatewayToken).toBeNull()
414
+ expect(result.httpEndpointEnabled).toBeNull()
415
+ })
416
+ })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Setup detection helpers — extracted from scripts/setup.mjs for testability.
3
+ *
4
+ * These functions detect the user's OpenClaw installation state:
5
+ * - Workspace directory location
6
+ * - Gateway auth token from openclaw.json
7
+ * - HTTP endpoint configuration
8
+ * - OpenClaw binary on PATH
9
+ *
10
+ * Used by: npm run setup, clawport setup, clawport doctor
11
+ */
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { homedir } from 'os'
16
+ import { execSync } from 'child_process'
17
+
18
+ // ── Workspace ─────────────────────────────────────────────────────
19
+
20
+ /** Detect the default workspace path (~/.openclaw/workspace). */
21
+ export function detectWorkspacePath(): string | null {
22
+ const defaultPath = join(homedir(), '.openclaw', 'workspace')
23
+ if (existsSync(defaultPath)) return defaultPath
24
+ return null
25
+ }
26
+
27
+ // ── Binary ────────────────────────────────────────────────────────
28
+
29
+ /** Find the openclaw binary on PATH. */
30
+ export function detectOpenClawBin(): string | null {
31
+ const cmd = process.platform === 'win32' ? 'where' : 'which'
32
+ try {
33
+ return execSync(`${cmd} openclaw`, {
34
+ encoding: 'utf-8',
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ }).trim()
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ // ── Gateway Token ─────────────────────────────────────────────────
43
+
44
+ /** Read the gateway auth token from ~/.openclaw/openclaw.json. */
45
+ export function detectGatewayToken(): string | null {
46
+ const configPath = join(homedir(), '.openclaw', 'openclaw.json')
47
+ if (!existsSync(configPath)) return null
48
+ try {
49
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
50
+ const token = config?.gateway?.auth?.token
51
+ return typeof token === 'string' ? token : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ // ── HTTP Endpoint ─────────────────────────────────────────────────
58
+
59
+ /** Check if the HTTP chat completions endpoint is enabled in openclaw.json. */
60
+ export function checkHttpEndpointEnabled(): boolean | null {
61
+ const configPath = join(homedir(), '.openclaw', 'openclaw.json')
62
+ if (!existsSync(configPath)) return null
63
+ try {
64
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
65
+ return config?.gateway?.http?.endpoints?.chatCompletions?.enabled === true
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** Enable the HTTP chat completions endpoint in openclaw.json. */
72
+ export function enableHttpEndpoint(): boolean {
73
+ const configPath = join(homedir(), '.openclaw', 'openclaw.json')
74
+ if (!existsSync(configPath)) return false
75
+ try {
76
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'))
77
+ if (!config.gateway) config.gateway = {}
78
+ if (!config.gateway.http) config.gateway.http = {}
79
+ if (!config.gateway.http.endpoints) config.gateway.http.endpoints = {}
80
+ if (!config.gateway.http.endpoints.chatCompletions) config.gateway.http.endpoints.chatCompletions = {}
81
+ config.gateway.http.endpoints.chatCompletions.enabled = true
82
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ // ── Summary ───────────────────────────────────────────────────────
90
+
91
+ export interface DetectionResult {
92
+ workspacePath: string | null
93
+ openclawBin: string | null
94
+ gatewayToken: string | null
95
+ httpEndpointEnabled: boolean | null
96
+ }
97
+
98
+ /** Run all detectors and return a summary. */
99
+ export function detectAll(): DetectionResult {
100
+ return {
101
+ workspacePath: detectWorkspacePath(),
102
+ openclawBin: detectOpenClawBin(),
103
+ gatewayToken: detectGatewayToken(),
104
+ httpEndpointEnabled: checkHttpEndpointEnabled(),
105
+ }
106
+ }
@@ -0,0 +1,752 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Setup Scenarios — Tests how ClawPort behaves for fresh users vs existing users.
4
+ *
5
+ * These tests simulate the full resolution chain for each subsystem:
6
+ * - Agent registry (bundled fallback → auto-discovery → user override)
7
+ * - Memory (empty workspace → populated workspace)
8
+ * - Crons (CLI unavailable → CLI returns data)
9
+ * - Pipelines (no config → pipelines.json present)
10
+ * - Environment (missing vars → all vars present)
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
14
+
15
+ // ── Hoisted mocks ─────────────────────────────────────────────────
16
+
17
+ const { mockReadFileSync, mockExistsSync, mockStatSync, mockReaddirSync } = vi.hoisted(() => ({
18
+ mockReadFileSync: vi.fn(),
19
+ mockExistsSync: vi.fn(),
20
+ mockStatSync: vi.fn(),
21
+ mockReaddirSync: vi.fn(),
22
+ }))
23
+
24
+ const { mockExecSync } = vi.hoisted(() => ({
25
+ mockExecSync: vi.fn(),
26
+ }))
27
+
28
+ const { bundledAgents } = vi.hoisted(() => ({
29
+ bundledAgents: [
30
+ {
31
+ id: 'example',
32
+ name: 'Example',
33
+ title: 'Demo Agent',
34
+ reportsTo: null,
35
+ directReports: [],
36
+ soulPath: 'SOUL.md',
37
+ voiceId: null,
38
+ color: '#f5c518',
39
+ emoji: 'E',
40
+ tools: ['read', 'write'],
41
+ memoryPath: null,
42
+ description: 'Bundled example agent.',
43
+ },
44
+ ],
45
+ }))
46
+
47
+ vi.mock('fs', () => ({
48
+ readFileSync: mockReadFileSync,
49
+ existsSync: mockExistsSync,
50
+ statSync: mockStatSync,
51
+ readdirSync: mockReaddirSync,
52
+ default: {
53
+ readFileSync: mockReadFileSync,
54
+ existsSync: mockExistsSync,
55
+ statSync: mockStatSync,
56
+ readdirSync: mockReaddirSync,
57
+ },
58
+ }))
59
+
60
+ vi.mock('child_process', () => ({
61
+ execSync: mockExecSync,
62
+ default: { execSync: mockExecSync },
63
+ }))
64
+
65
+ vi.mock('@/lib/agents.json', () => ({
66
+ default: bundledAgents,
67
+ }))
68
+
69
+ // ── Imports (after mocks) ─────────────────────────────────────────
70
+
71
+ import { getAgents } from './agents'
72
+ import { loadRegistry } from './agents-registry'
73
+ import { getMemoryFiles, getMemoryConfig, getMemoryStatus, computeMemoryStats } from './memory'
74
+ import { requireEnv } from './env'
75
+ import { loadPipelines } from './cron-pipelines.server'
76
+
77
+ // ── Helpers ───────────────────────────────────────────────────────
78
+
79
+ const WS = '/home/user/.openclaw/workspace'
80
+
81
+ function fakeStat(size: number, mtime?: Date) {
82
+ return {
83
+ size,
84
+ mtime: mtime ?? new Date('2026-03-01T12:00:00Z'),
85
+ isFile: () => true,
86
+ }
87
+ }
88
+
89
+ // ═══════════════════════════════════════════════════════════════════
90
+ // SCENARIO 1: FRESH USER — No OpenClaw installation at all
91
+ // ═══════════════════════════════════════════════════════════════════
92
+
93
+ describe('Fresh user (no OpenClaw installed)', () => {
94
+ beforeEach(() => {
95
+ vi.clearAllMocks()
96
+ vi.unstubAllEnvs()
97
+ // Fresh user: no env vars set, no files on disk
98
+ mockExistsSync.mockReturnValue(false)
99
+ mockReaddirSync.mockReturnValue([])
100
+ })
101
+
102
+ describe('environment', () => {
103
+ it('requireEnv throws for WORKSPACE_PATH', () => {
104
+ expect(() => requireEnv('WORKSPACE_PATH')).toThrow('Missing required environment variable')
105
+ })
106
+
107
+ it('requireEnv throws for OPENCLAW_BIN', () => {
108
+ expect(() => requireEnv('OPENCLAW_BIN')).toThrow('Missing required environment variable')
109
+ })
110
+
111
+ it('requireEnv throws for OPENCLAW_GATEWAY_TOKEN', () => {
112
+ expect(() => requireEnv('OPENCLAW_GATEWAY_TOKEN')).toThrow('Missing required environment variable')
113
+ })
114
+ })
115
+
116
+ describe('agent registry', () => {
117
+ it('falls back to bundled agents when no WORKSPACE_PATH', async () => {
118
+ vi.stubEnv('WORKSPACE_PATH', '')
119
+ const agents = await getAgents()
120
+ expect(agents.length).toBe(bundledAgents.length)
121
+ expect(agents[0].id).toBe('example')
122
+ expect(agents[0].name).toBe('Example')
123
+ })
124
+
125
+ it('bundled agents have soul=null (no workspace to read from)', async () => {
126
+ vi.stubEnv('WORKSPACE_PATH', '')
127
+ const agents = await getAgents()
128
+ for (const agent of agents) {
129
+ expect(agent.soul).toBeNull()
130
+ }
131
+ })
132
+
133
+ it('bundled agents have empty crons array', async () => {
134
+ vi.stubEnv('WORKSPACE_PATH', '')
135
+ const agents = await getAgents()
136
+ for (const agent of agents) {
137
+ expect(agent.crons).toEqual([])
138
+ }
139
+ })
140
+ })
141
+
142
+ describe('memory', () => {
143
+ it('getMemoryFiles throws without WORKSPACE_PATH', async () => {
144
+ vi.stubEnv('WORKSPACE_PATH', '')
145
+ await expect(getMemoryFiles()).rejects.toThrow('Missing required environment variable')
146
+ })
147
+
148
+ it('getMemoryConfig throws without WORKSPACE_PATH', () => {
149
+ vi.stubEnv('WORKSPACE_PATH', '')
150
+ expect(() => getMemoryConfig()).toThrow('Missing required environment variable')
151
+ })
152
+
153
+ it('getMemoryStatus returns defaults without OPENCLAW_BIN', () => {
154
+ vi.stubEnv('OPENCLAW_BIN', '')
155
+ const status = getMemoryStatus()
156
+ expect(status.indexed).toBe(false)
157
+ expect(status.raw).toBe('Memory status unavailable')
158
+ })
159
+ })
160
+
161
+ describe('pipelines', () => {
162
+ it('loadPipelines returns empty array without WORKSPACE_PATH', () => {
163
+ vi.stubEnv('WORKSPACE_PATH', '')
164
+ const pipelines = loadPipelines()
165
+ expect(pipelines).toEqual([])
166
+ })
167
+ })
168
+ })
169
+
170
+ // ═══════════════════════════════════════════════════════════════════
171
+ // SCENARIO 2: PARTIAL USER — OpenClaw installed, setup not run yet
172
+ // ═══════════════════════════════════════════════════════════════════
173
+
174
+ describe('Partial user (OpenClaw installed, no ClawPort config)', () => {
175
+ beforeEach(() => {
176
+ vi.clearAllMocks()
177
+ vi.unstubAllEnvs()
178
+ vi.stubEnv('WORKSPACE_PATH', WS)
179
+ vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
180
+ vi.stubEnv('OPENCLAW_GATEWAY_TOKEN', 'tok_test_12345')
181
+ mockExistsSync.mockReturnValue(false)
182
+ mockReaddirSync.mockReturnValue([])
183
+ })
184
+
185
+ describe('agent registry — empty workspace', () => {
186
+ it('no agents/ dir and no SOUL.md → uses bundled fallback', async () => {
187
+ const agents = await getAgents()
188
+ expect(agents.length).toBe(bundledAgents.length)
189
+ expect(agents[0].id).toBe('example')
190
+ })
191
+
192
+ it('empty agents/ dir → uses bundled fallback', async () => {
193
+ mockExistsSync.mockImplementation((p: string) => {
194
+ if (p === `${WS}/agents`) return true
195
+ return false
196
+ })
197
+ mockReaddirSync.mockReturnValue([])
198
+
199
+ const registry = loadRegistry()
200
+ expect(registry.length).toBe(bundledAgents.length)
201
+ })
202
+ })
203
+
204
+ describe('agent registry — workspace with only root SOUL.md', () => {
205
+ it('root SOUL.md only → discovers single root orchestrator', async () => {
206
+ mockExistsSync.mockImplementation((p: string) => {
207
+ if (p === `${WS}/clawport/agents.json`) return false
208
+ if (p === `${WS}/SOUL.md`) return true
209
+ if (p === `${WS}/IDENTITY.md`) return false
210
+ if (p === `${WS}/agents`) return true
211
+ return false
212
+ })
213
+ mockReaddirSync.mockReturnValue([])
214
+ mockReadFileSync.mockImplementation((p: string) => {
215
+ if (p === `${WS}/SOUL.md`) return '# SOUL.md — MyAssistant\nI am helpful.'
216
+ throw new Error('ENOENT')
217
+ })
218
+
219
+ const agents = await getAgents()
220
+ expect(agents.length).toBe(1)
221
+ expect(agents[0].name).toBe('MyAssistant')
222
+ expect(agents[0].title).toBe('Orchestrator')
223
+ expect(agents[0].reportsTo).toBeNull()
224
+ })
225
+ })
226
+
227
+ describe('memory — empty workspace', () => {
228
+ it('no MEMORY.md and no memory/ dir → returns empty array', async () => {
229
+ const files = await getMemoryFiles()
230
+ expect(files).toEqual([])
231
+ })
232
+
233
+ it('computeMemoryStats on empty files → all zeros', () => {
234
+ const stats = computeMemoryStats([])
235
+ expect(stats.totalFiles).toBe(0)
236
+ expect(stats.totalSizeBytes).toBe(0)
237
+ expect(stats.dailyLogCount).toBe(0)
238
+ expect(stats.evergreenCount).toBe(0)
239
+ })
240
+ })
241
+
242
+ describe('memory config — no openclaw.json', () => {
243
+ it('returns defaults with configFound=false', () => {
244
+ const config = getMemoryConfig()
245
+ expect(config.configFound).toBe(false)
246
+ expect(config.memorySearch.enabled).toBe(false)
247
+ expect(config.memorySearch.hybrid.vectorWeight).toBe(0.7)
248
+ expect(config.memorySearch.hybrid.temporalDecay.halfLifeDays).toBe(30)
249
+ expect(config.memoryFlush.enabled).toBe(false)
250
+ })
251
+ })
252
+
253
+ describe('memory status — CLI not responding', () => {
254
+ it('handles CLI command failure gracefully', () => {
255
+ mockExecSync.mockImplementation(() => {
256
+ throw new Error('Command not found: openclaw')
257
+ })
258
+ const status = getMemoryStatus()
259
+ expect(status.indexed).toBe(false)
260
+ expect(status.raw).toBe('Memory status unavailable')
261
+ })
262
+ })
263
+
264
+ describe('pipelines — no pipelines.json', () => {
265
+ it('returns empty array when pipelines.json does not exist', () => {
266
+ const pipelines = loadPipelines()
267
+ expect(pipelines).toEqual([])
268
+ })
269
+ })
270
+ })
271
+
272
+ // ═══════════════════════════════════════════════════════════════════
273
+ // SCENARIO 3: EXISTING USER — Fully configured workspace
274
+ // ═══════════════════════════════════════════════════════════════════
275
+
276
+ describe('Existing user (fully configured workspace)', () => {
277
+ const OPENCLAW_BIN = '/usr/local/bin/openclaw'
278
+
279
+ beforeEach(() => {
280
+ vi.clearAllMocks()
281
+ vi.unstubAllEnvs()
282
+ vi.stubEnv('WORKSPACE_PATH', WS)
283
+ vi.stubEnv('OPENCLAW_BIN', OPENCLAW_BIN)
284
+ vi.stubEnv('OPENCLAW_GATEWAY_TOKEN', 'oc_tok_abc123xyz')
285
+ mockExistsSync.mockReturnValue(false)
286
+ mockReaddirSync.mockReturnValue([])
287
+ })
288
+
289
+ describe('agent registry — user override (clawport/agents.json)', () => {
290
+ const customAgents = [
291
+ {
292
+ id: 'orchestrator',
293
+ name: 'Commander',
294
+ title: 'Lead Agent',
295
+ reportsTo: null,
296
+ directReports: ['worker-a', 'worker-b'],
297
+ soulPath: 'SOUL.md',
298
+ voiceId: null,
299
+ color: '#e11d48',
300
+ emoji: 'C',
301
+ tools: ['exec', 'read', 'write', 'message'],
302
+ memoryPath: null,
303
+ description: 'Top-level orchestrator.',
304
+ },
305
+ {
306
+ id: 'worker-a',
307
+ name: 'Alpha',
308
+ title: 'Research Agent',
309
+ reportsTo: 'orchestrator',
310
+ directReports: [],
311
+ soulPath: 'agents/alpha/SOUL.md',
312
+ voiceId: null,
313
+ color: '#3b82f6',
314
+ emoji: 'A',
315
+ tools: ['web_search', 'read'],
316
+ memoryPath: null,
317
+ description: 'Research specialist.',
318
+ },
319
+ {
320
+ id: 'worker-b',
321
+ name: 'Beta',
322
+ title: 'Writer Agent',
323
+ reportsTo: 'orchestrator',
324
+ directReports: [],
325
+ soulPath: 'agents/beta/SOUL.md',
326
+ voiceId: null,
327
+ color: '#22c55e',
328
+ emoji: 'B',
329
+ tools: ['read', 'write'],
330
+ memoryPath: null,
331
+ description: 'Content writer.',
332
+ },
333
+ ]
334
+
335
+ it('loads custom agents from workspace override', async () => {
336
+ mockExistsSync.mockImplementation((p: string) => {
337
+ if (p === `${WS}/clawport/agents.json`) return true
338
+ if (p.endsWith('SOUL.md')) return true
339
+ return false
340
+ })
341
+ mockReadFileSync.mockImplementation((p: string) => {
342
+ if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
343
+ if (p.endsWith('SOUL.md')) return '# Agent SOUL content\nI do things.'
344
+ throw new Error('ENOENT')
345
+ })
346
+
347
+ const agents = await getAgents()
348
+ expect(agents.length).toBe(3)
349
+ expect(agents.map(a => a.id)).toEqual(['orchestrator', 'worker-a', 'worker-b'])
350
+ })
351
+
352
+ it('reads SOUL.md content for each agent', async () => {
353
+ mockExistsSync.mockImplementation((p: string) => {
354
+ if (p === `${WS}/clawport/agents.json`) return true
355
+ if (p.endsWith('SOUL.md')) return true
356
+ return false
357
+ })
358
+ mockReadFileSync.mockImplementation((p: string) => {
359
+ if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
360
+ if (p === `${WS}/SOUL.md`) return '# Commander\nI orchestrate.'
361
+ if (p === `${WS}/agents/alpha/SOUL.md`) return '# ALPHA\nI research.'
362
+ if (p === `${WS}/agents/beta/SOUL.md`) return '# BETA\nI write.'
363
+ throw new Error('ENOENT')
364
+ })
365
+
366
+ const agents = await getAgents()
367
+ const cmd = agents.find(a => a.id === 'orchestrator')!
368
+ expect(cmd.soul).toBe('# Commander\nI orchestrate.')
369
+ const alpha = agents.find(a => a.id === 'worker-a')!
370
+ expect(alpha.soul).toBe('# ALPHA\nI research.')
371
+ })
372
+
373
+ it('preserves hierarchy structure', async () => {
374
+ mockExistsSync.mockImplementation((p: string) => {
375
+ if (p === `${WS}/clawport/agents.json`) return true
376
+ return false
377
+ })
378
+ mockReadFileSync.mockImplementation((p: string) => {
379
+ if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
380
+ throw new Error('ENOENT')
381
+ })
382
+
383
+ const agents = await getAgents()
384
+ const root = agents.find(a => a.reportsTo === null)!
385
+ expect(root.id).toBe('orchestrator')
386
+ expect(root.directReports).toEqual(['worker-a', 'worker-b'])
387
+
388
+ const workerA = agents.find(a => a.id === 'worker-a')!
389
+ expect(workerA.reportsTo).toBe('orchestrator')
390
+ })
391
+ })
392
+
393
+ describe('agent registry — auto-discovery (no agents.json, has agents/ dir)', () => {
394
+ it('discovers full hierarchy with root + agents + sub-agents', async () => {
395
+ mockExistsSync.mockImplementation((p: string) => {
396
+ if (p === `${WS}/clawport/agents.json`) return false
397
+ if (p === `${WS}/SOUL.md`) return true
398
+ if (p === `${WS}/IDENTITY.md`) return true
399
+ if (p === `${WS}/agents`) return true
400
+ if (p === `${WS}/agents/research/SOUL.md`) return true
401
+ if (p === `${WS}/agents/research/sub-agents`) return true
402
+ if (p === `${WS}/agents/research/sub-agents/SCOUT.md`) return true
403
+ if (p === `${WS}/agents/research/members`) return false
404
+ if (p === `${WS}/agents/content/SOUL.md`) return true
405
+ if (p === `${WS}/agents/content/sub-agents`) return false
406
+ if (p === `${WS}/agents/content/members`) return true
407
+ if (p === `${WS}/agents/content/members/WRITER.md`) return true
408
+ return false
409
+ })
410
+
411
+ mockReaddirSync.mockImplementation((p: string | { toString(): string }, opts?: unknown) => {
412
+ const path = typeof p === 'string' ? p : p.toString()
413
+ if (path === `${WS}/agents`) {
414
+ return [
415
+ { name: 'research', isDirectory: () => true },
416
+ { name: 'content', isDirectory: () => true },
417
+ ]
418
+ }
419
+ if (path === `${WS}/agents/research/sub-agents`) return ['SCOUT.md']
420
+ if (path === `${WS}/agents/content/members`) return ['WRITER.md']
421
+ return []
422
+ })
423
+
424
+ mockReadFileSync.mockImplementation((p: string) => {
425
+ if (p === `${WS}/IDENTITY.md`) return '- **Name:** Atlas\n- **Emoji:** 🌐'
426
+ if (p === `${WS}/SOUL.md`) return '# SOUL.md - Who You Are\nYou are Atlas.'
427
+ if (p === `${WS}/agents/research/SOUL.md`) return '# SOUL.md — VERA, Chief Strategy Officer'
428
+ if (p === `${WS}/agents/content/SOUL.md`) return '# HERALD — Content Director'
429
+ if (p === `${WS}/agents/research/sub-agents/SCOUT.md`) return '# SCOUT — Trend Finder'
430
+ if (p === `${WS}/agents/content/members/WRITER.md`) return '# QUILL — Blog Writer'
431
+ throw new Error('ENOENT')
432
+ })
433
+
434
+ const agents = await getAgents()
435
+
436
+ // Root agent from IDENTITY.md
437
+ const root = agents.find(a => a.reportsTo === null)!
438
+ expect(root.name).toBe('Atlas')
439
+ expect(root.emoji).toBe('🌐')
440
+ expect(root.id).toBe('atlas')
441
+
442
+ // Research agent
443
+ const vera = agents.find(a => a.id === 'research')!
444
+ expect(vera.name).toBe('VERA')
445
+ expect(vera.title).toBe('Chief Strategy Officer')
446
+ expect(vera.reportsTo).toBe('atlas')
447
+
448
+ // Content agent
449
+ const herald = agents.find(a => a.id === 'content')!
450
+ expect(herald.name).toBe('HERALD')
451
+ expect(herald.title).toBe('Content Director')
452
+ expect(herald.reportsTo).toBe('atlas')
453
+
454
+ // Sub-agents
455
+ const scout = agents.find(a => a.id === 'research-scout')!
456
+ expect(scout.name).toBe('SCOUT')
457
+ expect(scout.title).toBe('Trend Finder')
458
+ expect(scout.reportsTo).toBe('research')
459
+
460
+ // Sub-agent ID is derived from filename (WRITER.md → content-writer)
461
+ const writer = agents.find(a => a.id === 'content-writer')!
462
+ expect(writer.name).toBe('QUILL') // name from heading, not filename
463
+ expect(writer.title).toBe('Blog Writer')
464
+ expect(writer.reportsTo).toBe('content')
465
+ })
466
+ })
467
+
468
+ describe('memory — populated workspace', () => {
469
+ it('discovers all memory files from root and memory/ dir', async () => {
470
+ const today = new Date().toISOString().slice(0, 10)
471
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
472
+
473
+ mockExistsSync.mockImplementation((p: string) => {
474
+ if (p === `${WS}/MEMORY.md`) return true
475
+ if (p === `${WS}/memory`) return true
476
+ return false
477
+ })
478
+ mockReaddirSync.mockReturnValue([
479
+ `${today}.md`,
480
+ `${yesterday}.md`,
481
+ 'team-memory.md',
482
+ 'debugging.md',
483
+ ])
484
+ mockReadFileSync.mockReturnValue('# Content')
485
+ mockStatSync.mockReturnValue(fakeStat(512))
486
+
487
+ const files = await getMemoryFiles()
488
+
489
+ // 1 root MEMORY.md + 4 memory/ files = 5
490
+ expect(files.length).toBe(5)
491
+
492
+ // Evergreen files sorted first
493
+ const categories = files.map(f => f.category)
494
+ const firstDailyIdx = categories.indexOf('daily')
495
+ const lastEvergreenIdx = categories.lastIndexOf('evergreen')
496
+ expect(lastEvergreenIdx).toBeLessThan(firstDailyIdx)
497
+
498
+ // Root memory has special label
499
+ const rootMemory = files.find(f => f.relativePath === 'MEMORY.md')
500
+ expect(rootMemory?.label).toBe('Long-Term Memory')
501
+
502
+ // Today's log has special label
503
+ const todayLog = files.find(f => f.relativePath === `memory/${today}.md`)
504
+ expect(todayLog?.label).toBe('Daily Log (Today)')
505
+
506
+ // Yesterday's log has special label
507
+ const yesterdayLog = files.find(f => f.relativePath === `memory/${yesterday}.md`)
508
+ expect(yesterdayLog?.label).toBe('Daily Log (Yesterday)')
509
+
510
+ // Evergreen files use humanized names
511
+ const teamMemory = files.find(f => f.relativePath === 'memory/team-memory.md')
512
+ expect(teamMemory?.label).toBe('Team Memory')
513
+ expect(teamMemory?.category).toBe('evergreen')
514
+ })
515
+
516
+ it('computeMemoryStats reflects full workspace', () => {
517
+ const today = new Date().toISOString().slice(0, 10)
518
+ const files = [
519
+ { label: 'LTM', path: `${WS}/MEMORY.md`, relativePath: 'MEMORY.md', content: '# Memory', lastModified: '2026-03-01T12:00:00Z', sizeBytes: 2048, category: 'evergreen' as const },
520
+ { label: 'Team', path: `${WS}/memory/team-memory.md`, relativePath: 'memory/team-memory.md', content: '', lastModified: '2026-03-01T12:00:00Z', sizeBytes: 1024, category: 'evergreen' as const },
521
+ { label: 'Today', path: `${WS}/memory/${today}.md`, relativePath: `memory/${today}.md`, content: '', lastModified: `${today}T12:00:00Z`, sizeBytes: 256, category: 'daily' as const },
522
+ { label: 'Yesterday', path: `${WS}/memory/2026-03-03.md`, relativePath: 'memory/2026-03-03.md', content: '', lastModified: '2026-03-03T12:00:00Z', sizeBytes: 384, category: 'daily' as const },
523
+ ]
524
+
525
+ const stats = computeMemoryStats(files)
526
+ expect(stats.totalFiles).toBe(4)
527
+ expect(stats.totalSizeBytes).toBe(2048 + 1024 + 256 + 384)
528
+ expect(stats.evergreenCount).toBe(2)
529
+ expect(stats.dailyLogCount).toBe(2)
530
+ expect(stats.dailyTimeline).toHaveLength(30)
531
+ })
532
+ })
533
+
534
+ describe('memory config — openclaw.json with memory search enabled', () => {
535
+ it('reads and merges memory search config', () => {
536
+ // openclaw.json is in the parent of the workspace dir
537
+ mockExistsSync.mockReturnValue(true)
538
+ mockReadFileSync.mockReturnValue(JSON.stringify({
539
+ agents: {
540
+ defaults: {
541
+ memorySearch: {
542
+ enabled: true,
543
+ provider: 'openai',
544
+ model: 'text-embedding-3-small',
545
+ hybrid: {
546
+ enabled: true,
547
+ vectorWeight: 0.8,
548
+ textWeight: 0.2,
549
+ temporalDecay: { enabled: true, halfLifeDays: 14 },
550
+ mmr: { enabled: true, lambda: 0.6 },
551
+ },
552
+ cache: { enabled: true, maxEntries: 512 },
553
+ },
554
+ compaction: {
555
+ memoryFlush: { enabled: true, softThresholdTokens: 60000 },
556
+ },
557
+ },
558
+ },
559
+ }))
560
+
561
+ const config = getMemoryConfig()
562
+ expect(config.configFound).toBe(true)
563
+ expect(config.memorySearch.enabled).toBe(true)
564
+ expect(config.memorySearch.provider).toBe('openai')
565
+ expect(config.memorySearch.model).toBe('text-embedding-3-small')
566
+ expect(config.memorySearch.hybrid.vectorWeight).toBe(0.8)
567
+ expect(config.memorySearch.hybrid.temporalDecay.halfLifeDays).toBe(14)
568
+ expect(config.memorySearch.hybrid.mmr.lambda).toBe(0.6)
569
+ expect(config.memorySearch.cache.maxEntries).toBe(512)
570
+ expect(config.memoryFlush.enabled).toBe(true)
571
+ expect(config.memoryFlush.softThresholdTokens).toBe(60000)
572
+ })
573
+ })
574
+
575
+ describe('memory status — CLI returns JSON', () => {
576
+ it('parses full memory status from CLI', () => {
577
+ vi.stubEnv('OPENCLAW_BIN', OPENCLAW_BIN)
578
+ mockExecSync.mockReturnValue(JSON.stringify({
579
+ indexed: true,
580
+ lastIndexed: '2026-03-04T08:00:00Z',
581
+ totalEntries: 128,
582
+ vectorAvailable: true,
583
+ embeddingProvider: 'openai',
584
+ }))
585
+
586
+ const status = getMemoryStatus()
587
+ expect(status.indexed).toBe(true)
588
+ expect(status.lastIndexed).toBe('2026-03-04T08:00:00Z')
589
+ expect(status.totalEntries).toBe(128)
590
+ expect(status.vectorAvailable).toBe(true)
591
+ expect(status.embeddingProvider).toBe('openai')
592
+ })
593
+ })
594
+
595
+ describe('pipelines — pipelines.json configured', () => {
596
+ it('loads pipeline definitions from workspace', () => {
597
+ const pipelineData = [
598
+ {
599
+ name: 'daily-report',
600
+ edges: [
601
+ { from: 'scout-daily', to: 'vera-daily-review', artifact: 'scout-report.md' },
602
+ { from: 'vera-daily-review', to: 'herald-publish', artifact: 'reviewed-report.md' },
603
+ ],
604
+ },
605
+ ]
606
+
607
+ mockExistsSync.mockImplementation((p: string) => {
608
+ if (p === `${WS}/clawport/pipelines.json`) return true
609
+ return false
610
+ })
611
+ mockReadFileSync.mockImplementation((p: string) => {
612
+ if (p === `${WS}/clawport/pipelines.json`) return JSON.stringify(pipelineData)
613
+ throw new Error('ENOENT')
614
+ })
615
+
616
+ const pipelines = loadPipelines()
617
+ expect(pipelines).toHaveLength(1)
618
+ expect(pipelines[0].name).toBe('daily-report')
619
+ expect(pipelines[0].edges).toHaveLength(2)
620
+ })
621
+
622
+ it('handles malformed pipelines.json gracefully', () => {
623
+ mockExistsSync.mockImplementation((p: string) => {
624
+ if (p === `${WS}/clawport/pipelines.json`) return true
625
+ return false
626
+ })
627
+ mockReadFileSync.mockImplementation((p: string) => {
628
+ if (p === `${WS}/clawport/pipelines.json`) return '{ broken json'
629
+ throw new Error('ENOENT')
630
+ })
631
+
632
+ const pipelines = loadPipelines()
633
+ expect(pipelines).toEqual([])
634
+ })
635
+ })
636
+ })
637
+
638
+ // ═══════════════════════════════════════════════════════════════════
639
+ // SCENARIO 4: TRANSITION — User going from fresh to configured
640
+ // ═══════════════════════════════════════════════════════════════════
641
+
642
+ describe('Transition scenarios', () => {
643
+ beforeEach(() => {
644
+ vi.clearAllMocks()
645
+ vi.unstubAllEnvs()
646
+ })
647
+
648
+ it('agents upgrade from bundled → auto-discovery when SOUL.md appears', async () => {
649
+ // Phase 1: No workspace → bundled
650
+ vi.stubEnv('WORKSPACE_PATH', '')
651
+ mockExistsSync.mockReturnValue(false)
652
+ const bundled = await getAgents()
653
+ expect(bundled.length).toBe(bundledAgents.length)
654
+ expect(bundled[0].id).toBe('example')
655
+
656
+ // Phase 2: Workspace created with agents → auto-discovery
657
+ vi.stubEnv('WORKSPACE_PATH', WS)
658
+ mockExistsSync.mockImplementation((p: string) => {
659
+ if (p === `${WS}/clawport/agents.json`) return false
660
+ if (p === `${WS}/SOUL.md`) return true
661
+ if (p === `${WS}/IDENTITY.md`) return false
662
+ if (p === `${WS}/agents`) return true
663
+ if (p === `${WS}/agents/helper/SOUL.md`) return true
664
+ if (p === `${WS}/agents/helper/sub-agents`) return false
665
+ if (p === `${WS}/agents/helper/members`) return false
666
+ return false
667
+ })
668
+ mockReaddirSync.mockReturnValue([
669
+ { name: 'helper', isDirectory: () => true },
670
+ ])
671
+ mockReadFileSync.mockImplementation((p: string) => {
672
+ if (p === `${WS}/SOUL.md`) return '# SOUL.md — MyBot\nI help.'
673
+ if (p === `${WS}/agents/helper/SOUL.md`) return '# Helper — Task Assistant\nI assist.'
674
+ throw new Error('ENOENT')
675
+ })
676
+
677
+ const discovered = await getAgents()
678
+ expect(discovered.length).toBe(2)
679
+ expect(discovered.map(a => a.id)).toContain('mybot')
680
+ expect(discovered.map(a => a.id)).toContain('helper')
681
+ })
682
+
683
+ it('agents upgrade from auto-discovery → user override when agents.json appears', async () => {
684
+ vi.stubEnv('WORKSPACE_PATH', WS)
685
+
686
+ // Phase 1: Auto-discovery
687
+ mockExistsSync.mockImplementation((p: string) => {
688
+ if (p === `${WS}/clawport/agents.json`) return false
689
+ if (p === `${WS}/SOUL.md`) return false
690
+ if (p === `${WS}/agents`) return true
691
+ if (p === `${WS}/agents/bot/SOUL.md`) return true
692
+ if (p === `${WS}/agents/bot/sub-agents`) return false
693
+ if (p === `${WS}/agents/bot/members`) return false
694
+ return false
695
+ })
696
+ mockReaddirSync.mockReturnValue([
697
+ { name: 'bot', isDirectory: () => true },
698
+ ])
699
+ mockReadFileSync.mockImplementation((p: string) => {
700
+ if (p === `${WS}/agents/bot/SOUL.md`) return '# Bot\nContent.'
701
+ throw new Error('ENOENT')
702
+ })
703
+
704
+ const auto = await getAgents()
705
+ expect(auto.length).toBe(1)
706
+ expect(auto[0].id).toBe('bot')
707
+
708
+ // Phase 2: User drops agents.json → override
709
+ const customAgents = [
710
+ { id: 'custom-root', name: 'Root', title: 'Boss', reportsTo: null, directReports: [], soulPath: null, voiceId: null, color: '#ff0000', emoji: 'R', tools: ['exec'], memoryPath: null, description: 'Boss.' },
711
+ ]
712
+ mockExistsSync.mockImplementation((p: string) => {
713
+ if (p === `${WS}/clawport/agents.json`) return true
714
+ return false
715
+ })
716
+ mockReadFileSync.mockImplementation((p: string) => {
717
+ if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
718
+ throw new Error('ENOENT')
719
+ })
720
+
721
+ const overridden = await getAgents()
722
+ expect(overridden.length).toBe(1)
723
+ expect(overridden[0].id).toBe('custom-root')
724
+ expect(overridden[0].name).toBe('Root')
725
+ })
726
+
727
+ it('memory grows as user accumulates daily logs', () => {
728
+ const today = new Date().toISOString().slice(0, 10)
729
+ const d1 = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
730
+ const d2 = new Date(Date.now() - 86400000 * 2).toISOString().slice(0, 10)
731
+
732
+ // Day 1: only MEMORY.md
733
+ const day1Files = [
734
+ { label: 'LTM', path: `${WS}/MEMORY.md`, relativePath: 'MEMORY.md', content: '# Fresh memory', lastModified: d2, sizeBytes: 100, category: 'evergreen' as const },
735
+ ]
736
+ const stats1 = computeMemoryStats(day1Files)
737
+ expect(stats1.totalFiles).toBe(1)
738
+ expect(stats1.dailyLogCount).toBe(0)
739
+ expect(stats1.evergreenCount).toBe(1)
740
+
741
+ // Day 3: MEMORY.md + 2 daily logs
742
+ const day3Files = [
743
+ ...day1Files,
744
+ { label: 'Daily', path: `${WS}/memory/${d1}.md`, relativePath: `memory/${d1}.md`, content: '', lastModified: d1, sizeBytes: 200, category: 'daily' as const },
745
+ { label: 'Daily', path: `${WS}/memory/${today}.md`, relativePath: `memory/${today}.md`, content: '', lastModified: today, sizeBytes: 300, category: 'daily' as const },
746
+ ]
747
+ const stats3 = computeMemoryStats(day3Files)
748
+ expect(stats3.totalFiles).toBe(3)
749
+ expect(stats3.dailyLogCount).toBe(2)
750
+ expect(stats3.totalSizeBytes).toBe(600)
751
+ })
752
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawport-ui",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
5
5
  "homepage": "https://clawport.dev",
6
6
  "repository": {