@bjlee2024/claude-mem 13.4.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/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +202 -0
- package/README.md +419 -0
- package/dist/npx-cli/index.js +10001 -0
- package/dist/opencode-plugin/index.js +67 -0
- package/openclaw/Dockerfile.e2e +46 -0
- package/openclaw/SKILL.md +462 -0
- package/openclaw/TESTING.md +279 -0
- package/openclaw/dist/index.js +15 -0
- package/openclaw/e2e-verify.sh +222 -0
- package/openclaw/install.sh +1653 -0
- package/openclaw/openclaw.plugin.json +98 -0
- package/openclaw/package.json +21 -0
- package/openclaw/src/index.test.ts +1124 -0
- package/openclaw/src/index.ts +1092 -0
- package/openclaw/test-e2e.sh +40 -0
- package/openclaw/test-install.sh +2086 -0
- package/openclaw/test-sse-consumer.js +98 -0
- package/openclaw/tsconfig.json +26 -0
- package/package.json +211 -0
- package/plugin/.claude-plugin/plugin.json +24 -0
- package/plugin/.codex-plugin/plugin.json +46 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/hooks/bugfixes-2026-01-10.md +92 -0
- package/plugin/hooks/codex-hooks.json +74 -0
- package/plugin/hooks/hooks.json +87 -0
- package/plugin/modes/code--ar.json +24 -0
- package/plugin/modes/code--bn.json +24 -0
- package/plugin/modes/code--chill.json +8 -0
- package/plugin/modes/code--cs.json +24 -0
- package/plugin/modes/code--da.json +24 -0
- package/plugin/modes/code--de.json +24 -0
- package/plugin/modes/code--el.json +24 -0
- package/plugin/modes/code--es.json +24 -0
- package/plugin/modes/code--fi.json +24 -0
- package/plugin/modes/code--fr.json +24 -0
- package/plugin/modes/code--he.json +24 -0
- package/plugin/modes/code--hi.json +24 -0
- package/plugin/modes/code--hu.json +24 -0
- package/plugin/modes/code--id.json +24 -0
- package/plugin/modes/code--it.json +24 -0
- package/plugin/modes/code--ja.json +24 -0
- package/plugin/modes/code--ko.json +24 -0
- package/plugin/modes/code--nl.json +24 -0
- package/plugin/modes/code--no.json +24 -0
- package/plugin/modes/code--pl.json +24 -0
- package/plugin/modes/code--pt-br.json +24 -0
- package/plugin/modes/code--ro.json +24 -0
- package/plugin/modes/code--ru.json +24 -0
- package/plugin/modes/code--sv.json +24 -0
- package/plugin/modes/code--th.json +24 -0
- package/plugin/modes/code--tr.json +24 -0
- package/plugin/modes/code--uk.json +24 -0
- package/plugin/modes/code--ur.json +25 -0
- package/plugin/modes/code--vi.json +24 -0
- package/plugin/modes/code--zh.json +24 -0
- package/plugin/modes/code.json +139 -0
- package/plugin/modes/email-investigation.json +120 -0
- package/plugin/modes/law-study--chill.json +7 -0
- package/plugin/modes/law-study-CLAUDE.md +85 -0
- package/plugin/modes/law-study.json +120 -0
- package/plugin/modes/meme-tokens.json +125 -0
- package/plugin/package.json +46 -0
- package/plugin/scripts/bun-runner.js +216 -0
- package/plugin/scripts/context-generator.cjs +795 -0
- package/plugin/scripts/mcp-server.cjs +239 -0
- package/plugin/scripts/server-beta-service.cjs +9856 -0
- package/plugin/scripts/statusline-counts.js +40 -0
- package/plugin/scripts/version-check.js +69 -0
- package/plugin/scripts/worker-cli.js +19 -0
- package/plugin/scripts/worker-service.cjs +2368 -0
- package/plugin/scripts/worker-wrapper.cjs +2 -0
- package/plugin/skills/babysit/SKILL.md +87 -0
- package/plugin/skills/design-is/SKILL.md +312 -0
- package/plugin/skills/do/SKILL.md +45 -0
- package/plugin/skills/how-it-works/SKILL.md +22 -0
- package/plugin/skills/how-it-works/onboarding-explainer.md +17 -0
- package/plugin/skills/knowledge-agent/SKILL.md +80 -0
- package/plugin/skills/learn-codebase/SKILL.md +21 -0
- package/plugin/skills/make-plan/SKILL.md +67 -0
- package/plugin/skills/mem-search/SKILL.md +131 -0
- package/plugin/skills/oh-my-issues/SKILL.md +226 -0
- package/plugin/skills/pathfinder/SKILL.md +111 -0
- package/plugin/skills/smart-explore/SKILL.md +190 -0
- package/plugin/skills/timeline-report/SKILL.md +211 -0
- package/plugin/skills/version-bump/SKILL.md +68 -0
- package/plugin/skills/version-bump/scripts/generate_changelog.js +34 -0
- package/plugin/skills/weekly-digests/SKILL.md +262 -0
- package/plugin/skills/wowerpoint/SKILL.md +205 -0
- package/plugin/ui/assets/fonts/monaspace-radon-var.woff +0 -0
- package/plugin/ui/assets/fonts/monaspace-radon-var.woff2 +0 -0
- package/plugin/ui/claude-mem-logo-for-dark-mode.webp +0 -0
- package/plugin/ui/claude-mem-logo-stylized.png +0 -0
- package/plugin/ui/claude-mem-logomark.webp +0 -0
- package/plugin/ui/icon-thick-completed.svg +8 -0
- package/plugin/ui/icon-thick-investigated.svg +8 -0
- package/plugin/ui/icon-thick-learned.svg +12 -0
- package/plugin/ui/icon-thick-next-steps.svg +8 -0
- package/plugin/ui/viewer-bundle.js +65 -0
- package/plugin/ui/viewer.html +3145 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
+
import { mkdtemp, readFile, rm } from "fs/promises";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
import claudeMemPlugin from "./index.js";
|
|
8
|
+
|
|
9
|
+
function createMockApi(pluginConfigOverride: Record<string, any> = {}) {
|
|
10
|
+
const logs: string[] = [];
|
|
11
|
+
const sentMessages: Array<{ to: string; text: string; channel: string; opts?: any }> = [];
|
|
12
|
+
|
|
13
|
+
let registeredService: any = null;
|
|
14
|
+
const registeredCommands: Map<string, any> = new Map();
|
|
15
|
+
const eventHandlers: Map<string, Function[]> = new Map();
|
|
16
|
+
|
|
17
|
+
const api = {
|
|
18
|
+
id: "claude-mem",
|
|
19
|
+
name: "Claude-Mem (Persistent Memory)",
|
|
20
|
+
version: "1.0.0",
|
|
21
|
+
source: "/test/extensions/claude-mem/dist/index.js",
|
|
22
|
+
config: {},
|
|
23
|
+
pluginConfig: pluginConfigOverride,
|
|
24
|
+
logger: {
|
|
25
|
+
info: (message: string) => { logs.push(message); },
|
|
26
|
+
warn: (message: string) => { logs.push(message); },
|
|
27
|
+
error: (message: string) => { logs.push(message); },
|
|
28
|
+
debug: (message: string) => { logs.push(message); },
|
|
29
|
+
},
|
|
30
|
+
registerService: (service: any) => {
|
|
31
|
+
registeredService = service;
|
|
32
|
+
},
|
|
33
|
+
registerCommand: (command: any) => {
|
|
34
|
+
registeredCommands.set(command.name, command);
|
|
35
|
+
},
|
|
36
|
+
on: (event: string, callback: Function) => {
|
|
37
|
+
if (!eventHandlers.has(event)) {
|
|
38
|
+
eventHandlers.set(event, []);
|
|
39
|
+
}
|
|
40
|
+
eventHandlers.get(event)!.push(callback);
|
|
41
|
+
},
|
|
42
|
+
runtime: {
|
|
43
|
+
channel: {
|
|
44
|
+
telegram: {
|
|
45
|
+
sendMessageTelegram: async (to: string, text: string) => {
|
|
46
|
+
sentMessages.push({ to, text, channel: "telegram" });
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
discord: {
|
|
50
|
+
sendMessageDiscord: async (to: string, text: string) => {
|
|
51
|
+
sentMessages.push({ to, text, channel: "discord" });
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
signal: {
|
|
55
|
+
sendMessageSignal: async (to: string, text: string) => {
|
|
56
|
+
sentMessages.push({ to, text, channel: "signal" });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
slack: {
|
|
60
|
+
sendMessageSlack: async (to: string, text: string) => {
|
|
61
|
+
sentMessages.push({ to, text, channel: "slack" });
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
whatsapp: {
|
|
65
|
+
sendMessageWhatsApp: async (to: string, text: string, opts?: { verbose: boolean }) => {
|
|
66
|
+
sentMessages.push({ to, text, channel: "whatsapp", opts });
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
line: {
|
|
70
|
+
sendMessageLine: async (to: string, text: string) => {
|
|
71
|
+
sentMessages.push({ to, text, channel: "line" });
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
api: api as any,
|
|
80
|
+
logs,
|
|
81
|
+
sentMessages,
|
|
82
|
+
getService: () => registeredService,
|
|
83
|
+
getCommand: (name?: string) => {
|
|
84
|
+
if (name) return registeredCommands.get(name);
|
|
85
|
+
return registeredCommands.get("claude_mem_feed");
|
|
86
|
+
},
|
|
87
|
+
getEventHandlers: (event: string) => eventHandlers.get(event) || [],
|
|
88
|
+
fireEvent: async (event: string, data: any, ctx: any = {}) => {
|
|
89
|
+
const handlers = eventHandlers.get(event) || [];
|
|
90
|
+
let lastResult: any;
|
|
91
|
+
for (const handler of handlers) {
|
|
92
|
+
lastResult = await handler(data, ctx);
|
|
93
|
+
}
|
|
94
|
+
return lastResult;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("claudeMemPlugin", () => {
|
|
100
|
+
it("registers service, commands, and event handlers on load", () => {
|
|
101
|
+
const { api, logs, getService, getCommand, getEventHandlers } = createMockApi();
|
|
102
|
+
claudeMemPlugin(api);
|
|
103
|
+
|
|
104
|
+
assert.ok(getService(), "service should be registered");
|
|
105
|
+
assert.equal(getService().id, "claude-mem-observation-feed");
|
|
106
|
+
assert.ok(getCommand("claude_mem_feed"), "feed command should be registered");
|
|
107
|
+
assert.ok(getCommand("claude_mem_status"), "status command should be registered");
|
|
108
|
+
assert.ok(getEventHandlers("session_start").length > 0, "session_start handler registered");
|
|
109
|
+
assert.ok(getEventHandlers("after_compaction").length > 0, "after_compaction handler registered");
|
|
110
|
+
assert.ok(getEventHandlers("before_agent_start").length > 0, "before_agent_start handler registered");
|
|
111
|
+
assert.ok(getEventHandlers("before_prompt_build").length > 0, "before_prompt_build handler registered");
|
|
112
|
+
assert.ok(getEventHandlers("tool_result_persist").length > 0, "tool_result_persist handler registered");
|
|
113
|
+
assert.ok(getEventHandlers("agent_end").length > 0, "agent_end handler registered");
|
|
114
|
+
assert.ok(getEventHandlers("gateway_start").length > 0, "gateway_start handler registered");
|
|
115
|
+
assert.ok(logs.some((l) => l.includes("plugin loaded")));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("service start", () => {
|
|
119
|
+
it("logs disabled when feed not enabled", async () => {
|
|
120
|
+
const { api, logs, getService } = createMockApi({});
|
|
121
|
+
claudeMemPlugin(api);
|
|
122
|
+
|
|
123
|
+
await getService().start({});
|
|
124
|
+
assert.ok(logs.some((l) => l.includes("feed disabled")));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("logs disabled when enabled is false", async () => {
|
|
128
|
+
const { api, logs, getService } = createMockApi({
|
|
129
|
+
observationFeed: { enabled: false },
|
|
130
|
+
});
|
|
131
|
+
claudeMemPlugin(api);
|
|
132
|
+
|
|
133
|
+
await getService().start({});
|
|
134
|
+
assert.ok(logs.some((l) => l.includes("feed disabled")));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("logs misconfigured when channel is missing", async () => {
|
|
138
|
+
const { api, logs, getService } = createMockApi({
|
|
139
|
+
observationFeed: { enabled: true, to: "123" },
|
|
140
|
+
});
|
|
141
|
+
claudeMemPlugin(api);
|
|
142
|
+
|
|
143
|
+
await getService().start({});
|
|
144
|
+
assert.ok(logs.some((l) => l.includes("misconfigured")));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("logs misconfigured when to is missing", async () => {
|
|
148
|
+
const { api, logs, getService } = createMockApi({
|
|
149
|
+
observationFeed: { enabled: true, channel: "telegram" },
|
|
150
|
+
});
|
|
151
|
+
claudeMemPlugin(api);
|
|
152
|
+
|
|
153
|
+
await getService().start({});
|
|
154
|
+
assert.ok(logs.some((l) => l.includes("misconfigured")));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("service stop", () => {
|
|
159
|
+
it("logs disconnection on stop", async () => {
|
|
160
|
+
const { api, logs, getService } = createMockApi({});
|
|
161
|
+
claudeMemPlugin(api);
|
|
162
|
+
|
|
163
|
+
await getService().stop({});
|
|
164
|
+
assert.ok(logs.some((l) => l.includes("feed stopped")));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("command handler", () => {
|
|
169
|
+
it("returns not configured when no feedConfig", async () => {
|
|
170
|
+
const { api, getCommand } = createMockApi({});
|
|
171
|
+
claudeMemPlugin(api);
|
|
172
|
+
|
|
173
|
+
const result = await getCommand().handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed", config: {} });
|
|
174
|
+
assert.ok(result.text.includes("not configured"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns status when no args", async () => {
|
|
178
|
+
const { api, getCommand } = createMockApi({
|
|
179
|
+
observationFeed: { enabled: true, channel: "telegram", to: "123" },
|
|
180
|
+
});
|
|
181
|
+
claudeMemPlugin(api);
|
|
182
|
+
|
|
183
|
+
const result = await getCommand().handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed", config: {} });
|
|
184
|
+
assert.ok(result.text.includes("Enabled: yes"));
|
|
185
|
+
assert.ok(result.text.includes("Channel: telegram"));
|
|
186
|
+
assert.ok(result.text.includes("Target: 123"));
|
|
187
|
+
assert.ok(result.text.includes("Connection:"));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("handles 'on' argument", async () => {
|
|
191
|
+
const { api, logs, getCommand } = createMockApi({
|
|
192
|
+
observationFeed: { enabled: false },
|
|
193
|
+
});
|
|
194
|
+
claudeMemPlugin(api);
|
|
195
|
+
|
|
196
|
+
const result = await getCommand().handler({ args: "on", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed on", config: {} });
|
|
197
|
+
assert.ok(result.text.includes("enable requested"));
|
|
198
|
+
assert.ok(logs.some((l) => l.includes("enable requested")));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("handles 'off' argument", async () => {
|
|
202
|
+
const { api, logs, getCommand } = createMockApi({
|
|
203
|
+
observationFeed: { enabled: true },
|
|
204
|
+
});
|
|
205
|
+
claudeMemPlugin(api);
|
|
206
|
+
|
|
207
|
+
const result = await getCommand().handler({ args: "off", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed off", config: {} });
|
|
208
|
+
assert.ok(result.text.includes("disable requested"));
|
|
209
|
+
assert.ok(logs.some((l) => l.includes("disable requested")));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("shows connection state in status output", async () => {
|
|
213
|
+
const { api, getCommand } = createMockApi({
|
|
214
|
+
observationFeed: { enabled: false, channel: "slack", to: "#general" },
|
|
215
|
+
});
|
|
216
|
+
claudeMemPlugin(api);
|
|
217
|
+
|
|
218
|
+
const result = await getCommand().handler({ args: "", channel: "slack", isAuthorizedSender: true, commandBody: "/claude_mem_feed", config: {} });
|
|
219
|
+
assert.ok(result.text.includes("Connection: disconnected"));
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("Observation I/O event handlers", () => {
|
|
225
|
+
let workerServer: Server;
|
|
226
|
+
let workerPort: number;
|
|
227
|
+
let receivedRequests: Array<{ method: string; url: string; body: any }> = [];
|
|
228
|
+
|
|
229
|
+
function startWorkerMock(): Promise<number> {
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
workerServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
232
|
+
let body = "";
|
|
233
|
+
req.on("data", (chunk) => { body += chunk.toString(); });
|
|
234
|
+
req.on("end", () => {
|
|
235
|
+
let parsedBody: any = null;
|
|
236
|
+
try { parsedBody = JSON.parse(body); } catch {}
|
|
237
|
+
|
|
238
|
+
receivedRequests.push({
|
|
239
|
+
method: req.method || "GET",
|
|
240
|
+
url: req.url || "/",
|
|
241
|
+
body: parsedBody,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (req.url === "/api/health") {
|
|
245
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
246
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (req.url === "/api/sessions/init") {
|
|
251
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
252
|
+
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (req.url === "/api/sessions/observations") {
|
|
257
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
258
|
+
res.end(JSON.stringify({ status: "queued" }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (req.url === "/api/sessions/summarize") {
|
|
263
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
264
|
+
res.end(JSON.stringify({ status: "queued" }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (req.url?.startsWith("/api/context/inject")) {
|
|
269
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
270
|
+
res.end("# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (req.url === "/stream") {
|
|
275
|
+
res.writeHead(200, {
|
|
276
|
+
"Content-Type": "text/event-stream",
|
|
277
|
+
"Cache-Control": "no-cache",
|
|
278
|
+
Connection: "keep-alive",
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
res.writeHead(404);
|
|
284
|
+
res.end();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
workerServer.listen(0, () => {
|
|
288
|
+
const address = workerServer.address();
|
|
289
|
+
if (address && typeof address === "object") {
|
|
290
|
+
resolve(address.port);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
beforeEach(async () => {
|
|
297
|
+
receivedRequests = [];
|
|
298
|
+
workerPort = await startWorkerMock();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterEach(() => {
|
|
302
|
+
workerServer?.close();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("session_start sends session init to worker", async () => {
|
|
306
|
+
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
|
307
|
+
claudeMemPlugin(api);
|
|
308
|
+
|
|
309
|
+
await fireEvent("session_start", {
|
|
310
|
+
sessionId: "test-session-1",
|
|
311
|
+
}, { sessionKey: "agent-1" });
|
|
312
|
+
|
|
313
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
314
|
+
|
|
315
|
+
const initRequest = receivedRequests.find((r) => r.url === "/api/sessions/init");
|
|
316
|
+
assert.ok(initRequest, "should send init request to worker");
|
|
317
|
+
assert.equal(initRequest!.body.project, "openclaw");
|
|
318
|
+
assert.ok(initRequest!.body.contentSessionId.startsWith("openclaw-agent-1-"));
|
|
319
|
+
assert.ok(logs.some((l) => l.includes("Session initialized")));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("session_start calls init on worker", async () => {
|
|
323
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
324
|
+
claudeMemPlugin(api);
|
|
325
|
+
|
|
326
|
+
await fireEvent("session_start", { sessionId: "test-session-1" }, {});
|
|
327
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
328
|
+
|
|
329
|
+
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
|
|
330
|
+
assert.equal(initRequests.length, 1, "should init on session_start");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("after_compaction re-inits session on worker", async () => {
|
|
334
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
335
|
+
claudeMemPlugin(api);
|
|
336
|
+
|
|
337
|
+
await fireEvent("after_compaction", { messageCount: 5, compactedCount: 3 }, {});
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
339
|
+
|
|
340
|
+
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
|
|
341
|
+
assert.equal(initRequests.length, 1, "should re-init after compaction");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("before_agent_start calls init for session privacy check", async () => {
|
|
345
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
346
|
+
claudeMemPlugin(api);
|
|
347
|
+
|
|
348
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, {});
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
350
|
+
|
|
351
|
+
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
|
|
352
|
+
assert.equal(initRequests.length, 1, "before_agent_start should init session");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("tool_result_persist sends observation to worker", async () => {
|
|
356
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
357
|
+
claudeMemPlugin(api);
|
|
358
|
+
|
|
359
|
+
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "test-agent" });
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
361
|
+
|
|
362
|
+
await fireEvent("tool_result_persist", {
|
|
363
|
+
toolName: "Read",
|
|
364
|
+
params: { file_path: "/src/index.ts" },
|
|
365
|
+
message: {
|
|
366
|
+
content: [{ type: "text", text: "file contents here..." }],
|
|
367
|
+
},
|
|
368
|
+
}, { sessionKey: "test-agent" });
|
|
369
|
+
|
|
370
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
371
|
+
|
|
372
|
+
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
|
|
373
|
+
assert.ok(obsRequest, "should send observation to worker");
|
|
374
|
+
assert.equal(obsRequest!.body.tool_name, "Read");
|
|
375
|
+
assert.deepEqual(obsRequest!.body.tool_input, { file_path: "/src/index.ts" });
|
|
376
|
+
assert.equal(obsRequest!.body.tool_response, "file contents here...");
|
|
377
|
+
assert.ok(obsRequest!.body.contentSessionId.startsWith("openclaw-test-agent-"));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("tool_result_persist skips memory_ tools", async () => {
|
|
381
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
382
|
+
claudeMemPlugin(api);
|
|
383
|
+
|
|
384
|
+
await fireEvent("tool_result_persist", {
|
|
385
|
+
toolName: "memory_search",
|
|
386
|
+
params: {},
|
|
387
|
+
}, {});
|
|
388
|
+
|
|
389
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
390
|
+
|
|
391
|
+
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
|
|
392
|
+
assert.ok(!obsRequest, "should skip memory_ tools");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("tool_result_persist truncates long responses", async () => {
|
|
396
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
397
|
+
claudeMemPlugin(api);
|
|
398
|
+
|
|
399
|
+
const longText = "x".repeat(2000);
|
|
400
|
+
await fireEvent("tool_result_persist", {
|
|
401
|
+
toolName: "Bash",
|
|
402
|
+
params: { command: "ls" },
|
|
403
|
+
message: {
|
|
404
|
+
content: [{ type: "text", text: longText }],
|
|
405
|
+
},
|
|
406
|
+
}, {});
|
|
407
|
+
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
409
|
+
|
|
410
|
+
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
|
|
411
|
+
assert.ok(obsRequest, "should send observation");
|
|
412
|
+
assert.equal(obsRequest!.body.tool_response.length, 1000, "should truncate to 1000 chars");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("agent_end sends summarize and complete to worker", async () => {
|
|
416
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
417
|
+
claudeMemPlugin(api);
|
|
418
|
+
|
|
419
|
+
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "summarize-test" });
|
|
420
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
421
|
+
|
|
422
|
+
await fireEvent("agent_end", {
|
|
423
|
+
messages: [
|
|
424
|
+
{ role: "user", content: "help me" },
|
|
425
|
+
{ role: "assistant", content: "Here is the solution..." },
|
|
426
|
+
],
|
|
427
|
+
}, { sessionKey: "summarize-test" });
|
|
428
|
+
|
|
429
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
430
|
+
|
|
431
|
+
const summarizeRequest = receivedRequests.find((r) => r.url === "/api/sessions/summarize");
|
|
432
|
+
assert.ok(summarizeRequest, "should send summarize to worker");
|
|
433
|
+
assert.equal(summarizeRequest!.body.last_assistant_message, "Here is the solution...");
|
|
434
|
+
assert.ok(summarizeRequest!.body.contentSessionId.startsWith("openclaw-summarize-test-"));
|
|
435
|
+
|
|
436
|
+
const completeRequest = receivedRequests.find((r) => r.url === "/api/sessions/complete");
|
|
437
|
+
assert.ok(!completeRequest, "should not send complete (worker self-completes)");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("agent_end extracts text from array content", async () => {
|
|
441
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
442
|
+
claudeMemPlugin(api);
|
|
443
|
+
|
|
444
|
+
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "array-content" });
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
446
|
+
|
|
447
|
+
await fireEvent("agent_end", {
|
|
448
|
+
messages: [
|
|
449
|
+
{
|
|
450
|
+
role: "assistant",
|
|
451
|
+
content: [
|
|
452
|
+
{ type: "text", text: "First part" },
|
|
453
|
+
{ type: "text", text: "Second part" },
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
}, { sessionKey: "array-content" });
|
|
458
|
+
|
|
459
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
460
|
+
|
|
461
|
+
const summarizeRequest = receivedRequests.find((r) => r.url === "/api/sessions/summarize");
|
|
462
|
+
assert.ok(summarizeRequest, "should send summarize");
|
|
463
|
+
assert.equal(summarizeRequest!.body.last_assistant_message, "First part\nSecond part");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("uses custom project name from config", async () => {
|
|
467
|
+
const { api, fireEvent } = createMockApi({ workerPort, project: "my-project" });
|
|
468
|
+
claudeMemPlugin(api);
|
|
469
|
+
|
|
470
|
+
await fireEvent("session_start", { sessionId: "s1" }, {});
|
|
471
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
472
|
+
|
|
473
|
+
const initRequest = receivedRequests.find((r) => r.url === "/api/sessions/init");
|
|
474
|
+
assert.ok(initRequest, "should send init");
|
|
475
|
+
assert.equal(initRequest!.body.project, "my-project");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("claude_mem_status command reports worker health", async () => {
|
|
479
|
+
const { api, getCommand } = createMockApi({ workerPort });
|
|
480
|
+
claudeMemPlugin(api);
|
|
481
|
+
|
|
482
|
+
const statusCmd = getCommand("claude_mem_status");
|
|
483
|
+
assert.ok(statusCmd, "status command should exist");
|
|
484
|
+
|
|
485
|
+
const result = await statusCmd.handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_status", config: {} });
|
|
486
|
+
assert.ok(result.text.includes("Status: ok"));
|
|
487
|
+
assert.ok(result.text.includes(`Port: ${workerPort}`));
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("claude_mem_status reports unreachable when worker is down", async () => {
|
|
491
|
+
workerServer.close();
|
|
492
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
493
|
+
|
|
494
|
+
const { api, getCommand } = createMockApi({ workerPort: 59999 });
|
|
495
|
+
claudeMemPlugin(api);
|
|
496
|
+
|
|
497
|
+
const statusCmd = getCommand("claude_mem_status");
|
|
498
|
+
const result = await statusCmd.handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_status", config: {} });
|
|
499
|
+
assert.ok(result.text.includes("unreachable"));
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("reuses same contentSessionId for same sessionKey", async () => {
|
|
503
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
504
|
+
claudeMemPlugin(api);
|
|
505
|
+
|
|
506
|
+
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "reuse-test" });
|
|
507
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
508
|
+
|
|
509
|
+
await fireEvent("tool_result_persist", {
|
|
510
|
+
toolName: "Read",
|
|
511
|
+
params: { file_path: "/src/index.ts" },
|
|
512
|
+
message: { content: [{ type: "text", text: "contents" }] },
|
|
513
|
+
}, { sessionKey: "reuse-test" });
|
|
514
|
+
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
516
|
+
|
|
517
|
+
const initRequest = receivedRequests.find((r) => r.url === "/api/sessions/init");
|
|
518
|
+
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
|
|
519
|
+
assert.ok(initRequest && obsRequest, "both requests should exist");
|
|
520
|
+
assert.equal(
|
|
521
|
+
initRequest!.body.contentSessionId,
|
|
522
|
+
obsRequest!.body.contentSessionId,
|
|
523
|
+
"should reuse contentSessionId for same sessionKey"
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("before_prompt_build context injection", () => {
|
|
529
|
+
let workerServer: Server;
|
|
530
|
+
let workerPort: number;
|
|
531
|
+
let receivedRequests: Array<{ method: string; url: string; body: any }> = [];
|
|
532
|
+
let contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
|
|
533
|
+
|
|
534
|
+
function startWorkerMock(): Promise<number> {
|
|
535
|
+
return new Promise((resolve) => {
|
|
536
|
+
workerServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
537
|
+
let body = "";
|
|
538
|
+
req.on("data", (chunk) => { body += chunk.toString(); });
|
|
539
|
+
req.on("end", () => {
|
|
540
|
+
let parsedBody: any = null;
|
|
541
|
+
try { parsedBody = JSON.parse(body); } catch {}
|
|
542
|
+
|
|
543
|
+
receivedRequests.push({
|
|
544
|
+
method: req.method || "GET",
|
|
545
|
+
url: req.url || "/",
|
|
546
|
+
body: parsedBody,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (req.url?.startsWith("/api/context/inject")) {
|
|
550
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
551
|
+
res.end(contextResponse);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (req.url === "/api/sessions/init") {
|
|
556
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
557
|
+
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
562
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
workerServer.listen(0, () => {
|
|
566
|
+
const address = workerServer.address();
|
|
567
|
+
if (address && typeof address === "object") {
|
|
568
|
+
resolve(address.port);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
beforeEach(async () => {
|
|
575
|
+
receivedRequests = [];
|
|
576
|
+
contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
|
|
577
|
+
workerPort = await startWorkerMock();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
afterEach(async () => {
|
|
581
|
+
workerServer?.close();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("returns appendSystemContext from before_prompt_build", async () => {
|
|
585
|
+
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
|
586
|
+
claudeMemPlugin(api);
|
|
587
|
+
|
|
588
|
+
const result = await fireEvent("before_prompt_build", {
|
|
589
|
+
prompt: "Help me write a function",
|
|
590
|
+
messages: [],
|
|
591
|
+
}, { agentId: "main" });
|
|
592
|
+
|
|
593
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
594
|
+
|
|
595
|
+
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
|
596
|
+
assert.ok(contextRequest, "should request context from worker");
|
|
597
|
+
assert.ok(contextRequest!.url!.includes("projects=openclaw"));
|
|
598
|
+
|
|
599
|
+
assert.ok(result, "should return a result");
|
|
600
|
+
assert.ok(result.appendSystemContext, "should return appendSystemContext");
|
|
601
|
+
assert.ok(result.appendSystemContext.includes("Claude-Mem Context"), "should contain context");
|
|
602
|
+
assert.ok(result.appendSystemContext.includes("Session 1"), "should contain timeline");
|
|
603
|
+
assert.ok(logs.some((l) => l.includes("Context injected via system prompt")));
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("does not write MEMORY.md on before_agent_start", async () => {
|
|
607
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
|
608
|
+
try {
|
|
609
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
610
|
+
claudeMemPlugin(api);
|
|
611
|
+
|
|
612
|
+
await fireEvent("before_agent_start", {
|
|
613
|
+
prompt: "Help me write a function",
|
|
614
|
+
}, { sessionKey: "sync-test", workspaceDir: tmpDir });
|
|
615
|
+
|
|
616
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
617
|
+
|
|
618
|
+
let memoryExists = true;
|
|
619
|
+
try {
|
|
620
|
+
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
|
621
|
+
} catch {
|
|
622
|
+
memoryExists = false;
|
|
623
|
+
}
|
|
624
|
+
assert.ok(!memoryExists, "MEMORY.md should not be created by before_agent_start");
|
|
625
|
+
} finally {
|
|
626
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("does not sync MEMORY.md on tool_result_persist", async () => {
|
|
631
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
|
|
632
|
+
try {
|
|
633
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
634
|
+
claudeMemPlugin(api);
|
|
635
|
+
|
|
636
|
+
await fireEvent("before_agent_start", {
|
|
637
|
+
prompt: "Help me write a function",
|
|
638
|
+
}, { sessionKey: "tool-sync", workspaceDir: tmpDir });
|
|
639
|
+
|
|
640
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
641
|
+
|
|
642
|
+
await fireEvent("tool_result_persist", {
|
|
643
|
+
toolName: "Read",
|
|
644
|
+
params: { file_path: "/src/app.ts" },
|
|
645
|
+
message: { content: [{ type: "text", text: "file contents" }] },
|
|
646
|
+
}, { sessionKey: "tool-sync" });
|
|
647
|
+
|
|
648
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
649
|
+
|
|
650
|
+
const contextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
|
|
651
|
+
assert.equal(contextRequests.length, 0, "tool_result_persist should not fetch context");
|
|
652
|
+
|
|
653
|
+
let memoryExists = true;
|
|
654
|
+
try {
|
|
655
|
+
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
|
|
656
|
+
} catch {
|
|
657
|
+
memoryExists = false;
|
|
658
|
+
}
|
|
659
|
+
assert.ok(!memoryExists, "MEMORY.md should not be written by tool_result_persist");
|
|
660
|
+
} finally {
|
|
661
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("skips context injection when syncMemoryFile is false", async () => {
|
|
666
|
+
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFile: false });
|
|
667
|
+
claudeMemPlugin(api);
|
|
668
|
+
|
|
669
|
+
const result = await fireEvent("before_prompt_build", {
|
|
670
|
+
prompt: "Help me write a function",
|
|
671
|
+
messages: [],
|
|
672
|
+
}, { agentId: "main" });
|
|
673
|
+
|
|
674
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
675
|
+
|
|
676
|
+
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
|
677
|
+
assert.ok(!contextRequest, "should not fetch context when injection disabled");
|
|
678
|
+
assert.equal(result, undefined, "should return undefined when injection disabled");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("skips context injection for excluded agents", async () => {
|
|
682
|
+
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
|
|
683
|
+
claudeMemPlugin(api);
|
|
684
|
+
|
|
685
|
+
const result = await fireEvent("before_prompt_build", {
|
|
686
|
+
prompt: "Help me",
|
|
687
|
+
messages: [],
|
|
688
|
+
}, { agentId: "snarf" });
|
|
689
|
+
|
|
690
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
691
|
+
|
|
692
|
+
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
|
693
|
+
assert.ok(!contextRequest, "should not fetch context for excluded agent");
|
|
694
|
+
assert.equal(result, undefined, "should return undefined for excluded agent");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("injects context for non-excluded agents", async () => {
|
|
698
|
+
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
|
|
699
|
+
claudeMemPlugin(api);
|
|
700
|
+
|
|
701
|
+
const result = await fireEvent("before_prompt_build", {
|
|
702
|
+
prompt: "Help me",
|
|
703
|
+
messages: [],
|
|
704
|
+
}, { agentId: "main" });
|
|
705
|
+
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
707
|
+
|
|
708
|
+
assert.ok(result, "should return a result for non-excluded agent");
|
|
709
|
+
assert.ok(result.appendSystemContext, "should inject context for non-excluded agent");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("returns undefined when context is empty", async () => {
|
|
713
|
+
contextResponse = " ";
|
|
714
|
+
const { api, logs, fireEvent } = createMockApi({ workerPort });
|
|
715
|
+
claudeMemPlugin(api);
|
|
716
|
+
|
|
717
|
+
const result = await fireEvent("before_prompt_build", {
|
|
718
|
+
prompt: "Help me write a function",
|
|
719
|
+
messages: [],
|
|
720
|
+
}, { agentId: "main" });
|
|
721
|
+
|
|
722
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
723
|
+
|
|
724
|
+
assert.equal(result, undefined, "should return undefined for empty context");
|
|
725
|
+
assert.ok(!logs.some((l) => l.includes("Context injected")), "should not log injection for empty context");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("uses custom project name in context inject URL", async () => {
|
|
729
|
+
const { api, fireEvent } = createMockApi({ workerPort, project: "my-bot" });
|
|
730
|
+
claudeMemPlugin(api);
|
|
731
|
+
|
|
732
|
+
await fireEvent("before_prompt_build", {
|
|
733
|
+
prompt: "Help me write a function",
|
|
734
|
+
messages: [],
|
|
735
|
+
}, { agentId: "main" });
|
|
736
|
+
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
738
|
+
|
|
739
|
+
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
|
740
|
+
assert.ok(contextRequest, "should request context");
|
|
741
|
+
assert.ok(contextRequest!.url!.includes("projects=my-bot"), "should use custom project name");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("includes agent-scoped project in context request", async () => {
|
|
745
|
+
const { api, fireEvent } = createMockApi({ workerPort });
|
|
746
|
+
claudeMemPlugin(api);
|
|
747
|
+
|
|
748
|
+
await fireEvent("before_prompt_build", {
|
|
749
|
+
prompt: "Help me",
|
|
750
|
+
messages: [],
|
|
751
|
+
}, { agentId: "debugger" });
|
|
752
|
+
|
|
753
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
754
|
+
|
|
755
|
+
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
|
|
756
|
+
assert.ok(contextRequest, "should request context");
|
|
757
|
+
const url = decodeURIComponent(contextRequest!.url!);
|
|
758
|
+
assert.ok(url.includes("openclaw,openclaw-debugger"), "should include both base and agent-scoped projects");
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
describe("SSE stream integration", () => {
|
|
763
|
+
let server: Server;
|
|
764
|
+
let serverPort: number;
|
|
765
|
+
let serverResponses: ServerResponse[] = [];
|
|
766
|
+
|
|
767
|
+
function startSSEServer(): Promise<number> {
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
770
|
+
if (req.url !== "/stream") {
|
|
771
|
+
res.writeHead(404);
|
|
772
|
+
res.end();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
res.writeHead(200, {
|
|
776
|
+
"Content-Type": "text/event-stream",
|
|
777
|
+
"Cache-Control": "no-cache",
|
|
778
|
+
Connection: "keep-alive",
|
|
779
|
+
});
|
|
780
|
+
serverResponses.push(res);
|
|
781
|
+
});
|
|
782
|
+
server.listen(0, () => {
|
|
783
|
+
const address = server.address();
|
|
784
|
+
if (address && typeof address === "object") {
|
|
785
|
+
resolve(address.port);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
beforeEach(async () => {
|
|
792
|
+
serverResponses = [];
|
|
793
|
+
serverPort = await startSSEServer();
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
afterEach(() => {
|
|
797
|
+
for (const res of serverResponses) {
|
|
798
|
+
try {
|
|
799
|
+
res.end();
|
|
800
|
+
} catch {}
|
|
801
|
+
}
|
|
802
|
+
server?.close();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("connects to SSE stream and receives new_observation events", async () => {
|
|
806
|
+
const { api, logs, sentMessages, getService } = createMockApi({
|
|
807
|
+
workerPort: serverPort,
|
|
808
|
+
observationFeed: { enabled: true, channel: "telegram", to: "12345" },
|
|
809
|
+
});
|
|
810
|
+
claudeMemPlugin(api);
|
|
811
|
+
|
|
812
|
+
await getService().start({});
|
|
813
|
+
|
|
814
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
815
|
+
|
|
816
|
+
assert.ok(logs.some((l) => l.includes("Connecting to SSE stream")));
|
|
817
|
+
|
|
818
|
+
const observation = {
|
|
819
|
+
type: "new_observation",
|
|
820
|
+
observation: {
|
|
821
|
+
id: 1,
|
|
822
|
+
title: "Test Observation",
|
|
823
|
+
subtitle: "Found something interesting",
|
|
824
|
+
type: "discovery",
|
|
825
|
+
project: "test",
|
|
826
|
+
prompt_number: 1,
|
|
827
|
+
created_at_epoch: Date.now(),
|
|
828
|
+
},
|
|
829
|
+
timestamp: Date.now(),
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
for (const res of serverResponses) {
|
|
833
|
+
res.write(`data: ${JSON.stringify(observation)}\n\n`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
837
|
+
|
|
838
|
+
assert.equal(sentMessages.length, 1);
|
|
839
|
+
assert.equal(sentMessages[0].channel, "telegram");
|
|
840
|
+
assert.equal(sentMessages[0].to, "12345");
|
|
841
|
+
assert.ok(sentMessages[0].text.includes("Test Observation"));
|
|
842
|
+
assert.ok(sentMessages[0].text.includes("Found something interesting"));
|
|
843
|
+
|
|
844
|
+
await getService().stop({});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("filters out non-observation events", async () => {
|
|
848
|
+
const { api, sentMessages, getService } = createMockApi({
|
|
849
|
+
workerPort: serverPort,
|
|
850
|
+
observationFeed: { enabled: true, channel: "discord", to: "channel-id" },
|
|
851
|
+
});
|
|
852
|
+
claudeMemPlugin(api);
|
|
853
|
+
|
|
854
|
+
await getService().start({});
|
|
855
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
856
|
+
|
|
857
|
+
for (const res of serverResponses) {
|
|
858
|
+
res.write(`data: ${JSON.stringify({ type: "processing_status", isProcessing: true })}\n\n`);
|
|
859
|
+
res.write(`data: ${JSON.stringify({ type: "session_started", sessionId: "abc" })}\n\n`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
863
|
+
assert.equal(sentMessages.length, 0, "non-observation events should be filtered");
|
|
864
|
+
|
|
865
|
+
await getService().stop({});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("handles observation with null subtitle", async () => {
|
|
869
|
+
const { api, sentMessages, getService } = createMockApi({
|
|
870
|
+
workerPort: serverPort,
|
|
871
|
+
observationFeed: { enabled: true, channel: "telegram", to: "999" },
|
|
872
|
+
});
|
|
873
|
+
claudeMemPlugin(api);
|
|
874
|
+
|
|
875
|
+
await getService().start({});
|
|
876
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
877
|
+
|
|
878
|
+
for (const res of serverResponses) {
|
|
879
|
+
res.write(
|
|
880
|
+
`data: ${JSON.stringify({
|
|
881
|
+
type: "new_observation",
|
|
882
|
+
observation: { id: 2, title: "No Subtitle", subtitle: null },
|
|
883
|
+
timestamp: Date.now(),
|
|
884
|
+
})}\n\n`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
889
|
+
assert.equal(sentMessages.length, 1);
|
|
890
|
+
assert.ok(sentMessages[0].text.includes("No Subtitle"));
|
|
891
|
+
assert.ok(!sentMessages[0].text.includes("null"));
|
|
892
|
+
|
|
893
|
+
await getService().stop({});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("handles observation with null title", async () => {
|
|
897
|
+
const { api, sentMessages, getService } = createMockApi({
|
|
898
|
+
workerPort: serverPort,
|
|
899
|
+
observationFeed: { enabled: true, channel: "telegram", to: "999" },
|
|
900
|
+
});
|
|
901
|
+
claudeMemPlugin(api);
|
|
902
|
+
|
|
903
|
+
await getService().start({});
|
|
904
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
905
|
+
|
|
906
|
+
for (const res of serverResponses) {
|
|
907
|
+
res.write(
|
|
908
|
+
`data: ${JSON.stringify({
|
|
909
|
+
type: "new_observation",
|
|
910
|
+
observation: { id: 3, title: null, subtitle: "Has subtitle" },
|
|
911
|
+
timestamp: Date.now(),
|
|
912
|
+
})}\n\n`
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
917
|
+
assert.equal(sentMessages.length, 1);
|
|
918
|
+
assert.ok(sentMessages[0].text.includes("Untitled"));
|
|
919
|
+
|
|
920
|
+
await getService().stop({});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("uses custom workerPort from config", async () => {
|
|
924
|
+
const { api, logs, getService } = createMockApi({
|
|
925
|
+
workerPort: serverPort,
|
|
926
|
+
observationFeed: { enabled: true, channel: "telegram", to: "12345" },
|
|
927
|
+
});
|
|
928
|
+
claudeMemPlugin(api);
|
|
929
|
+
|
|
930
|
+
await getService().start({});
|
|
931
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
932
|
+
|
|
933
|
+
assert.ok(logs.some((l) => l.includes(`127.0.0.1:${serverPort}`)));
|
|
934
|
+
|
|
935
|
+
await getService().stop({});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("logs unknown channel type", async () => {
|
|
939
|
+
const { api, logs, sentMessages, getService } = createMockApi({
|
|
940
|
+
workerPort: serverPort,
|
|
941
|
+
observationFeed: { enabled: true, channel: "matrix", to: "room-id" },
|
|
942
|
+
});
|
|
943
|
+
claudeMemPlugin(api);
|
|
944
|
+
|
|
945
|
+
await getService().start({});
|
|
946
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
947
|
+
|
|
948
|
+
for (const res of serverResponses) {
|
|
949
|
+
res.write(
|
|
950
|
+
`data: ${JSON.stringify({
|
|
951
|
+
type: "new_observation",
|
|
952
|
+
observation: { id: 4, title: "Test", subtitle: null },
|
|
953
|
+
timestamp: Date.now(),
|
|
954
|
+
})}\n\n`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
959
|
+
assert.equal(sentMessages.length, 0);
|
|
960
|
+
assert.ok(logs.some((l) => l.includes("Unsupported channel type: matrix")));
|
|
961
|
+
|
|
962
|
+
await getService().stop({});
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
describe("circuit breaker", () => {
|
|
967
|
+
beforeEach(async () => {
|
|
968
|
+
const { api, fireEvent } = createMockApi({ workerPort: 59999 });
|
|
969
|
+
claudeMemPlugin(api);
|
|
970
|
+
await fireEvent("gateway_start", {}, {});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it("opens after threshold failures and stops further requests", async () => {
|
|
974
|
+
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
|
|
975
|
+
claudeMemPlugin(api);
|
|
976
|
+
await fireEvent("gateway_start", {}, {});
|
|
977
|
+
|
|
978
|
+
for (let i = 0; i < 4; i++) {
|
|
979
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-open-${i}` });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const logCountBeforeDrop = logs.length;
|
|
983
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-drop" });
|
|
984
|
+
const noisyDropLogs = logs.slice(logCountBeforeDrop).filter(
|
|
985
|
+
(l) => l.includes("failed") || l.includes("disabling")
|
|
986
|
+
);
|
|
987
|
+
assert.equal(noisyDropLogs.length, 0, "calls when circuit is open should be silently dropped");
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("logs individual failures while circuit is closed, then disabling when it opens", async () => {
|
|
991
|
+
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
|
|
992
|
+
claudeMemPlugin(api);
|
|
993
|
+
await fireEvent("gateway_start", {}, {});
|
|
994
|
+
const logsAfterReset = logs.length;
|
|
995
|
+
|
|
996
|
+
for (let i = 0; i < 3; i++) {
|
|
997
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-log-${i}` });
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const newLogs = logs.slice(logsAfterReset);
|
|
1001
|
+
assert.ok(newLogs.length > 0, "threshold calls should produce log output");
|
|
1002
|
+
const disablingLogs = newLogs.filter((l) => l.includes("disabling requests"));
|
|
1003
|
+
assert.equal(disablingLogs.length, 1, "should emit exactly one disabling warning when circuit opens");
|
|
1004
|
+
const failureLogs = newLogs.filter((l) => l.includes("failed:"));
|
|
1005
|
+
assert.ok(failureLogs.length < 3, "threshold-crossing call should not log an individual failure");
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("resets on gateway_start, allowing connections again", async () => {
|
|
1009
|
+
const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });
|
|
1010
|
+
claudeMemPlugin(api);
|
|
1011
|
+
await fireEvent("gateway_start", {}, {});
|
|
1012
|
+
|
|
1013
|
+
for (let i = 0; i < 4; i++) {
|
|
1014
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: `cb-reset-${i}` });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const logCountWhileOpen = logs.length;
|
|
1018
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-while-open" });
|
|
1019
|
+
assert.equal(
|
|
1020
|
+
logs.slice(logCountWhileOpen).filter((l) => l.includes("failed") || l.includes("disabling")).length,
|
|
1021
|
+
0,
|
|
1022
|
+
"call while circuit is open should be silently dropped"
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
await fireEvent("gateway_start", {}, {});
|
|
1026
|
+
|
|
1027
|
+
const logCountAfterReset = logs.length;
|
|
1028
|
+
await fireEvent("before_agent_start", { prompt: "hello" }, { sessionKey: "cb-after-reset" });
|
|
1029
|
+
const newLogs = logs.slice(logCountAfterReset);
|
|
1030
|
+
assert.ok(
|
|
1031
|
+
newLogs.some((l) => l.includes("failed:") || l.includes("disabling")),
|
|
1032
|
+
"should attempt worker connection after gateway_start reset"
|
|
1033
|
+
);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("HALF_OPEN allows only a single probe — non-2xx keeps circuit open, 2xx closes it", async () => {
|
|
1037
|
+
const resetMock = createMockApi({ workerPort: 59999 });
|
|
1038
|
+
claudeMemPlugin(resetMock.api);
|
|
1039
|
+
await resetMock.fireEvent("gateway_start", {}, {});
|
|
1040
|
+
|
|
1041
|
+
for (let i = 0; i < 4; i++) {
|
|
1042
|
+
await resetMock.fireEvent("before_agent_start", { prompt: "probe-test" }, { sessionKey: `probe-phase1-${i}` });
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const realDateNow = Date.now.bind(Date);
|
|
1046
|
+
Date.now = () => realDateNow() + 31_000;
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
let serverA: Server | null = null;
|
|
1050
|
+
const portA: number = await new Promise((resolve) => {
|
|
1051
|
+
serverA = createServer((_req: IncomingMessage, res: ServerResponse) => {
|
|
1052
|
+
res.writeHead(500);
|
|
1053
|
+
res.end();
|
|
1054
|
+
});
|
|
1055
|
+
serverA!.listen(0, () => {
|
|
1056
|
+
const addr = serverA!.address();
|
|
1057
|
+
resolve((addr as any).port);
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
const mockA = createMockApi({ workerPort: portA });
|
|
1062
|
+
claudeMemPlugin(mockA.api);
|
|
1063
|
+
|
|
1064
|
+
const logCountAtProbe = mockA.logs.length;
|
|
1065
|
+
await mockA.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-call-non2xx" });
|
|
1066
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1067
|
+
|
|
1068
|
+
const probeALogs = mockA.logs.slice(logCountAtProbe);
|
|
1069
|
+
assert.ok(
|
|
1070
|
+
probeALogs.some((l) => l.includes("disabling") || l.includes("returned 500") || l.includes("Worker POST")),
|
|
1071
|
+
"non-2xx probe should keep circuit open (expected disabling or 500 status log)"
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const logCountAfterFailedProbe = mockA.logs.length;
|
|
1075
|
+
await mockA.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-concurrent" });
|
|
1076
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1077
|
+
const droppedLogs = mockA.logs.slice(logCountAfterFailedProbe).filter(
|
|
1078
|
+
(l) => l.includes("failed") || l.includes("disabling")
|
|
1079
|
+
);
|
|
1080
|
+
assert.equal(droppedLogs.length, 0, "call should be silently dropped while circuit is OPEN again after failed probe");
|
|
1081
|
+
|
|
1082
|
+
serverA!.close();
|
|
1083
|
+
|
|
1084
|
+
const resetMock2 = createMockApi({ workerPort: 59999 });
|
|
1085
|
+
claudeMemPlugin(resetMock2.api);
|
|
1086
|
+
await resetMock2.fireEvent("gateway_start", {}, {});
|
|
1087
|
+
|
|
1088
|
+
Date.now = realDateNow;
|
|
1089
|
+
for (let i = 0; i < 4; i++) {
|
|
1090
|
+
await resetMock2.fireEvent("before_agent_start", { prompt: "probe-test" }, { sessionKey: `probe-phase4-${i}` });
|
|
1091
|
+
}
|
|
1092
|
+
Date.now = () => realDateNow() + 31_000;
|
|
1093
|
+
|
|
1094
|
+
let serverB: Server | null = null;
|
|
1095
|
+
const portB: number = await new Promise((resolve) => {
|
|
1096
|
+
serverB = createServer((_req: IncomingMessage, res: ServerResponse) => {
|
|
1097
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1098
|
+
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
|
|
1099
|
+
});
|
|
1100
|
+
serverB!.listen(0, () => {
|
|
1101
|
+
const addr = serverB!.address();
|
|
1102
|
+
resolve((addr as any).port);
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const mockB = createMockApi({ workerPort: portB });
|
|
1107
|
+
claudeMemPlugin(mockB.api);
|
|
1108
|
+
|
|
1109
|
+
const logCountBeforeSuccessProbe = mockB.logs.length;
|
|
1110
|
+
await mockB.fireEvent("before_agent_start", { prompt: "probe" }, { sessionKey: "probe-call-2xx" });
|
|
1111
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1112
|
+
|
|
1113
|
+
const successProbeLogs = mockB.logs.slice(logCountBeforeSuccessProbe);
|
|
1114
|
+
assert.ok(
|
|
1115
|
+
successProbeLogs.some((l) => l.includes("restored") || l.includes("circuit closed")),
|
|
1116
|
+
"2xx probe should close the circuit — expected 'restored' or 'circuit closed' log"
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
serverB!.close();
|
|
1120
|
+
} finally {
|
|
1121
|
+
Date.now = realDateNow;
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
});
|