agentlife 1.0.0
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/dev-dashboard.ts +238 -0
- package/index.test.ts +1905 -0
- package/index.ts +1433 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +11 -0
package/index.test.ts
ADDED
|
@@ -0,0 +1,1905 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Mock API factory
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function createMockApi(configOverride?: { agents?: { list: any[] } }) {
|
|
12
|
+
const services: { id: string; start: Function; stop?: Function }[] = [];
|
|
13
|
+
const hooks: { events: string; handler: Function; opts?: any }[] = [];
|
|
14
|
+
const gatewayMethods: { method: string; handler: Function }[] = [];
|
|
15
|
+
const onHooks: { hookName: string; handler: Function }[] = [];
|
|
16
|
+
|
|
17
|
+
const configData: any = configOverride ?? { agents: { list: [] } };
|
|
18
|
+
const writeConfigCalls: any[] = [];
|
|
19
|
+
|
|
20
|
+
const api = {
|
|
21
|
+
id: "agentlife",
|
|
22
|
+
name: "AgentLife",
|
|
23
|
+
source: "test",
|
|
24
|
+
config: {},
|
|
25
|
+
pluginConfig: {},
|
|
26
|
+
runtime: {
|
|
27
|
+
config: {
|
|
28
|
+
loadConfig: vi.fn(() => configData),
|
|
29
|
+
writeConfigFile: vi.fn(async (cfg: any) => {
|
|
30
|
+
writeConfigCalls.push(structuredClone(cfg));
|
|
31
|
+
Object.assign(configData, cfg);
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
36
|
+
registerService: vi.fn((s: any) => services.push(s)),
|
|
37
|
+
registerHook: vi.fn((events: string, handler: Function, opts?: any) =>
|
|
38
|
+
hooks.push({ events, handler, opts }),
|
|
39
|
+
),
|
|
40
|
+
registerGatewayMethod: vi.fn((method: string, handler: Function) =>
|
|
41
|
+
gatewayMethods.push({ method, handler }),
|
|
42
|
+
),
|
|
43
|
+
on: vi.fn((hookName: string, handler: Function) =>
|
|
44
|
+
onHooks.push({ hookName, handler }),
|
|
45
|
+
),
|
|
46
|
+
resolvePath: vi.fn((p: string) => p),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { api, services, hooks, gatewayMethods, onHooks, writeConfigCalls, configData };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// 1. Plugin registration
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe("plugin registration", () => {
|
|
57
|
+
it("registers expected services, hooks, and gateway methods", async () => {
|
|
58
|
+
vi.resetModules();
|
|
59
|
+
const { default: register } = await import("./index.ts");
|
|
60
|
+
const { api, services, hooks, gatewayMethods, onHooks } = createMockApi();
|
|
61
|
+
register(api as any);
|
|
62
|
+
|
|
63
|
+
expect(services.map((s) => s.id)).toEqual([
|
|
64
|
+
"agentlife-surfaces",
|
|
65
|
+
"agentlife-provisioning",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
expect(hooks).toHaveLength(1);
|
|
69
|
+
expect(hooks[0].events).toBe("agent:bootstrap");
|
|
70
|
+
|
|
71
|
+
expect(onHooks).toHaveLength(1);
|
|
72
|
+
expect(onHooks[0].hookName).toBe("after_tool_call");
|
|
73
|
+
|
|
74
|
+
expect(gatewayMethods.map((m) => m.method).sort()).toEqual([
|
|
75
|
+
"agentlife.agents",
|
|
76
|
+
"agentlife.bootstrap",
|
|
77
|
+
"agentlife.createAgent",
|
|
78
|
+
"agentlife.db.exec",
|
|
79
|
+
"agentlife.db.query",
|
|
80
|
+
"agentlife.dismiss",
|
|
81
|
+
"agentlife.history",
|
|
82
|
+
"agentlife.surfaces",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// 2. Orchestrator prompt content
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe("orchestrator prompt content", () => {
|
|
92
|
+
let tmpDir: string;
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
96
|
+
vi.stubEnv("HOME", tmpDir);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(async () => {
|
|
100
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("contains correct routing instructions", async () => {
|
|
104
|
+
vi.resetModules();
|
|
105
|
+
const { default: register } = await import("./index.ts");
|
|
106
|
+
const { api, services } = createMockApi();
|
|
107
|
+
register(api as any);
|
|
108
|
+
|
|
109
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
110
|
+
|
|
111
|
+
const agentsMd = await fs.readFile(
|
|
112
|
+
path.join(tmpDir, ".openclaw", "workspace-agentlife", "AGENTS.md"),
|
|
113
|
+
"utf-8",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(agentsMd).toContain("You are a message router");
|
|
117
|
+
expect(agentsMd).toContain("Never answer directly");
|
|
118
|
+
expect(agentsMd).toContain("Never use the canvas tool");
|
|
119
|
+
expect(agentsMd).toContain("AGENT_REGISTRY.md");
|
|
120
|
+
expect(agentsMd).toContain("agents_list");
|
|
121
|
+
expect(agentsMd).toContain("sessions_send");
|
|
122
|
+
expect(agentsMd).toContain("sessions_spawn");
|
|
123
|
+
expect(agentsMd).toContain("Multi-Agent Fan-Out");
|
|
124
|
+
expect(agentsMd).toContain("agentlife-builder");
|
|
125
|
+
expect(agentsMd).toContain("Not a chatbot");
|
|
126
|
+
expect(agentsMd).toContain("you ARE the assistant");
|
|
127
|
+
expect(agentsMd).toContain("Cannot reach");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// 3. Builder prompt content
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describe("builder prompt content", () => {
|
|
136
|
+
let tmpDir: string;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
140
|
+
vi.stubEnv("HOME", tmpDir);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(async () => {
|
|
144
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("contains correct builder instructions", async () => {
|
|
148
|
+
vi.resetModules();
|
|
149
|
+
const { default: register } = await import("./index.ts");
|
|
150
|
+
const { api, services } = createMockApi();
|
|
151
|
+
register(api as any);
|
|
152
|
+
|
|
153
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
154
|
+
|
|
155
|
+
const agentsMd = await fs.readFile(
|
|
156
|
+
path.join(
|
|
157
|
+
tmpDir,
|
|
158
|
+
".openclaw",
|
|
159
|
+
"workspace-agentlife-builder",
|
|
160
|
+
"AGENTS.md",
|
|
161
|
+
),
|
|
162
|
+
"utf-8",
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(agentsMd).toContain(
|
|
166
|
+
"expert at creating and improving OpenClaw agents",
|
|
167
|
+
);
|
|
168
|
+
expect(agentsMd).toContain("Interactive Overlays");
|
|
169
|
+
expect(agentsMd).toContain("NEVER ask multiple questions in text");
|
|
170
|
+
expect(agentsMd).toContain("overlay");
|
|
171
|
+
expect(agentsMd).toContain("agentlife.createAgent");
|
|
172
|
+
expect(agentsMd).toContain("description");
|
|
173
|
+
// Model guide should NOT be present — models are provider-agnostic
|
|
174
|
+
expect(agentsMd).not.toContain("Default to **sonnet**");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// 4. Agent provisioning
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe("agent provisioning", () => {
|
|
183
|
+
let tmpDir: string;
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
187
|
+
vi.stubEnv("HOME", tmpDir);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(async () => {
|
|
191
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("creates workspace directories for both agents", async () => {
|
|
195
|
+
vi.resetModules();
|
|
196
|
+
const { default: register } = await import("./index.ts");
|
|
197
|
+
const { api, services } = createMockApi();
|
|
198
|
+
register(api as any);
|
|
199
|
+
|
|
200
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
201
|
+
|
|
202
|
+
const orchestratorStat = await fs.stat(
|
|
203
|
+
path.join(tmpDir, ".openclaw", "workspace-agentlife"),
|
|
204
|
+
);
|
|
205
|
+
expect(orchestratorStat.isDirectory()).toBe(true);
|
|
206
|
+
|
|
207
|
+
const builderStat = await fs.stat(
|
|
208
|
+
path.join(tmpDir, ".openclaw", "workspace-agentlife-builder"),
|
|
209
|
+
);
|
|
210
|
+
expect(builderStat.isDirectory()).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("writes AGENTS.md in each workspace", async () => {
|
|
214
|
+
vi.resetModules();
|
|
215
|
+
const { default: register } = await import("./index.ts");
|
|
216
|
+
const { api, services } = createMockApi();
|
|
217
|
+
register(api as any);
|
|
218
|
+
|
|
219
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
220
|
+
|
|
221
|
+
const orchestratorMd = await fs.readFile(
|
|
222
|
+
path.join(tmpDir, ".openclaw", "workspace-agentlife", "AGENTS.md"),
|
|
223
|
+
"utf-8",
|
|
224
|
+
);
|
|
225
|
+
expect(orchestratorMd.length).toBeGreaterThan(0);
|
|
226
|
+
|
|
227
|
+
const builderMd = await fs.readFile(
|
|
228
|
+
path.join(
|
|
229
|
+
tmpDir,
|
|
230
|
+
".openclaw",
|
|
231
|
+
"workspace-agentlife-builder",
|
|
232
|
+
"AGENTS.md",
|
|
233
|
+
),
|
|
234
|
+
"utf-8",
|
|
235
|
+
);
|
|
236
|
+
expect(builderMd.length).toBeGreaterThan(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("writes empty stub files to prevent boilerplate", async () => {
|
|
240
|
+
vi.resetModules();
|
|
241
|
+
const { default: register } = await import("./index.ts");
|
|
242
|
+
const { api, services } = createMockApi();
|
|
243
|
+
register(api as any);
|
|
244
|
+
|
|
245
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
246
|
+
|
|
247
|
+
const stubs = [
|
|
248
|
+
"SOUL.md",
|
|
249
|
+
"TOOLS.md",
|
|
250
|
+
"IDENTITY.md",
|
|
251
|
+
"USER.md",
|
|
252
|
+
"HEARTBEAT.md",
|
|
253
|
+
"BOOTSTRAP.md",
|
|
254
|
+
];
|
|
255
|
+
for (const stub of stubs) {
|
|
256
|
+
const content = await fs.readFile(
|
|
257
|
+
path.join(tmpDir, ".openclaw", "workspace-agentlife", stub),
|
|
258
|
+
"utf-8",
|
|
259
|
+
);
|
|
260
|
+
expect(content).toBe("");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("adds both agents to config with orchestrator first and default", async () => {
|
|
265
|
+
vi.resetModules();
|
|
266
|
+
const { default: register } = await import("./index.ts");
|
|
267
|
+
const { api, services, writeConfigCalls } = createMockApi();
|
|
268
|
+
register(api as any);
|
|
269
|
+
|
|
270
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
271
|
+
|
|
272
|
+
expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
|
|
273
|
+
const list = writeConfigCalls[0].agents.list;
|
|
274
|
+
|
|
275
|
+
expect(list[0].id).toBe("agentlife");
|
|
276
|
+
expect(list[0].default).toBe(true);
|
|
277
|
+
expect(list[0].subagents).toEqual({ allowAgents: ["*"] });
|
|
278
|
+
|
|
279
|
+
expect(list[1].id).toBe("agentlife-builder");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("is idempotent — second run does not call writeConfigFile", async () => {
|
|
283
|
+
vi.resetModules();
|
|
284
|
+
const { default: register } = await import("./index.ts");
|
|
285
|
+
const { api, services } = createMockApi();
|
|
286
|
+
register(api as any);
|
|
287
|
+
|
|
288
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
289
|
+
expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
|
|
290
|
+
|
|
291
|
+
vi.clearAllMocks();
|
|
292
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
293
|
+
expect(api.runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("preserves existing user agents in list", async () => {
|
|
297
|
+
vi.resetModules();
|
|
298
|
+
const { default: register } = await import("./index.ts");
|
|
299
|
+
const userAgent = {
|
|
300
|
+
id: "my-agent",
|
|
301
|
+
name: "My Agent",
|
|
302
|
+
workspace: "/tmp/my-agent",
|
|
303
|
+
};
|
|
304
|
+
const { api, services, writeConfigCalls } = createMockApi({
|
|
305
|
+
agents: { list: [userAgent] },
|
|
306
|
+
});
|
|
307
|
+
register(api as any);
|
|
308
|
+
|
|
309
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
310
|
+
|
|
311
|
+
const list = writeConfigCalls[0].agents.list;
|
|
312
|
+
expect(list.some((a: any) => a.id === "my-agent")).toBe(true);
|
|
313
|
+
expect(list[0].id).toBe("agentlife");
|
|
314
|
+
expect(list[list.length - 1].id).toBe("agentlife-builder");
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// 5. Bootstrap injection
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
describe("bootstrap injection", () => {
|
|
323
|
+
let tmpDir: string;
|
|
324
|
+
let register: (api: any) => void;
|
|
325
|
+
let api: ReturnType<typeof createMockApi>["api"];
|
|
326
|
+
let hooks: ReturnType<typeof createMockApi>["hooks"];
|
|
327
|
+
let services: ReturnType<typeof createMockApi>["services"];
|
|
328
|
+
let gatewayMethods: ReturnType<typeof createMockApi>["gatewayMethods"];
|
|
329
|
+
|
|
330
|
+
beforeEach(async () => {
|
|
331
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
332
|
+
vi.resetModules();
|
|
333
|
+
const mod = await import("./index.ts");
|
|
334
|
+
register = mod.default;
|
|
335
|
+
const mock = createMockApi();
|
|
336
|
+
api = mock.api;
|
|
337
|
+
hooks = mock.hooks;
|
|
338
|
+
services = mock.services;
|
|
339
|
+
gatewayMethods = mock.gatewayMethods;
|
|
340
|
+
register(api as any);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
afterEach(async () => {
|
|
344
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
function bootstrapHandler() {
|
|
348
|
+
return hooks.find((h) => h.events === "agent:bootstrap")!.handler;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
it("replaces TOOLS.md with TENAZITAS_GUIDANCE for regular agents", () => {
|
|
352
|
+
const bootstrapFiles = [
|
|
353
|
+
{
|
|
354
|
+
name: "TOOLS.md",
|
|
355
|
+
path: "/some/path/TOOLS.md",
|
|
356
|
+
content: "original",
|
|
357
|
+
missing: false,
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
bootstrapHandler()({ context: { bootstrapFiles, agentId: "some-agent" } });
|
|
361
|
+
|
|
362
|
+
const toolsFile = bootstrapFiles.find((f) => f.name === "TOOLS.md")!;
|
|
363
|
+
expect(toolsFile.content).toContain("a2ui_push");
|
|
364
|
+
expect(toolsFile.content).toContain("WidgetDSL");
|
|
365
|
+
expect(toolsFile.content).toContain("overlay");
|
|
366
|
+
expect(toolsFile.path).toBe("agentlife://a2ui-guidance");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("injects TENAZITAS_GUIDANCE for builder agent (not skipped)", () => {
|
|
370
|
+
const bootstrapFiles = [
|
|
371
|
+
{
|
|
372
|
+
name: "TOOLS.md",
|
|
373
|
+
path: "/some/path/TOOLS.md",
|
|
374
|
+
content: "original",
|
|
375
|
+
missing: false,
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
bootstrapHandler()({
|
|
379
|
+
context: { bootstrapFiles, agentId: "agentlife-builder" },
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const toolsFile = bootstrapFiles.find((f) => f.name === "TOOLS.md")!;
|
|
383
|
+
expect(toolsFile.content).toContain("a2ui_push");
|
|
384
|
+
expect(toolsFile.content).toContain("WidgetDSL");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("does NOT replace TOOLS.md for orchestrator", () => {
|
|
388
|
+
const bootstrapFiles = [
|
|
389
|
+
{
|
|
390
|
+
name: "TOOLS.md",
|
|
391
|
+
path: "/path/TOOLS.md",
|
|
392
|
+
content: "original tools",
|
|
393
|
+
missing: false,
|
|
394
|
+
},
|
|
395
|
+
];
|
|
396
|
+
bootstrapHandler()({
|
|
397
|
+
context: { bootstrapFiles, agentId: "agentlife" },
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const toolsFile = bootstrapFiles.find((f) => f.name === "TOOLS.md")!;
|
|
401
|
+
expect(toolsFile.content).toBe("original tools");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("injects AGENT_REGISTRY.md for orchestrator when registry has entries", async () => {
|
|
405
|
+
await services
|
|
406
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
407
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
408
|
+
|
|
409
|
+
const createAgent = gatewayMethods.find(
|
|
410
|
+
(m) => m.method === "agentlife.createAgent",
|
|
411
|
+
)!.handler;
|
|
412
|
+
await createAgent({
|
|
413
|
+
params: {
|
|
414
|
+
id: "test-specialist",
|
|
415
|
+
name: "Test",
|
|
416
|
+
workspace: "/tmp/test",
|
|
417
|
+
description: "Runs automated tests and validates code quality across the codebase",
|
|
418
|
+
},
|
|
419
|
+
respond: vi.fn(),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const bootstrapFiles: any[] = [];
|
|
423
|
+
bootstrapHandler()({
|
|
424
|
+
context: { bootstrapFiles, agentId: "agentlife" },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const regFile = bootstrapFiles.find(
|
|
428
|
+
(f: any) => f.name === "AGENT_REGISTRY.md",
|
|
429
|
+
);
|
|
430
|
+
expect(regFile).toBeTruthy();
|
|
431
|
+
expect(regFile.content).toContain("test-specialist");
|
|
432
|
+
expect(regFile.content).toContain("Runs automated tests");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("does NOT inject AGENT_REGISTRY.md for orchestrator with empty registry", () => {
|
|
436
|
+
const bootstrapFiles: any[] = [];
|
|
437
|
+
bootstrapHandler()({
|
|
438
|
+
context: { bootstrapFiles, agentId: "agentlife" },
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const regFile = bootstrapFiles.find(
|
|
442
|
+
(f: any) => f.name === "AGENT_REGISTRY.md",
|
|
443
|
+
);
|
|
444
|
+
expect(regFile).toBeUndefined();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("handles missing bootstrapFiles without crashing", () => {
|
|
448
|
+
expect(() => bootstrapHandler()({ context: {} })).not.toThrow();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("replaces existing TOOLS.md without creating duplicates", () => {
|
|
452
|
+
const bootstrapFiles = [
|
|
453
|
+
{
|
|
454
|
+
name: "AGENTS.md",
|
|
455
|
+
path: "/path/AGENTS.md",
|
|
456
|
+
content: "agent stuff",
|
|
457
|
+
missing: false,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "TOOLS.md",
|
|
461
|
+
path: "/path/TOOLS.md",
|
|
462
|
+
content: "old tools",
|
|
463
|
+
missing: false,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "SOUL.md",
|
|
467
|
+
path: "/path/SOUL.md",
|
|
468
|
+
content: "soul",
|
|
469
|
+
missing: false,
|
|
470
|
+
},
|
|
471
|
+
];
|
|
472
|
+
bootstrapHandler()({
|
|
473
|
+
context: { bootstrapFiles, agentId: "some-agent" },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const toolsFiles = bootstrapFiles.filter((f) => f.name === "TOOLS.md");
|
|
477
|
+
expect(toolsFiles).toHaveLength(1);
|
|
478
|
+
expect(toolsFiles[0].content).toContain("a2ui_push");
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// 6. Gateway: agentlife.createAgent
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
describe("gateway: agentlife.createAgent", () => {
|
|
487
|
+
let tmpDir: string;
|
|
488
|
+
|
|
489
|
+
beforeEach(() => {
|
|
490
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
afterEach(async () => {
|
|
494
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("creates new agent and responds with status created", async () => {
|
|
498
|
+
vi.resetModules();
|
|
499
|
+
const { default: register } = await import("./index.ts");
|
|
500
|
+
const { api, services, gatewayMethods } = createMockApi();
|
|
501
|
+
register(api as any);
|
|
502
|
+
|
|
503
|
+
await services
|
|
504
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
505
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
506
|
+
|
|
507
|
+
const createAgent = gatewayMethods.find(
|
|
508
|
+
(m) => m.method === "agentlife.createAgent",
|
|
509
|
+
)!.handler;
|
|
510
|
+
const respond = vi.fn();
|
|
511
|
+
await createAgent({
|
|
512
|
+
params: {
|
|
513
|
+
id: "new-agent",
|
|
514
|
+
name: "New Agent",
|
|
515
|
+
workspace: "/tmp/new-agent",
|
|
516
|
+
description: "Does new things",
|
|
517
|
+
},
|
|
518
|
+
respond,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(respond).toHaveBeenCalledWith(
|
|
522
|
+
true,
|
|
523
|
+
expect.objectContaining({ status: "created", id: "new-agent" }),
|
|
524
|
+
);
|
|
525
|
+
expect(api.runtime.config.writeConfigFile).toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("responds with status exists for duplicate agent", async () => {
|
|
529
|
+
vi.resetModules();
|
|
530
|
+
const { default: register } = await import("./index.ts");
|
|
531
|
+
const { api, services, gatewayMethods } = createMockApi({
|
|
532
|
+
agents: { list: [{ id: "existing-agent", workspace: "/tmp/existing" }] },
|
|
533
|
+
});
|
|
534
|
+
register(api as any);
|
|
535
|
+
|
|
536
|
+
await services
|
|
537
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
538
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
539
|
+
|
|
540
|
+
const createAgent = gatewayMethods.find(
|
|
541
|
+
(m) => m.method === "agentlife.createAgent",
|
|
542
|
+
)!.handler;
|
|
543
|
+
const respond = vi.fn();
|
|
544
|
+
await createAgent({
|
|
545
|
+
params: {
|
|
546
|
+
id: "existing-agent",
|
|
547
|
+
name: "Existing",
|
|
548
|
+
workspace: "/tmp/existing",
|
|
549
|
+
description: "Already there",
|
|
550
|
+
},
|
|
551
|
+
respond,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
expect(respond).toHaveBeenCalledWith(
|
|
555
|
+
true,
|
|
556
|
+
expect.objectContaining({ status: "exists" }),
|
|
557
|
+
);
|
|
558
|
+
expect(api.runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("always updates registry when description provided", async () => {
|
|
562
|
+
vi.resetModules();
|
|
563
|
+
const { default: register } = await import("./index.ts");
|
|
564
|
+
const { api, services, gatewayMethods } = createMockApi({
|
|
565
|
+
agents: { list: [{ id: "existing-agent", workspace: "/tmp/existing" }] },
|
|
566
|
+
});
|
|
567
|
+
register(api as any);
|
|
568
|
+
|
|
569
|
+
await services
|
|
570
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
571
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
572
|
+
|
|
573
|
+
const createAgent = gatewayMethods.find(
|
|
574
|
+
(m) => m.method === "agentlife.createAgent",
|
|
575
|
+
)!.handler;
|
|
576
|
+
await createAgent({
|
|
577
|
+
params: {
|
|
578
|
+
id: "existing-agent",
|
|
579
|
+
name: "Updated",
|
|
580
|
+
workspace: "/tmp/existing",
|
|
581
|
+
description: "Monitors and updates existing agent configurations and workspaces",
|
|
582
|
+
},
|
|
583
|
+
respond: vi.fn(),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const agentsMethod = gatewayMethods.find(
|
|
587
|
+
(m) => m.method === "agentlife.agents",
|
|
588
|
+
)!.handler;
|
|
589
|
+
const respond = vi.fn();
|
|
590
|
+
agentsMethod({ respond });
|
|
591
|
+
|
|
592
|
+
expect(respond).toHaveBeenCalledWith(
|
|
593
|
+
true,
|
|
594
|
+
expect.objectContaining({
|
|
595
|
+
count: 1,
|
|
596
|
+
agents: expect.objectContaining({
|
|
597
|
+
"existing-agent": expect.objectContaining({
|
|
598
|
+
description: "Monitors and updates existing agent configurations and workspaces",
|
|
599
|
+
}),
|
|
600
|
+
}),
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("rejects missing id", async () => {
|
|
606
|
+
vi.resetModules();
|
|
607
|
+
const { default: register } = await import("./index.ts");
|
|
608
|
+
const { api, gatewayMethods } = createMockApi();
|
|
609
|
+
register(api as any);
|
|
610
|
+
|
|
611
|
+
const createAgent = gatewayMethods.find(
|
|
612
|
+
(m) => m.method === "agentlife.createAgent",
|
|
613
|
+
)!.handler;
|
|
614
|
+
const respond = vi.fn();
|
|
615
|
+
await createAgent({ params: { workspace: "/tmp/test" }, respond });
|
|
616
|
+
|
|
617
|
+
expect(respond).toHaveBeenCalledWith(false, {
|
|
618
|
+
error: "missing agent id",
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("rejects missing workspace", async () => {
|
|
623
|
+
vi.resetModules();
|
|
624
|
+
const { default: register } = await import("./index.ts");
|
|
625
|
+
const { api, gatewayMethods } = createMockApi();
|
|
626
|
+
register(api as any);
|
|
627
|
+
|
|
628
|
+
const createAgent = gatewayMethods.find(
|
|
629
|
+
(m) => m.method === "agentlife.createAgent",
|
|
630
|
+
)!.handler;
|
|
631
|
+
const respond = vi.fn();
|
|
632
|
+
await createAgent({ params: { id: "test-agent" }, respond });
|
|
633
|
+
|
|
634
|
+
expect(respond).toHaveBeenCalledWith(false, {
|
|
635
|
+
error: "missing workspace path",
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("omits model when not specified (provider-agnostic)", async () => {
|
|
640
|
+
vi.resetModules();
|
|
641
|
+
const { default: register } = await import("./index.ts");
|
|
642
|
+
const { api, services, gatewayMethods } = createMockApi();
|
|
643
|
+
register(api as any);
|
|
644
|
+
|
|
645
|
+
await services
|
|
646
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
647
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
648
|
+
|
|
649
|
+
const createAgent = gatewayMethods.find(
|
|
650
|
+
(m) => m.method === "agentlife.createAgent",
|
|
651
|
+
)!.handler;
|
|
652
|
+
const respond = vi.fn();
|
|
653
|
+
await createAgent({
|
|
654
|
+
params: { id: "no-model-agent", workspace: "/tmp/test" },
|
|
655
|
+
respond,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
expect(respond).toHaveBeenCalledWith(
|
|
659
|
+
true,
|
|
660
|
+
expect.objectContaining({ model: "" }),
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// 7. Gateway: agentlife.agents
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
describe("gateway: agentlife.agents", () => {
|
|
670
|
+
let tmpDir: string;
|
|
671
|
+
|
|
672
|
+
beforeEach(() => {
|
|
673
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
afterEach(async () => {
|
|
677
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("returns empty when no agents registered", async () => {
|
|
681
|
+
vi.resetModules();
|
|
682
|
+
const { default: register } = await import("./index.ts");
|
|
683
|
+
const { api, gatewayMethods } = createMockApi();
|
|
684
|
+
register(api as any);
|
|
685
|
+
|
|
686
|
+
const agentsMethod = gatewayMethods.find(
|
|
687
|
+
(m) => m.method === "agentlife.agents",
|
|
688
|
+
)!.handler;
|
|
689
|
+
const respond = vi.fn();
|
|
690
|
+
agentsMethod({ respond });
|
|
691
|
+
|
|
692
|
+
expect(respond).toHaveBeenCalledWith(true, { agents: {}, count: 0 });
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("returns agent with description after createAgent", async () => {
|
|
696
|
+
vi.resetModules();
|
|
697
|
+
const { default: register } = await import("./index.ts");
|
|
698
|
+
const { api, services, gatewayMethods } = createMockApi();
|
|
699
|
+
register(api as any);
|
|
700
|
+
|
|
701
|
+
await services
|
|
702
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
703
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
704
|
+
|
|
705
|
+
const createAgent = gatewayMethods.find(
|
|
706
|
+
(m) => m.method === "agentlife.createAgent",
|
|
707
|
+
)!.handler;
|
|
708
|
+
await createAgent({
|
|
709
|
+
params: {
|
|
710
|
+
id: "weather-agent",
|
|
711
|
+
name: "Weather",
|
|
712
|
+
workspace: "/tmp/weather",
|
|
713
|
+
description: "Provides weather info",
|
|
714
|
+
},
|
|
715
|
+
respond: vi.fn(),
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const agentsMethod = gatewayMethods.find(
|
|
719
|
+
(m) => m.method === "agentlife.agents",
|
|
720
|
+
)!.handler;
|
|
721
|
+
const respond = vi.fn();
|
|
722
|
+
agentsMethod({ respond });
|
|
723
|
+
|
|
724
|
+
expect(respond).toHaveBeenCalledWith(
|
|
725
|
+
true,
|
|
726
|
+
expect.objectContaining({
|
|
727
|
+
count: 1,
|
|
728
|
+
agents: expect.objectContaining({
|
|
729
|
+
"weather-agent": expect.objectContaining({
|
|
730
|
+
name: "Weather",
|
|
731
|
+
description: "Provides weather info",
|
|
732
|
+
}),
|
|
733
|
+
}),
|
|
734
|
+
}),
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// 8. End-to-end flow
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
describe("end-to-end flow", () => {
|
|
744
|
+
let tmpDir: string;
|
|
745
|
+
|
|
746
|
+
beforeEach(() => {
|
|
747
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
748
|
+
vi.stubEnv("HOME", tmpDir);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
afterEach(async () => {
|
|
752
|
+
// Flush any in-flight scheduleSave setImmediate + async saveToDisk
|
|
753
|
+
await new Promise((r) => setImmediate(r));
|
|
754
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
755
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("register → provision → createAgent → bootstrap orchestrator → bootstrap specialist", async () => {
|
|
759
|
+
vi.resetModules();
|
|
760
|
+
const { default: register } = await import("./index.ts");
|
|
761
|
+
const { api, services, hooks, gatewayMethods } = createMockApi();
|
|
762
|
+
register(api as any);
|
|
763
|
+
|
|
764
|
+
// 1. Start surfaces service
|
|
765
|
+
await services
|
|
766
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
767
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
768
|
+
|
|
769
|
+
// 2. Start provisioning
|
|
770
|
+
await services
|
|
771
|
+
.find((s) => s.id === "agentlife-provisioning")!
|
|
772
|
+
.start();
|
|
773
|
+
|
|
774
|
+
// 3. Create a specialist agent with description
|
|
775
|
+
const createAgent = gatewayMethods.find(
|
|
776
|
+
(m) => m.method === "agentlife.createAgent",
|
|
777
|
+
)!.handler;
|
|
778
|
+
await createAgent({
|
|
779
|
+
params: {
|
|
780
|
+
id: "weather",
|
|
781
|
+
name: "Weather Bot",
|
|
782
|
+
workspace: "/tmp/weather",
|
|
783
|
+
description: "Provides weather forecasts and alerts",
|
|
784
|
+
},
|
|
785
|
+
respond: vi.fn(),
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// 4. Bootstrap orchestrator — should get AGENT_REGISTRY.md
|
|
789
|
+
const bootstrap = hooks.find(
|
|
790
|
+
(h) => h.events === "agent:bootstrap",
|
|
791
|
+
)!.handler;
|
|
792
|
+
const orchestratorFiles: any[] = [];
|
|
793
|
+
bootstrap({
|
|
794
|
+
context: { bootstrapFiles: orchestratorFiles, agentId: "agentlife" },
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const regFile = orchestratorFiles.find(
|
|
798
|
+
(f: any) => f.name === "AGENT_REGISTRY.md",
|
|
799
|
+
);
|
|
800
|
+
expect(regFile).toBeTruthy();
|
|
801
|
+
expect(regFile.content).toContain("weather");
|
|
802
|
+
expect(regFile.content).toContain(
|
|
803
|
+
"Provides weather forecasts and alerts",
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
// 5. Bootstrap specialist — should get TENAZITAS_GUIDANCE
|
|
807
|
+
const specialistFiles: any[] = [
|
|
808
|
+
{
|
|
809
|
+
name: "TOOLS.md",
|
|
810
|
+
path: "/path/TOOLS.md",
|
|
811
|
+
content: "",
|
|
812
|
+
missing: true,
|
|
813
|
+
},
|
|
814
|
+
];
|
|
815
|
+
bootstrap({
|
|
816
|
+
context: { bootstrapFiles: specialistFiles, agentId: "weather" },
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const toolsFile = specialistFiles.find(
|
|
820
|
+
(f: any) => f.name === "TOOLS.md",
|
|
821
|
+
);
|
|
822
|
+
expect(toolsFile).toBeTruthy();
|
|
823
|
+
expect(toolsFile.content).toContain("a2ui_push");
|
|
824
|
+
expect(toolsFile.content).toContain("WidgetDSL");
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// 9. after_tool_call hook (surface capture)
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
describe("after_tool_call hook", () => {
|
|
833
|
+
let afterToolCall: Function;
|
|
834
|
+
let surfacesMethod: Function;
|
|
835
|
+
|
|
836
|
+
beforeEach(async () => {
|
|
837
|
+
vi.resetModules();
|
|
838
|
+
const { default: register } = await import("./index.ts");
|
|
839
|
+
const { api, onHooks, gatewayMethods } = createMockApi();
|
|
840
|
+
register(api as any);
|
|
841
|
+
|
|
842
|
+
afterToolCall = onHooks.find(
|
|
843
|
+
(h) => h.hookName === "after_tool_call",
|
|
844
|
+
)!.handler;
|
|
845
|
+
surfacesMethod = gatewayMethods.find(
|
|
846
|
+
(m) => m.method === "agentlife.surfaces",
|
|
847
|
+
)!.handler;
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
function pushDsl(dsl: string) {
|
|
851
|
+
afterToolCall({
|
|
852
|
+
toolName: "canvas",
|
|
853
|
+
params: { action: "a2ui_push", jsonl: dsl },
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function getSurfaces(): { surfaceId: string; dsl: string }[] {
|
|
858
|
+
const respond = vi.fn();
|
|
859
|
+
surfacesMethod({ respond });
|
|
860
|
+
return respond.mock.calls[0][1].surfaces;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
it("stores surface from a2ui_push", () => {
|
|
864
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
865
|
+
const surfaces = getSurfaces();
|
|
866
|
+
expect(surfaces).toHaveLength(1);
|
|
867
|
+
expect(surfaces[0].surfaceId).toBe("weather");
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("stores multiple surfaces separated by ---", () => {
|
|
871
|
+
pushDsl(
|
|
872
|
+
'surface weather w=6\n card\n text "Weather" h3\n---\nsurface stocks w=4\n card\n text "Stocks" h3',
|
|
873
|
+
);
|
|
874
|
+
const surfaces = getSurfaces();
|
|
875
|
+
expect(surfaces).toHaveLength(2);
|
|
876
|
+
expect(surfaces.map((s) => s.surfaceId).sort()).toEqual([
|
|
877
|
+
"stocks",
|
|
878
|
+
"weather",
|
|
879
|
+
]);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("deletes surface via delete command", () => {
|
|
883
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
884
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
885
|
+
|
|
886
|
+
pushDsl("delete weather");
|
|
887
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("does NOT store overlay surfaces", () => {
|
|
891
|
+
pushDsl(
|
|
892
|
+
'surface q1 overlay\n card\n text "Question?" h4\n button "Yes" action=yes',
|
|
893
|
+
);
|
|
894
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it("clears all surfaces on a2ui_reset", () => {
|
|
898
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
899
|
+
pushDsl('surface stocks w=4\n card\n text "Stocks" h3');
|
|
900
|
+
expect(getSurfaces()).toHaveLength(2);
|
|
901
|
+
|
|
902
|
+
afterToolCall({
|
|
903
|
+
toolName: "canvas",
|
|
904
|
+
params: { action: "a2ui_reset" },
|
|
905
|
+
});
|
|
906
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it("ignores non-canvas tool calls", () => {
|
|
910
|
+
afterToolCall({ toolName: "exec", params: { command: "ls" } });
|
|
911
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("ignores canvas calls with other actions", () => {
|
|
915
|
+
afterToolCall({
|
|
916
|
+
toolName: "canvas",
|
|
917
|
+
params: { action: "a2ui_list" },
|
|
918
|
+
});
|
|
919
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("updates surface in place (same surfaceId)", () => {
|
|
923
|
+
pushDsl('surface weather w=6\n card\n text "Loading..." h3');
|
|
924
|
+
pushDsl('surface weather w=6\n card\n text "72F Sunny" h3');
|
|
925
|
+
const surfaces = getSurfaces();
|
|
926
|
+
expect(surfaces).toHaveLength(1);
|
|
927
|
+
expect(surfaces[0].dsl).toContain("72F Sunny");
|
|
928
|
+
expect(surfaces[0].dsl).not.toContain("Loading");
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
// 10. Gateway: agentlife.surfaces (with expiry filtering)
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
|
|
936
|
+
describe("gateway: agentlife.surfaces (expiry filtering)", () => {
|
|
937
|
+
let afterToolCall: Function;
|
|
938
|
+
let surfacesMethod: Function;
|
|
939
|
+
|
|
940
|
+
beforeEach(async () => {
|
|
941
|
+
vi.useFakeTimers();
|
|
942
|
+
vi.setSystemTime(new Date("2026-03-05T12:00:00Z"));
|
|
943
|
+
vi.resetModules();
|
|
944
|
+
const { default: register } = await import("./index.ts");
|
|
945
|
+
const { api, onHooks, gatewayMethods } = createMockApi();
|
|
946
|
+
register(api as any);
|
|
947
|
+
|
|
948
|
+
afterToolCall = onHooks.find(
|
|
949
|
+
(h) => h.hookName === "after_tool_call",
|
|
950
|
+
)!.handler;
|
|
951
|
+
surfacesMethod = gatewayMethods.find(
|
|
952
|
+
(m) => m.method === "agentlife.surfaces",
|
|
953
|
+
)!.handler;
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
afterEach(() => {
|
|
957
|
+
vi.useRealTimers();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
function pushDsl(dsl: string) {
|
|
961
|
+
afterToolCall({
|
|
962
|
+
toolName: "canvas",
|
|
963
|
+
params: { action: "a2ui_push", jsonl: dsl },
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function getSurfaces(): { surfaceId: string; dsl: string }[] {
|
|
968
|
+
const respond = vi.fn();
|
|
969
|
+
surfacesMethod({ respond });
|
|
970
|
+
return respond.mock.calls[0][1].surfaces;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
it("filters out expired surfaces (TTL exceeded)", () => {
|
|
974
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3\n ttl: 30m');
|
|
975
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
976
|
+
|
|
977
|
+
vi.advanceTimersByTime(31 * 60 * 1000);
|
|
978
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("surfaces with ttl=none never expire", () => {
|
|
982
|
+
pushDsl('surface todo w=6\n card\n text "Todo" h3\n ttl: none');
|
|
983
|
+
vi.advanceTimersByTime(30 * 24 * 60 * 60 * 1000); // 30 days
|
|
984
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("filters surfaces past absolute expireAt", () => {
|
|
988
|
+
pushDsl(
|
|
989
|
+
'surface event w=6\n card\n text "Meeting" h3\n expireAt: 2026-03-05T14:00:00Z',
|
|
990
|
+
);
|
|
991
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
992
|
+
|
|
993
|
+
vi.advanceTimersByTime(3 * 60 * 60 * 1000); // +3h → 15:00
|
|
994
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("applies default 7-day ceiling when no lifecycle fields", () => {
|
|
998
|
+
pushDsl('surface plain w=6\n card\n text "Plain" h3');
|
|
999
|
+
vi.advanceTimersByTime(6 * 24 * 60 * 60 * 1000); // 6 days
|
|
1000
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1001
|
+
|
|
1002
|
+
vi.advanceTimersByTime(2 * 24 * 60 * 60 * 1000); // 8 days total
|
|
1003
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("expireHint prevents default 7-day ceiling", () => {
|
|
1007
|
+
pushDsl(
|
|
1008
|
+
'surface meeting w=6\n card\n text "Meeting" h3\n expireHint: after the meeting ends',
|
|
1009
|
+
);
|
|
1010
|
+
vi.advanceTimersByTime(8 * 24 * 60 * 60 * 1000); // 8 days
|
|
1011
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// 11. Gateway: agentlife.dismiss
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
describe("gateway: agentlife.dismiss", () => {
|
|
1020
|
+
let afterToolCall: Function;
|
|
1021
|
+
let dismissMethod: Function;
|
|
1022
|
+
let surfacesMethod: Function;
|
|
1023
|
+
|
|
1024
|
+
beforeEach(async () => {
|
|
1025
|
+
vi.resetModules();
|
|
1026
|
+
const { default: register } = await import("./index.ts");
|
|
1027
|
+
const { api, onHooks, gatewayMethods } = createMockApi();
|
|
1028
|
+
register(api as any);
|
|
1029
|
+
|
|
1030
|
+
afterToolCall = onHooks.find(
|
|
1031
|
+
(h) => h.hookName === "after_tool_call",
|
|
1032
|
+
)!.handler;
|
|
1033
|
+
dismissMethod = gatewayMethods.find(
|
|
1034
|
+
(m) => m.method === "agentlife.dismiss",
|
|
1035
|
+
)!.handler;
|
|
1036
|
+
surfacesMethod = gatewayMethods.find(
|
|
1037
|
+
(m) => m.method === "agentlife.surfaces",
|
|
1038
|
+
)!.handler;
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
function pushDsl(dsl: string) {
|
|
1042
|
+
afterToolCall({
|
|
1043
|
+
toolName: "canvas",
|
|
1044
|
+
params: { action: "a2ui_push", jsonl: dsl },
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function getSurfaces(): { surfaceId: string; dsl: string }[] {
|
|
1049
|
+
const respond = vi.fn();
|
|
1050
|
+
surfacesMethod({ respond });
|
|
1051
|
+
return respond.mock.calls[0][1].surfaces;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
it("dismisses existing surface", () => {
|
|
1055
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
1056
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1057
|
+
|
|
1058
|
+
const respond = vi.fn();
|
|
1059
|
+
dismissMethod({ params: { surfaceId: "weather" }, respond });
|
|
1060
|
+
expect(respond).toHaveBeenCalledWith(true, {
|
|
1061
|
+
surfaceId: "weather",
|
|
1062
|
+
dismissed: true,
|
|
1063
|
+
});
|
|
1064
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it("returns dismissed:false for unknown surface", () => {
|
|
1068
|
+
const respond = vi.fn();
|
|
1069
|
+
dismissMethod({ params: { surfaceId: "nonexistent" }, respond });
|
|
1070
|
+
expect(respond).toHaveBeenCalledWith(true, {
|
|
1071
|
+
surfaceId: "nonexistent",
|
|
1072
|
+
dismissed: false,
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("rejects missing surfaceId", () => {
|
|
1077
|
+
const respond = vi.fn();
|
|
1078
|
+
dismissMethod({ params: {}, respond });
|
|
1079
|
+
expect(respond).toHaveBeenCalledWith(false, {
|
|
1080
|
+
error: "missing surfaceId",
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// ---------------------------------------------------------------------------
|
|
1086
|
+
// 12. Dashboard state in bootstrap
|
|
1087
|
+
// ---------------------------------------------------------------------------
|
|
1088
|
+
|
|
1089
|
+
describe("dashboard state in bootstrap", () => {
|
|
1090
|
+
let afterToolCall: Function;
|
|
1091
|
+
let bootstrapHandler: Function;
|
|
1092
|
+
|
|
1093
|
+
beforeEach(async () => {
|
|
1094
|
+
vi.useFakeTimers();
|
|
1095
|
+
vi.setSystemTime(new Date("2026-03-05T12:00:00Z"));
|
|
1096
|
+
vi.resetModules();
|
|
1097
|
+
const { default: register } = await import("./index.ts");
|
|
1098
|
+
const { api, onHooks, hooks } = createMockApi();
|
|
1099
|
+
register(api as any);
|
|
1100
|
+
|
|
1101
|
+
afterToolCall = onHooks.find(
|
|
1102
|
+
(h) => h.hookName === "after_tool_call",
|
|
1103
|
+
)!.handler;
|
|
1104
|
+
bootstrapHandler = hooks.find(
|
|
1105
|
+
(h) => h.events === "agent:bootstrap",
|
|
1106
|
+
)!.handler;
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
afterEach(() => {
|
|
1110
|
+
vi.useRealTimers();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
function pushDsl(dsl: string) {
|
|
1114
|
+
afterToolCall({
|
|
1115
|
+
toolName: "canvas",
|
|
1116
|
+
params: { action: "a2ui_push", jsonl: dsl },
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function getDashboardState(
|
|
1121
|
+
agentId: string = "some-agent",
|
|
1122
|
+
): string | undefined {
|
|
1123
|
+
const bootstrapFiles: any[] = [
|
|
1124
|
+
{ name: "TOOLS.md", path: "/path/TOOLS.md", content: "", missing: true },
|
|
1125
|
+
];
|
|
1126
|
+
bootstrapHandler({ context: { bootstrapFiles, agentId } });
|
|
1127
|
+
return bootstrapFiles.find(
|
|
1128
|
+
(f: any) => f.name === "DASHBOARD_STATE.md",
|
|
1129
|
+
)?.content;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
it("injects empty dashboard guidance when no surfaces exist", () => {
|
|
1133
|
+
const content = getDashboardState();
|
|
1134
|
+
expect(content).toBeDefined();
|
|
1135
|
+
expect(content).toContain("Dashboard is Empty");
|
|
1136
|
+
expect(content).toContain("suggestion cards");
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("injects active card details for non-expired surfaces", () => {
|
|
1140
|
+
pushDsl(
|
|
1141
|
+
'surface weather w=6\n card\n text "72F Sunny" h3\n detail: Current weather in San Francisco\n ttl: 1h',
|
|
1142
|
+
);
|
|
1143
|
+
const content = getDashboardState();
|
|
1144
|
+
expect(content).toContain("Current Dashboard State");
|
|
1145
|
+
expect(content).toContain("Active Cards");
|
|
1146
|
+
expect(content).toContain("[weather]");
|
|
1147
|
+
expect(content).toContain("72F Sunny");
|
|
1148
|
+
expect(content).toContain("ttl: 1h");
|
|
1149
|
+
expect(content).toContain("Current weather in San Francisco");
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it("shows EXPIRED flag for expired surfaces", () => {
|
|
1153
|
+
pushDsl('surface old w=6\n card\n text "Old" h3\n ttl: 30m');
|
|
1154
|
+
vi.advanceTimersByTime(31 * 60 * 1000);
|
|
1155
|
+
const content = getDashboardState();
|
|
1156
|
+
expect(content).toContain("Dashboard is Empty");
|
|
1157
|
+
expect(content).toContain("EXPIRED");
|
|
1158
|
+
expect(content).toContain("[old]");
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it("shows both active and expired sections when mixed", () => {
|
|
1162
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3\n ttl: 2h');
|
|
1163
|
+
pushDsl('surface news w=6\n card\n text "News" h3\n ttl: 30m');
|
|
1164
|
+
vi.advanceTimersByTime(31 * 60 * 1000);
|
|
1165
|
+
const content = getDashboardState();
|
|
1166
|
+
expect(content).toContain("Current Dashboard State");
|
|
1167
|
+
expect(content).toContain("Active Cards");
|
|
1168
|
+
expect(content).toContain("[weather]");
|
|
1169
|
+
expect(content).toContain("Expired Cards");
|
|
1170
|
+
expect(content).toContain("[news]");
|
|
1171
|
+
expect(content).toContain("EXPIRED");
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it("includes widget size metadata (w= h=)", () => {
|
|
1175
|
+
pushDsl('surface weather w=4 h=3\n card\n text "Weather" h3');
|
|
1176
|
+
const content = getDashboardState();
|
|
1177
|
+
expect(content).toContain("w=4");
|
|
1178
|
+
expect(content).toContain("h=3");
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("does NOT inject dashboard state for orchestrator", () => {
|
|
1182
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
1183
|
+
const content = getDashboardState("agentlife");
|
|
1184
|
+
expect(content).toBeUndefined();
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// ---------------------------------------------------------------------------
|
|
1189
|
+
// 13. Surface lifecycle (TTL, expiry, grace period, disk persistence)
|
|
1190
|
+
// ---------------------------------------------------------------------------
|
|
1191
|
+
|
|
1192
|
+
describe("surface lifecycle", () => {
|
|
1193
|
+
let tmpDir: string;
|
|
1194
|
+
let afterToolCall: Function;
|
|
1195
|
+
let surfacesMethod: Function;
|
|
1196
|
+
let mock: ReturnType<typeof createMockApi>;
|
|
1197
|
+
|
|
1198
|
+
beforeEach(async () => {
|
|
1199
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
1200
|
+
vi.useFakeTimers();
|
|
1201
|
+
vi.setSystemTime(new Date("2026-03-05T12:00:00Z"));
|
|
1202
|
+
vi.resetModules();
|
|
1203
|
+
const { default: register } = await import("./index.ts");
|
|
1204
|
+
mock = createMockApi();
|
|
1205
|
+
register(mock.api as any);
|
|
1206
|
+
|
|
1207
|
+
afterToolCall = mock.onHooks.find(
|
|
1208
|
+
(h) => h.hookName === "after_tool_call",
|
|
1209
|
+
)!.handler;
|
|
1210
|
+
surfacesMethod = mock.gatewayMethods.find(
|
|
1211
|
+
(m) => m.method === "agentlife.surfaces",
|
|
1212
|
+
)!.handler;
|
|
1213
|
+
// NOTE: surfaces service NOT started — avoids scheduleSave disk writes.
|
|
1214
|
+
// Only disk-specific tests start it.
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
afterEach(async () => {
|
|
1218
|
+
vi.useRealTimers();
|
|
1219
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
function pushDsl(dsl: string) {
|
|
1223
|
+
afterToolCall({
|
|
1224
|
+
toolName: "canvas",
|
|
1225
|
+
params: { action: "a2ui_push", jsonl: dsl },
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function getSurfaces(): { surfaceId: string; dsl: string }[] {
|
|
1230
|
+
const respond = vi.fn();
|
|
1231
|
+
surfacesMethod({ respond });
|
|
1232
|
+
return respond.mock.calls[0][1].surfaces;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
it("TTL 30m boundary: present at 29m, gone at 31m", () => {
|
|
1236
|
+
pushDsl('surface w w=6\n card\n text "W" h3\n ttl: 30m');
|
|
1237
|
+
vi.advanceTimersByTime(29 * 60 * 1000);
|
|
1238
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1239
|
+
vi.advanceTimersByTime(2 * 60 * 1000);
|
|
1240
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("TTL 6h boundary", () => {
|
|
1244
|
+
pushDsl('surface d w=6\n card\n text "D" h3\n ttl: 6h');
|
|
1245
|
+
vi.advanceTimersByTime(5 * 60 * 60 * 1000);
|
|
1246
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1247
|
+
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
|
|
1248
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
it("TTL none survives indefinitely", () => {
|
|
1252
|
+
pushDsl('surface p w=6\n card\n text "P" h3\n ttl: none');
|
|
1253
|
+
vi.advanceTimersByTime(365 * 24 * 60 * 60 * 1000);
|
|
1254
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it("expireAt boundary", () => {
|
|
1258
|
+
pushDsl(
|
|
1259
|
+
'surface ev w=6\n card\n text "E" h3\n expireAt: 2026-03-05T14:00:00Z',
|
|
1260
|
+
);
|
|
1261
|
+
vi.advanceTimersByTime(119 * 60 * 1000); // 1h59m → 13:59
|
|
1262
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1263
|
+
vi.advanceTimersByTime(2 * 60 * 1000); // → 14:01
|
|
1264
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
it("default 7-day ceiling boundary", () => {
|
|
1268
|
+
pushDsl('surface x w=6\n card\n text "X" h3');
|
|
1269
|
+
vi.advanceTimersByTime(7 * 24 * 60 * 60 * 1000 - 1000);
|
|
1270
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1271
|
+
vi.advanceTimersByTime(2000);
|
|
1272
|
+
expect(getSurfaces()).toHaveLength(0);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("expireHint prevents default ceiling", () => {
|
|
1276
|
+
pushDsl(
|
|
1277
|
+
'surface m w=6\n card\n text "M" h3\n expireHint: after the meeting',
|
|
1278
|
+
);
|
|
1279
|
+
vi.advanceTimersByTime(8 * 24 * 60 * 60 * 1000);
|
|
1280
|
+
expect(getSurfaces()).toHaveLength(1);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("disk round-trip preserves surface data", async () => {
|
|
1284
|
+
// Start surfaces service so disk writes go through
|
|
1285
|
+
await mock.services
|
|
1286
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1287
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1288
|
+
|
|
1289
|
+
pushDsl(
|
|
1290
|
+
'surface weather w=6\n card\n text "72F" h3\n ttl: 1h\n detail: Weather in SF',
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
// Run the fake setImmediate to invoke saveToDisk(), then switch to real
|
|
1294
|
+
// timers and wait for the async I/O to complete.
|
|
1295
|
+
vi.runAllTimers();
|
|
1296
|
+
vi.useRealTimers();
|
|
1297
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1298
|
+
|
|
1299
|
+
const raw = await fs.readFile(
|
|
1300
|
+
path.join(tmpDir, "agentlife", "surfaces.json"),
|
|
1301
|
+
"utf-8",
|
|
1302
|
+
);
|
|
1303
|
+
const data = JSON.parse(raw);
|
|
1304
|
+
expect(data._version).toBe(2);
|
|
1305
|
+
expect(data.surfaces.weather).toBeDefined();
|
|
1306
|
+
expect(data.surfaces.weather.ttl).toBe("1h");
|
|
1307
|
+
expect(data.surfaces.weather.lines).toContain("surface weather w=6");
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it("load purges surfaces past 24h grace period", async () => {
|
|
1311
|
+
const now = Date.now();
|
|
1312
|
+
const pastGrace = now - 25 * 60 * 60 * 1000; // 25h ago
|
|
1313
|
+
|
|
1314
|
+
// Write a surfaces file with one stale + one fresh entry
|
|
1315
|
+
const surfacesData = {
|
|
1316
|
+
_version: 2,
|
|
1317
|
+
surfaces: {
|
|
1318
|
+
"stale-card": {
|
|
1319
|
+
lines: [
|
|
1320
|
+
"surface stale-card w=6",
|
|
1321
|
+
" card",
|
|
1322
|
+
' text "Stale" h3',
|
|
1323
|
+
],
|
|
1324
|
+
createdAt: pastGrace - 1000,
|
|
1325
|
+
updatedAt: pastGrace - 1000,
|
|
1326
|
+
ttl: "1h",
|
|
1327
|
+
expiredSince: pastGrace,
|
|
1328
|
+
},
|
|
1329
|
+
"fresh-card": {
|
|
1330
|
+
lines: [
|
|
1331
|
+
"surface fresh-card w=6",
|
|
1332
|
+
" card",
|
|
1333
|
+
' text "Fresh" h3',
|
|
1334
|
+
],
|
|
1335
|
+
createdAt: now - 1000,
|
|
1336
|
+
updatedAt: now - 1000,
|
|
1337
|
+
ttl: "none",
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1341
|
+
const dir = path.join(tmpDir, "agentlife");
|
|
1342
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1343
|
+
await fs.writeFile(
|
|
1344
|
+
path.join(dir, "surfaces.json"),
|
|
1345
|
+
JSON.stringify(surfacesData),
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1348
|
+
// Re-import module to get fresh state, load from disk
|
|
1349
|
+
vi.resetModules();
|
|
1350
|
+
const { default: register2 } = await import("./index.ts");
|
|
1351
|
+
const mock2 = createMockApi();
|
|
1352
|
+
register2(mock2.api as any);
|
|
1353
|
+
await mock2.services
|
|
1354
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1355
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1356
|
+
|
|
1357
|
+
const respond = vi.fn();
|
|
1358
|
+
mock2.gatewayMethods
|
|
1359
|
+
.find((m) => m.method === "agentlife.surfaces")!
|
|
1360
|
+
.handler({ respond });
|
|
1361
|
+
const surfaces = respond.mock.calls[0][1].surfaces;
|
|
1362
|
+
|
|
1363
|
+
expect(
|
|
1364
|
+
surfaces.some((s: any) => s.surfaceId === "stale-card"),
|
|
1365
|
+
).toBe(false);
|
|
1366
|
+
expect(
|
|
1367
|
+
surfaces.some((s: any) => s.surfaceId === "fresh-card"),
|
|
1368
|
+
).toBe(true);
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
// ---------------------------------------------------------------------------
|
|
1373
|
+
// 14. Gateway: agentlife.db.exec
|
|
1374
|
+
// ---------------------------------------------------------------------------
|
|
1375
|
+
|
|
1376
|
+
describe("gateway: agentlife.db.exec", () => {
|
|
1377
|
+
let tmpDir: string;
|
|
1378
|
+
let dbExec: Function;
|
|
1379
|
+
let dbQuery: Function;
|
|
1380
|
+
let closeAllDbs: () => void;
|
|
1381
|
+
|
|
1382
|
+
beforeEach(async () => {
|
|
1383
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
1384
|
+
vi.resetModules();
|
|
1385
|
+
const mod = await import("./index.ts");
|
|
1386
|
+
const register = mod.default;
|
|
1387
|
+
closeAllDbs = mod._closeAllDbs;
|
|
1388
|
+
const mock = createMockApi();
|
|
1389
|
+
register(mock.api as any);
|
|
1390
|
+
|
|
1391
|
+
await mock.services
|
|
1392
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1393
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1394
|
+
|
|
1395
|
+
dbExec = mock.gatewayMethods.find(
|
|
1396
|
+
(m) => m.method === "agentlife.db.exec",
|
|
1397
|
+
)!.handler;
|
|
1398
|
+
dbQuery = mock.gatewayMethods.find(
|
|
1399
|
+
(m) => m.method === "agentlife.db.query",
|
|
1400
|
+
)!.handler;
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
afterEach(async () => {
|
|
1404
|
+
closeAllDbs();
|
|
1405
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
it("creates table and inserts row", () => {
|
|
1409
|
+
const respond1 = vi.fn();
|
|
1410
|
+
dbExec({
|
|
1411
|
+
params: {
|
|
1412
|
+
agentId: "test-agent",
|
|
1413
|
+
sql: "CREATE TABLE IF NOT EXISTS meals (id INTEGER PRIMARY KEY, name TEXT, calories INTEGER)",
|
|
1414
|
+
},
|
|
1415
|
+
respond: respond1,
|
|
1416
|
+
});
|
|
1417
|
+
expect(respond1).toHaveBeenCalledWith(true, { changes: 0, lastInsertRowid: 0 });
|
|
1418
|
+
|
|
1419
|
+
const respond2 = vi.fn();
|
|
1420
|
+
dbExec({
|
|
1421
|
+
params: {
|
|
1422
|
+
agentId: "test-agent",
|
|
1423
|
+
sql: "INSERT INTO meals (name, calories) VALUES (?, ?)",
|
|
1424
|
+
params: ["Salad", 350],
|
|
1425
|
+
},
|
|
1426
|
+
respond: respond2,
|
|
1427
|
+
});
|
|
1428
|
+
expect(respond2).toHaveBeenCalledWith(true, { changes: 1, lastInsertRowid: 1 });
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it("rejects SELECT statements", () => {
|
|
1432
|
+
const respond = vi.fn();
|
|
1433
|
+
dbExec({
|
|
1434
|
+
params: { agentId: "test-agent", sql: "SELECT * FROM meals" },
|
|
1435
|
+
respond,
|
|
1436
|
+
});
|
|
1437
|
+
expect(respond).toHaveBeenCalledWith(false, {
|
|
1438
|
+
error: "use agentlife.db.query for SELECT",
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it("rejects missing agentId", () => {
|
|
1443
|
+
const respond = vi.fn();
|
|
1444
|
+
dbExec({ params: { sql: "CREATE TABLE t (id INT)" }, respond });
|
|
1445
|
+
expect(respond).toHaveBeenCalledWith(false, { error: "missing agentId" });
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it("rejects missing sql", () => {
|
|
1449
|
+
const respond = vi.fn();
|
|
1450
|
+
dbExec({ params: { agentId: "test-agent" }, respond });
|
|
1451
|
+
expect(respond).toHaveBeenCalledWith(false, { error: "missing sql" });
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it("handles SQL errors gracefully", () => {
|
|
1455
|
+
const respond = vi.fn();
|
|
1456
|
+
dbExec({
|
|
1457
|
+
params: {
|
|
1458
|
+
agentId: "test-agent",
|
|
1459
|
+
sql: "INSERT INTO nonexistent_table VALUES (?)",
|
|
1460
|
+
params: [1],
|
|
1461
|
+
},
|
|
1462
|
+
respond,
|
|
1463
|
+
});
|
|
1464
|
+
expect(respond).toHaveBeenCalledWith(false, expect.objectContaining({ error: expect.any(String) }));
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it("isolates agent databases", () => {
|
|
1468
|
+
// Create table for agent-a
|
|
1469
|
+
dbExec({
|
|
1470
|
+
params: {
|
|
1471
|
+
agentId: "agent-a",
|
|
1472
|
+
sql: "CREATE TABLE IF NOT EXISTS data (id INTEGER PRIMARY KEY, val TEXT)",
|
|
1473
|
+
},
|
|
1474
|
+
respond: vi.fn(),
|
|
1475
|
+
});
|
|
1476
|
+
dbExec({
|
|
1477
|
+
params: {
|
|
1478
|
+
agentId: "agent-a",
|
|
1479
|
+
sql: "INSERT INTO data (val) VALUES (?)",
|
|
1480
|
+
params: ["secret"],
|
|
1481
|
+
},
|
|
1482
|
+
respond: vi.fn(),
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// agent-b should not see agent-a's table
|
|
1486
|
+
const respond = vi.fn();
|
|
1487
|
+
dbQuery({
|
|
1488
|
+
params: { agentId: "agent-b", sql: "SELECT * FROM data" },
|
|
1489
|
+
respond,
|
|
1490
|
+
});
|
|
1491
|
+
expect(respond).toHaveBeenCalledWith(false, expect.objectContaining({ error: expect.any(String) }));
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it("creates DB file lazily", async () => {
|
|
1495
|
+
const dbDir = path.join(tmpDir, "agentlife", "db");
|
|
1496
|
+
// No DB file before first exec
|
|
1497
|
+
await expect(fs.stat(path.join(dbDir, "lazy-agent.db"))).rejects.toThrow();
|
|
1498
|
+
|
|
1499
|
+
dbExec({
|
|
1500
|
+
params: {
|
|
1501
|
+
agentId: "lazy-agent",
|
|
1502
|
+
sql: "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)",
|
|
1503
|
+
},
|
|
1504
|
+
respond: vi.fn(),
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
const stat = await fs.stat(path.join(dbDir, "lazy-agent.db"));
|
|
1508
|
+
expect(stat.isFile()).toBe(true);
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
// ---------------------------------------------------------------------------
|
|
1513
|
+
// 15. Gateway: agentlife.db.query
|
|
1514
|
+
// ---------------------------------------------------------------------------
|
|
1515
|
+
|
|
1516
|
+
describe("gateway: agentlife.db.query", () => {
|
|
1517
|
+
let tmpDir: string;
|
|
1518
|
+
let dbExec: Function;
|
|
1519
|
+
let dbQuery: Function;
|
|
1520
|
+
let closeAllDbs: () => void;
|
|
1521
|
+
|
|
1522
|
+
beforeEach(async () => {
|
|
1523
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
1524
|
+
vi.resetModules();
|
|
1525
|
+
const mod = await import("./index.ts");
|
|
1526
|
+
const register = mod.default;
|
|
1527
|
+
closeAllDbs = mod._closeAllDbs;
|
|
1528
|
+
const mock = createMockApi();
|
|
1529
|
+
register(mock.api as any);
|
|
1530
|
+
|
|
1531
|
+
await mock.services
|
|
1532
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1533
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1534
|
+
|
|
1535
|
+
dbExec = mock.gatewayMethods.find(
|
|
1536
|
+
(m) => m.method === "agentlife.db.exec",
|
|
1537
|
+
)!.handler;
|
|
1538
|
+
dbQuery = mock.gatewayMethods.find(
|
|
1539
|
+
(m) => m.method === "agentlife.db.query",
|
|
1540
|
+
)!.handler;
|
|
1541
|
+
|
|
1542
|
+
// Set up test table with data
|
|
1543
|
+
dbExec({
|
|
1544
|
+
params: {
|
|
1545
|
+
agentId: "test-agent",
|
|
1546
|
+
sql: "CREATE TABLE IF NOT EXISTS meals (id INTEGER PRIMARY KEY, name TEXT, calories INTEGER)",
|
|
1547
|
+
},
|
|
1548
|
+
respond: vi.fn(),
|
|
1549
|
+
});
|
|
1550
|
+
dbExec({
|
|
1551
|
+
params: {
|
|
1552
|
+
agentId: "test-agent",
|
|
1553
|
+
sql: "INSERT INTO meals (name, calories) VALUES (?, ?)",
|
|
1554
|
+
params: ["Salad", 350],
|
|
1555
|
+
},
|
|
1556
|
+
respond: vi.fn(),
|
|
1557
|
+
});
|
|
1558
|
+
dbExec({
|
|
1559
|
+
params: {
|
|
1560
|
+
agentId: "test-agent",
|
|
1561
|
+
sql: "INSERT INTO meals (name, calories) VALUES (?, ?)",
|
|
1562
|
+
params: ["Pizza", 900],
|
|
1563
|
+
},
|
|
1564
|
+
respond: vi.fn(),
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
afterEach(async () => {
|
|
1569
|
+
closeAllDbs();
|
|
1570
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
it("returns rows and columns", () => {
|
|
1574
|
+
const respond = vi.fn();
|
|
1575
|
+
dbQuery({
|
|
1576
|
+
params: { agentId: "test-agent", sql: "SELECT name, calories FROM meals ORDER BY id" },
|
|
1577
|
+
respond,
|
|
1578
|
+
});
|
|
1579
|
+
const result = respond.mock.calls[0][1];
|
|
1580
|
+
expect(result.rows).toHaveLength(2);
|
|
1581
|
+
expect(result.columns).toEqual(["name", "calories"]);
|
|
1582
|
+
expect(result.count).toBe(2);
|
|
1583
|
+
expect(result.truncated).toBe(false);
|
|
1584
|
+
expect(result.rows[0]).toEqual({ name: "Salad", calories: 350 });
|
|
1585
|
+
expect(result.rows[1]).toEqual({ name: "Pizza", calories: 900 });
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it("supports parameterized queries", () => {
|
|
1589
|
+
const respond = vi.fn();
|
|
1590
|
+
dbQuery({
|
|
1591
|
+
params: {
|
|
1592
|
+
agentId: "test-agent",
|
|
1593
|
+
sql: "SELECT name FROM meals WHERE calories > ?",
|
|
1594
|
+
params: [500],
|
|
1595
|
+
},
|
|
1596
|
+
respond,
|
|
1597
|
+
});
|
|
1598
|
+
const result = respond.mock.calls[0][1];
|
|
1599
|
+
expect(result.rows).toHaveLength(1);
|
|
1600
|
+
expect(result.rows[0].name).toBe("Pizza");
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it("rejects non-SELECT statements", () => {
|
|
1604
|
+
const respond = vi.fn();
|
|
1605
|
+
dbQuery({
|
|
1606
|
+
params: { agentId: "test-agent", sql: "DELETE FROM meals" },
|
|
1607
|
+
respond,
|
|
1608
|
+
});
|
|
1609
|
+
expect(respond).toHaveBeenCalledWith(false, {
|
|
1610
|
+
error: "use agentlife.db.exec for non-SELECT",
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it("returns empty rows for no matches", () => {
|
|
1615
|
+
const respond = vi.fn();
|
|
1616
|
+
dbQuery({
|
|
1617
|
+
params: {
|
|
1618
|
+
agentId: "test-agent",
|
|
1619
|
+
sql: "SELECT * FROM meals WHERE calories > ?",
|
|
1620
|
+
params: [2000],
|
|
1621
|
+
},
|
|
1622
|
+
respond,
|
|
1623
|
+
});
|
|
1624
|
+
const result = respond.mock.calls[0][1];
|
|
1625
|
+
expect(result.rows).toHaveLength(0);
|
|
1626
|
+
expect(result.columns).toEqual([]);
|
|
1627
|
+
expect(result.count).toBe(0);
|
|
1628
|
+
});
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
// 16. Gateway: agentlife.history
|
|
1633
|
+
// ---------------------------------------------------------------------------
|
|
1634
|
+
|
|
1635
|
+
describe("gateway: agentlife.history", () => {
|
|
1636
|
+
let tmpDir: string;
|
|
1637
|
+
let afterToolCall: Function;
|
|
1638
|
+
let historyMethod: Function;
|
|
1639
|
+
let dismissMethod: Function;
|
|
1640
|
+
let closeAllDbs: () => void;
|
|
1641
|
+
|
|
1642
|
+
beforeEach(async () => {
|
|
1643
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
1644
|
+
vi.resetModules();
|
|
1645
|
+
const mod = await import("./index.ts");
|
|
1646
|
+
const register = mod.default;
|
|
1647
|
+
closeAllDbs = mod._closeAllDbs;
|
|
1648
|
+
const mock = createMockApi();
|
|
1649
|
+
register(mock.api as any);
|
|
1650
|
+
|
|
1651
|
+
await mock.services
|
|
1652
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1653
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1654
|
+
|
|
1655
|
+
afterToolCall = mock.onHooks.find(
|
|
1656
|
+
(h) => h.hookName === "after_tool_call",
|
|
1657
|
+
)!.handler;
|
|
1658
|
+
historyMethod = mock.gatewayMethods.find(
|
|
1659
|
+
(m) => m.method === "agentlife.history",
|
|
1660
|
+
)!.handler;
|
|
1661
|
+
dismissMethod = mock.gatewayMethods.find(
|
|
1662
|
+
(m) => m.method === "agentlife.dismiss",
|
|
1663
|
+
)!.handler;
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
afterEach(async () => {
|
|
1667
|
+
// Flush scheduleSave setImmediate + async saveToDisk before cleanup
|
|
1668
|
+
await new Promise((r) => setImmediate(r));
|
|
1669
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1670
|
+
closeAllDbs();
|
|
1671
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
function pushDsl(dsl: string) {
|
|
1675
|
+
afterToolCall({
|
|
1676
|
+
toolName: "canvas",
|
|
1677
|
+
params: { action: "a2ui_push", jsonl: dsl },
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
it("records 'created' on surface push", () => {
|
|
1682
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
1683
|
+
|
|
1684
|
+
const respond = vi.fn();
|
|
1685
|
+
historyMethod({ params: { surfaceId: "weather" }, respond });
|
|
1686
|
+
const result = respond.mock.calls[0][1];
|
|
1687
|
+
expect(result.events.length).toBeGreaterThanOrEqual(1);
|
|
1688
|
+
expect(result.events.some((e: any) => e.event === "created")).toBe(true);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it("records 'updated' on re-push", () => {
|
|
1692
|
+
pushDsl('surface weather w=6\n card\n text "Loading" h3');
|
|
1693
|
+
pushDsl('surface weather w=6\n card\n text "72F Sunny" h3');
|
|
1694
|
+
|
|
1695
|
+
const respond = vi.fn();
|
|
1696
|
+
historyMethod({ params: { surfaceId: "weather" }, respond });
|
|
1697
|
+
const result = respond.mock.calls[0][1];
|
|
1698
|
+
expect(result.events.some((e: any) => e.event === "created")).toBe(true);
|
|
1699
|
+
expect(result.events.some((e: any) => e.event === "updated")).toBe(true);
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
it("records 'dismissed' on dismiss", () => {
|
|
1703
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
1704
|
+
dismissMethod({ params: { surfaceId: "weather" }, respond: vi.fn() });
|
|
1705
|
+
|
|
1706
|
+
const respond = vi.fn();
|
|
1707
|
+
historyMethod({ params: { surfaceId: "weather" }, respond });
|
|
1708
|
+
const result = respond.mock.calls[0][1];
|
|
1709
|
+
expect(result.events.some((e: any) => e.event === "dismissed")).toBe(true);
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
it("filters by event type", () => {
|
|
1713
|
+
pushDsl('surface weather w=6\n card\n text "Weather" h3');
|
|
1714
|
+
pushDsl('surface weather w=6\n card\n text "Updated" h3');
|
|
1715
|
+
|
|
1716
|
+
const respond = vi.fn();
|
|
1717
|
+
historyMethod({ params: { event: "created" }, respond });
|
|
1718
|
+
const result = respond.mock.calls[0][1];
|
|
1719
|
+
expect(result.events.every((e: any) => e.event === "created")).toBe(true);
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
it("respects limit param", () => {
|
|
1723
|
+
pushDsl('surface a w=6\n card\n text "A" h3');
|
|
1724
|
+
pushDsl('surface b w=6\n card\n text "B" h3');
|
|
1725
|
+
pushDsl('surface c w=6\n card\n text "C" h3');
|
|
1726
|
+
|
|
1727
|
+
const respond = vi.fn();
|
|
1728
|
+
historyMethod({ params: { limit: 2 }, respond });
|
|
1729
|
+
const result = respond.mock.calls[0][1];
|
|
1730
|
+
expect(result.events).toHaveLength(2);
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
it("returns empty for no matches", () => {
|
|
1734
|
+
const respond = vi.fn();
|
|
1735
|
+
historyMethod({ params: { surfaceId: "nonexistent" }, respond });
|
|
1736
|
+
const result = respond.mock.calls[0][1];
|
|
1737
|
+
expect(result.events).toHaveLength(0);
|
|
1738
|
+
expect(result.count).toBe(0);
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
// ---------------------------------------------------------------------------
|
|
1743
|
+
// 16. Registry seeding + needsEnrichment
|
|
1744
|
+
// ---------------------------------------------------------------------------
|
|
1745
|
+
|
|
1746
|
+
describe("registry seeding + needsEnrichment", () => {
|
|
1747
|
+
let tmpDir: string;
|
|
1748
|
+
|
|
1749
|
+
beforeEach(() => {
|
|
1750
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agentlife-test-"));
|
|
1751
|
+
vi.stubEnv("HOME", tmpDir);
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
afterEach(async () => {
|
|
1755
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
it("seeds existing agents into registry with needsEnrichment: true", async () => {
|
|
1759
|
+
// Create a user agent workspace with SOUL.md
|
|
1760
|
+
const workspace = path.join(tmpDir, ".openclaw", "workspace-fitness");
|
|
1761
|
+
await fs.mkdir(workspace, { recursive: true });
|
|
1762
|
+
await fs.writeFile(
|
|
1763
|
+
path.join(workspace, "SOUL.md"),
|
|
1764
|
+
"# Fitness Agent\nYou are a fitness coach tracking workouts and nutrition.\n",
|
|
1765
|
+
"utf-8",
|
|
1766
|
+
);
|
|
1767
|
+
|
|
1768
|
+
vi.resetModules();
|
|
1769
|
+
const { default: register } = await import("./index.ts");
|
|
1770
|
+
const { api, services, gatewayMethods } = createMockApi({
|
|
1771
|
+
agents: {
|
|
1772
|
+
list: [
|
|
1773
|
+
{
|
|
1774
|
+
id: "fitness",
|
|
1775
|
+
name: "Fitness",
|
|
1776
|
+
workspace,
|
|
1777
|
+
model: "sonnet",
|
|
1778
|
+
},
|
|
1779
|
+
],
|
|
1780
|
+
},
|
|
1781
|
+
});
|
|
1782
|
+
register(api as any);
|
|
1783
|
+
|
|
1784
|
+
// Start surfaces service (for registry file path)
|
|
1785
|
+
await services
|
|
1786
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1787
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1788
|
+
|
|
1789
|
+
// Run provisioning (seeds built-in + discovers user agents)
|
|
1790
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
1791
|
+
|
|
1792
|
+
// Check registry via agentlife.agents
|
|
1793
|
+
const agentsMethod = gatewayMethods.find(
|
|
1794
|
+
(m) => m.method === "agentlife.agents",
|
|
1795
|
+
)!.handler;
|
|
1796
|
+
const respond = vi.fn();
|
|
1797
|
+
agentsMethod({ respond });
|
|
1798
|
+
|
|
1799
|
+
const result = respond.mock.calls[0][1];
|
|
1800
|
+
expect(result.agents).toHaveProperty("fitness");
|
|
1801
|
+
expect(result.agents.fitness.name).toBe("Fitness");
|
|
1802
|
+
// Seeding uses name as placeholder — enrichment provides the real description
|
|
1803
|
+
expect(result.agents.fitness.description).toBe("Fitness");
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
it("does not re-seed agents already in registry", async () => {
|
|
1807
|
+
const workspace = path.join(tmpDir, ".openclaw", "workspace-fitness");
|
|
1808
|
+
await fs.mkdir(workspace, { recursive: true });
|
|
1809
|
+
await fs.writeFile(
|
|
1810
|
+
path.join(workspace, "SOUL.md"),
|
|
1811
|
+
"# Fitness Agent\nYou are a fitness coach.\n",
|
|
1812
|
+
"utf-8",
|
|
1813
|
+
);
|
|
1814
|
+
|
|
1815
|
+
vi.resetModules();
|
|
1816
|
+
const { default: register } = await import("./index.ts");
|
|
1817
|
+
const { api, services, gatewayMethods } = createMockApi({
|
|
1818
|
+
agents: {
|
|
1819
|
+
list: [{ id: "fitness", name: "Fitness", workspace }],
|
|
1820
|
+
},
|
|
1821
|
+
});
|
|
1822
|
+
register(api as any);
|
|
1823
|
+
|
|
1824
|
+
await services
|
|
1825
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1826
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1827
|
+
|
|
1828
|
+
// Pre-populate registry via createAgent
|
|
1829
|
+
const createAgent = gatewayMethods.find(
|
|
1830
|
+
(m) => m.method === "agentlife.createAgent",
|
|
1831
|
+
)!.handler;
|
|
1832
|
+
await createAgent({
|
|
1833
|
+
params: {
|
|
1834
|
+
id: "fitness",
|
|
1835
|
+
name: "Fitness Pro",
|
|
1836
|
+
workspace,
|
|
1837
|
+
description: "Expert fitness tracker",
|
|
1838
|
+
},
|
|
1839
|
+
respond: vi.fn(),
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
// Run provisioning — should NOT overwrite existing registry entry
|
|
1843
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
1844
|
+
|
|
1845
|
+
const agentsMethod = gatewayMethods.find(
|
|
1846
|
+
(m) => m.method === "agentlife.agents",
|
|
1847
|
+
)!.handler;
|
|
1848
|
+
const respond = vi.fn();
|
|
1849
|
+
agentsMethod({ respond });
|
|
1850
|
+
|
|
1851
|
+
const result = respond.mock.calls[0][1];
|
|
1852
|
+
expect(result.agents.fitness.description).toBe("Expert fitness tracker");
|
|
1853
|
+
expect(result.agents.fitness.name).toBe("Fitness Pro");
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
it("clears needsEnrichment when createAgent called with description", async () => {
|
|
1857
|
+
const workspace = path.join(tmpDir, ".openclaw", "workspace-fitness");
|
|
1858
|
+
await fs.mkdir(workspace, { recursive: true });
|
|
1859
|
+
await fs.writeFile(
|
|
1860
|
+
path.join(workspace, "SOUL.md"),
|
|
1861
|
+
"# Fitness Agent\nYou are a fitness coach tracking workouts.\n",
|
|
1862
|
+
"utf-8",
|
|
1863
|
+
);
|
|
1864
|
+
|
|
1865
|
+
vi.resetModules();
|
|
1866
|
+
const { default: register } = await import("./index.ts");
|
|
1867
|
+
const { api, services, gatewayMethods } = createMockApi({
|
|
1868
|
+
agents: {
|
|
1869
|
+
list: [{ id: "fitness", name: "Fitness", workspace }],
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
register(api as any);
|
|
1873
|
+
|
|
1874
|
+
await services
|
|
1875
|
+
.find((s) => s.id === "agentlife-surfaces")!
|
|
1876
|
+
.start({ stateDir: tmpDir, config: {} });
|
|
1877
|
+
|
|
1878
|
+
// Run provisioning — seeds with needsEnrichment: true
|
|
1879
|
+
await services.find((s) => s.id === "agentlife-provisioning")!.start();
|
|
1880
|
+
|
|
1881
|
+
// Now call createAgent with a proper description (simulates builder enrichment)
|
|
1882
|
+
const createAgent = gatewayMethods.find(
|
|
1883
|
+
(m) => m.method === "agentlife.createAgent",
|
|
1884
|
+
)!.handler;
|
|
1885
|
+
await createAgent({
|
|
1886
|
+
params: {
|
|
1887
|
+
id: "fitness",
|
|
1888
|
+
name: "Fitness",
|
|
1889
|
+
workspace,
|
|
1890
|
+
description: "Tracks workouts, nutrition, and sleep. Shows trends and flags concerns.",
|
|
1891
|
+
},
|
|
1892
|
+
respond: vi.fn(),
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
// Verify registry entry has updated description and no needsEnrichment
|
|
1896
|
+
// Read from disk to verify persistence
|
|
1897
|
+
const registryPath = path.join(tmpDir, "agentlife", "agent-registry.json");
|
|
1898
|
+
const raw = JSON.parse(await fs.readFile(registryPath, "utf-8"));
|
|
1899
|
+
const entry = raw.agents.fitness;
|
|
1900
|
+
expect(entry.description).toBe(
|
|
1901
|
+
"Tracks workouts, nutrition, and sleep. Shows trends and flags concerns.",
|
|
1902
|
+
);
|
|
1903
|
+
expect(entry.needsEnrichment).toBeUndefined();
|
|
1904
|
+
});
|
|
1905
|
+
});
|