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.
- package/app/agents/[id]/page.tsx +3 -2
- package/app/page.tsx +2 -2
- package/components/GlobalSearch.tsx +1 -1
- package/components/NavLinks.tsx +5 -5
- package/lib/setup-detection.test.ts +416 -0
- package/lib/setup-detection.ts +106 -0
- package/lib/setup-scenarios.test.ts +752 -0
- package/package.json +1 -1
package/app/agents/[id]/page.tsx
CHANGED
|
@@ -278,10 +278,11 @@ export default function AgentDetailPage({
|
|
|
278
278
|
return r.json()
|
|
279
279
|
}),
|
|
280
280
|
])
|
|
281
|
-
.then(([agents,
|
|
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(
|
|
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,
|
|
141
|
+
.then(([a, cronData]) => {
|
|
142
142
|
setAgents(a)
|
|
143
|
-
setCrons(
|
|
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
|
-
|
|
169
|
+
setCrons(Array.isArray(data) ? data as CronJob[] : (data as { crons?: CronJob[] })?.crons ?? []);
|
|
170
170
|
})
|
|
171
171
|
.catch(() => setCrons([]));
|
|
172
172
|
}, [open]);
|
package/components/NavLinks.tsx
CHANGED
|
@@ -72,11 +72,11 @@ export function NavLinks() {
|
|
|
72
72
|
return r.json();
|
|
73
73
|
})
|
|
74
74
|
.then((data: unknown) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
})
|