@pentatonic-ai/ai-agent-sdk 0.4.9 → 0.5.1
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/README.md +61 -1
- package/bin/cli.js +70 -9
- package/dist/index.cjs +25 -3
- package/dist/index.js +25 -3
- package/package.json +4 -2
- package/packages/doctor/README.md +106 -0
- package/packages/doctor/__tests__/checks.test.js +187 -0
- package/packages/doctor/__tests__/detect.test.js +101 -0
- package/packages/doctor/__tests__/output.test.js +92 -0
- package/packages/doctor/__tests__/plugins.test.js +111 -0
- package/packages/doctor/__tests__/runner.test.js +131 -0
- package/packages/doctor/package.json +6 -0
- package/packages/doctor/src/checks/hosted-tes.js +109 -0
- package/packages/doctor/src/checks/local-memory.js +290 -0
- package/packages/doctor/src/checks/platform.js +170 -0
- package/packages/doctor/src/checks/universal.js +121 -0
- package/packages/doctor/src/detect.js +102 -0
- package/packages/doctor/src/index.js +33 -0
- package/packages/doctor/src/output.js +55 -0
- package/packages/doctor/src/plugins.js +81 -0
- package/packages/doctor/src/runner.js +136 -0
- package/packages/memory/migrations/005-atomic-memories.sql +16 -0
- package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
- package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
- package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
- package/packages/memory/openclaw-plugin/__tests__/query-expansion.test.js +193 -0
- package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
- package/packages/memory/openclaw-plugin/index.js +410 -60
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/api-contract.test.js +41 -0
- package/packages/memory/src/__tests__/distill.test.js +175 -0
- package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
- package/packages/memory/src/distill.js +162 -0
- package/packages/memory/src/index.js +1 -0
- package/packages/memory/src/ingest.js +16 -0
- package/packages/memory/src/openclaw/index.js +280 -23
- package/packages/memory/src/openclaw/package.json +1 -1
- package/packages/memory/src/server.js +27 -5
- package/src/normalizer.js +16 -0
- package/src/session.js +21 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-used indicator tests.
|
|
3
|
+
*
|
|
4
|
+
* When the assemble hook injects memories into the system prompt, the
|
|
5
|
+
* plugin appends an instruction telling the LLM to add a visible footer
|
|
6
|
+
* to its reply so the end user sees when Pentatonic Memory was used.
|
|
7
|
+
*
|
|
8
|
+
* Opt out with show_memory_indicator: false in plugin config.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import plugin from "../index.js";
|
|
12
|
+
|
|
13
|
+
const realFetch = globalThis.fetch;
|
|
14
|
+
|
|
15
|
+
function mockFetch(searchResults) {
|
|
16
|
+
globalThis.fetch = async (url, init) => {
|
|
17
|
+
const body = init?.body ? JSON.parse(init.body) : null;
|
|
18
|
+
const query = body?.query || "";
|
|
19
|
+
// Return search results for hosted search; empty for anything else.
|
|
20
|
+
if (query.includes("semanticSearchMemories")) {
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
status: 200,
|
|
24
|
+
json: async () => ({
|
|
25
|
+
data: { semanticSearchMemories: searchResults },
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (url.endsWith("/search")) {
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
status: 200,
|
|
33
|
+
json: async () => ({ results: searchResults }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 200,
|
|
39
|
+
json: async () => ({ data: {} }),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeEngine(extraConfig = {}) {
|
|
45
|
+
let factory;
|
|
46
|
+
plugin.register({
|
|
47
|
+
pluginConfig: {
|
|
48
|
+
tes_endpoint: "https://x.test",
|
|
49
|
+
tes_client_id: "c",
|
|
50
|
+
tes_api_key: "tes_c_xyz",
|
|
51
|
+
...extraConfig,
|
|
52
|
+
},
|
|
53
|
+
registerTool: () => {},
|
|
54
|
+
registerContextEngine: (_name, fn) => {
|
|
55
|
+
factory = fn;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (!factory) throw new Error("plugin did not register a context engine");
|
|
59
|
+
return factory();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
globalThis.fetch = realFetch;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("memory-used indicator — hosted mode", () => {
|
|
67
|
+
it("injects a footer instruction into systemPromptAddition when memories are found", async () => {
|
|
68
|
+
mockFetch([
|
|
69
|
+
{ id: "m1", content: "Phil likes cheese", similarity: 0.9 },
|
|
70
|
+
{ id: "m2", content: "Phil drinks cortado", similarity: 0.8 },
|
|
71
|
+
]);
|
|
72
|
+
const engine = makeEngine();
|
|
73
|
+
|
|
74
|
+
const result = await engine.assemble({
|
|
75
|
+
sessionId: "s",
|
|
76
|
+
messages: [{ role: "user", content: "what do I like?" }],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.systemPromptAddition).toMatch(/🧠/);
|
|
80
|
+
expect(result.systemPromptAddition).toMatch(/Used 2 memories from Pentatonic Memory/);
|
|
81
|
+
expect(result.systemPromptAddition).toMatch(/append exactly this footer/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("pluralises correctly for a single memory", async () => {
|
|
85
|
+
mockFetch([{ id: "m1", content: "only one", similarity: 0.9 }]);
|
|
86
|
+
const engine = makeEngine();
|
|
87
|
+
|
|
88
|
+
const result = await engine.assemble({
|
|
89
|
+
sessionId: "s",
|
|
90
|
+
messages: [{ role: "user", content: "query" }],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.systemPromptAddition).toMatch(/Used 1 memory from Pentatonic Memory/);
|
|
94
|
+
expect(result.systemPromptAddition).not.toMatch(/Used 1 memories/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("omits the indicator instruction when show_memory_indicator is false", async () => {
|
|
98
|
+
mockFetch([{ id: "m1", content: "fact", similarity: 0.9 }]);
|
|
99
|
+
const engine = makeEngine({ show_memory_indicator: false });
|
|
100
|
+
|
|
101
|
+
const result = await engine.assemble({
|
|
102
|
+
sessionId: "s",
|
|
103
|
+
messages: [{ role: "user", content: "query" }],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.systemPromptAddition).toBeDefined();
|
|
107
|
+
expect(result.systemPromptAddition).not.toMatch(/🧠/);
|
|
108
|
+
expect(result.systemPromptAddition).not.toMatch(/Pentatonic Memory_/);
|
|
109
|
+
// Still contains the memory content itself
|
|
110
|
+
expect(result.systemPromptAddition).toMatch(/fact/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not inject anything when no memories are found", async () => {
|
|
114
|
+
mockFetch([]);
|
|
115
|
+
const engine = makeEngine();
|
|
116
|
+
|
|
117
|
+
const result = await engine.assemble({
|
|
118
|
+
sessionId: "s",
|
|
119
|
+
messages: [{ role: "user", content: "query" }],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// No memories → no systemPromptAddition at all (so nothing to indicate)
|
|
123
|
+
expect(result.systemPromptAddition).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("instructs the LLM to omit the footer if memories weren't relevant", async () => {
|
|
127
|
+
// The prompt tells the model to skip the footer when the memories
|
|
128
|
+
// didn't influence the reply, so unrelated questions don't get a
|
|
129
|
+
// spurious "used 3 memories" footer.
|
|
130
|
+
mockFetch([{ id: "m1", content: "Phil likes cheese", similarity: 0.4 }]);
|
|
131
|
+
const engine = makeEngine();
|
|
132
|
+
|
|
133
|
+
const result = await engine.assemble({
|
|
134
|
+
sessionId: "s",
|
|
135
|
+
messages: [{ role: "user", content: "query" }],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.systemPromptAddition).toMatch(
|
|
139
|
+
/If the memories above were not relevant to your reply, omit the footer/
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query expansion fallback tests.
|
|
3
|
+
*
|
|
4
|
+
* When the raw user prompt returns no memories, the plugin retries once
|
|
5
|
+
* with a keyword-distilled form. This recovers matches for verbose
|
|
6
|
+
* natural-language prompts that fall below the semantic threshold.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import plugin, { extractSearchKeywords } from "../index.js";
|
|
10
|
+
|
|
11
|
+
const realFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = realFetch;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function makeEngine(extraConfig = {}) {
|
|
18
|
+
let factory;
|
|
19
|
+
plugin.register({
|
|
20
|
+
pluginConfig: {
|
|
21
|
+
tes_endpoint: "https://x.test",
|
|
22
|
+
tes_client_id: "c",
|
|
23
|
+
tes_api_key: "tes_c_xyz",
|
|
24
|
+
...extraConfig,
|
|
25
|
+
},
|
|
26
|
+
registerTool: () => {},
|
|
27
|
+
registerContextEngine: (_name, fn) => {
|
|
28
|
+
factory = fn;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (!factory) throw new Error("plugin did not register a context engine");
|
|
32
|
+
return factory();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("extractSearchKeywords", () => {
|
|
36
|
+
it("strips stopwords from verbose prompts", () => {
|
|
37
|
+
const out = extractSearchKeywords(
|
|
38
|
+
"when I was working in the thing-event-system, I copied migrations, what were they?"
|
|
39
|
+
);
|
|
40
|
+
expect(out).toMatch(/thing-event-system/);
|
|
41
|
+
expect(out).toMatch(/migrations/);
|
|
42
|
+
expect(out).not.toMatch(/\bwhen\b/);
|
|
43
|
+
expect(out).not.toMatch(/\bwhat\b/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("preserves hyphenated compounds", () => {
|
|
47
|
+
expect(extractSearchKeywords("where is thing-event-system?")).toMatch(
|
|
48
|
+
/thing-event-system/
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns null when the distilled form equals the input", () => {
|
|
53
|
+
expect(extractSearchKeywords("deep-memory migrations")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns null when the prompt is only stopwords", () => {
|
|
57
|
+
expect(extractSearchKeywords("what were they?")).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null for non-string input", () => {
|
|
61
|
+
expect(extractSearchKeywords(null)).toBeNull();
|
|
62
|
+
expect(extractSearchKeywords(undefined)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("assemble — keyword retry fallback (hosted mode)", () => {
|
|
67
|
+
it("retries with distilled keywords when raw prompt misses", async () => {
|
|
68
|
+
const queries = [];
|
|
69
|
+
globalThis.fetch = async (_url, init) => {
|
|
70
|
+
const body = JSON.parse(init.body);
|
|
71
|
+
const q = body.variables.query;
|
|
72
|
+
queries.push(q);
|
|
73
|
+
const isFirst = queries.length === 1;
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
status: 200,
|
|
77
|
+
json: async () => ({
|
|
78
|
+
data: {
|
|
79
|
+
semanticSearchMemories: isFirst
|
|
80
|
+
? []
|
|
81
|
+
: [{ id: "m1", content: "matched on retry", similarity: 0.7 }],
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const engine = makeEngine();
|
|
88
|
+
const result = await engine.assemble({
|
|
89
|
+
sessionId: "s",
|
|
90
|
+
messages: [
|
|
91
|
+
{
|
|
92
|
+
role: "user",
|
|
93
|
+
content:
|
|
94
|
+
"when I was working in the thing-event-system, what were those migration changes again?",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(queries).toHaveLength(2);
|
|
100
|
+
expect(queries[0]).toMatch(/when I was working/);
|
|
101
|
+
expect(queries[1]).not.toMatch(/\bwhen\b/);
|
|
102
|
+
expect(queries[1]).toMatch(/thing-event-system/);
|
|
103
|
+
expect(queries[1]).toMatch(/migration/);
|
|
104
|
+
expect(result.systemPromptAddition).toMatch(/matched on retry/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not retry when the raw prompt already returns results", async () => {
|
|
108
|
+
const queries = [];
|
|
109
|
+
globalThis.fetch = async (_url, init) => {
|
|
110
|
+
queries.push(JSON.parse(init.body).variables.query);
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
status: 200,
|
|
114
|
+
json: async () => ({
|
|
115
|
+
data: {
|
|
116
|
+
semanticSearchMemories: [
|
|
117
|
+
{ id: "m1", content: "hit", similarity: 0.9 },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const engine = makeEngine();
|
|
125
|
+
await engine.assemble({
|
|
126
|
+
sessionId: "s",
|
|
127
|
+
messages: [{ role: "user", content: "thing-event-system migrations" }],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(queries).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not retry when distilled query equals the raw query", async () => {
|
|
134
|
+
const queries = [];
|
|
135
|
+
globalThis.fetch = async (_url, init) => {
|
|
136
|
+
queries.push(JSON.parse(init.body).variables.query);
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
status: 200,
|
|
140
|
+
json: async () => ({ data: { semanticSearchMemories: [] } }),
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const engine = makeEngine();
|
|
145
|
+
await engine.assemble({
|
|
146
|
+
sessionId: "s",
|
|
147
|
+
messages: [{ role: "user", content: "deep-memory migrations" }],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(queries).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("assemble — keyword retry fallback (local mode)", () => {
|
|
155
|
+
it("retries via /search endpoint when raw query returns nothing", async () => {
|
|
156
|
+
const queries = [];
|
|
157
|
+
globalThis.fetch = async (_url, init) => {
|
|
158
|
+
const body = JSON.parse(init.body);
|
|
159
|
+
queries.push(body.query);
|
|
160
|
+
const isFirst = queries.length === 1;
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
status: 200,
|
|
164
|
+
json: async () => ({
|
|
165
|
+
results: isFirst
|
|
166
|
+
? []
|
|
167
|
+
: [{ id: "m1", content: "local hit", similarity: 0.6 }],
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let factory;
|
|
173
|
+
plugin.register({
|
|
174
|
+
pluginConfig: {}, // no tes_* creds → local mode
|
|
175
|
+
registerTool: () => {},
|
|
176
|
+
registerContextEngine: (_name, fn) => {
|
|
177
|
+
factory = fn;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const engine = factory();
|
|
181
|
+
|
|
182
|
+
const result = await engine.assemble({
|
|
183
|
+
sessionId: "s",
|
|
184
|
+
messages: [
|
|
185
|
+
{ role: "user", content: "what were the migration changes again?" },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(queries).toHaveLength(2);
|
|
190
|
+
expect(queries[1]).toMatch(/migration/);
|
|
191
|
+
expect(result.systemPromptAddition).toMatch(/local hit/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-version mismatch warning — plugin checks the local memory
|
|
3
|
+
* server's /health payload and warns loudly (stderr) when the server
|
|
4
|
+
* is older than MIN_SERVER_VERSION. Catches the common footgun of
|
|
5
|
+
* updating the plugin without re-running `npx ... memory` to rebuild
|
|
6
|
+
* the Docker stack.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import plugin from "../index.js";
|
|
10
|
+
|
|
11
|
+
const realFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
// Capture console.error so tests can assert on warnings.
|
|
14
|
+
function captureWarnings() {
|
|
15
|
+
const warnings = [];
|
|
16
|
+
const orig = console.error;
|
|
17
|
+
console.error = (...args) => warnings.push(args.join(" "));
|
|
18
|
+
return {
|
|
19
|
+
warnings,
|
|
20
|
+
restore: () => {
|
|
21
|
+
console.error = orig;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mockHealth(body, ok = true) {
|
|
27
|
+
globalThis.fetch = async (url) => {
|
|
28
|
+
if (url.endsWith("/health")) {
|
|
29
|
+
return {
|
|
30
|
+
ok,
|
|
31
|
+
status: ok ? 200 : 500,
|
|
32
|
+
json: async () => body,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeEngine(extraConfig = {}) {
|
|
40
|
+
let factory;
|
|
41
|
+
plugin.register({
|
|
42
|
+
pluginConfig: {
|
|
43
|
+
memory_url: "http://localhost:3333",
|
|
44
|
+
...extraConfig,
|
|
45
|
+
},
|
|
46
|
+
registerTool: () => {},
|
|
47
|
+
registerContextEngine: (_n, fn) => {
|
|
48
|
+
factory = fn;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
return factory();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
globalThis.fetch = realFetch;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("memory server version check", () => {
|
|
59
|
+
it("warns when server is older than MIN_SERVER_VERSION", async () => {
|
|
60
|
+
mockHealth({ status: "ok", version: "0.4.5" });
|
|
61
|
+
const cap = captureWarnings();
|
|
62
|
+
const engine = makeEngine();
|
|
63
|
+
// register schedules a localHealth() call asynchronously — await it
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
65
|
+
cap.restore();
|
|
66
|
+
|
|
67
|
+
const warning = cap.warnings.find((w) =>
|
|
68
|
+
w.includes("memory server is 0.4.5")
|
|
69
|
+
);
|
|
70
|
+
expect(warning).toBeDefined();
|
|
71
|
+
expect(warning).toMatch(/npx @pentatonic-ai\/ai-agent-sdk@latest memory/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does NOT warn when server is at MIN_SERVER_VERSION", async () => {
|
|
75
|
+
mockHealth({ status: "ok", version: "0.5.0" });
|
|
76
|
+
const cap = captureWarnings();
|
|
77
|
+
makeEngine();
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
79
|
+
cap.restore();
|
|
80
|
+
|
|
81
|
+
const warning = cap.warnings.find((w) => w.includes("memory server is"));
|
|
82
|
+
expect(warning).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does NOT warn when server is newer than MIN_SERVER_VERSION", async () => {
|
|
86
|
+
mockHealth({ status: "ok", version: "1.2.3" });
|
|
87
|
+
const cap = captureWarnings();
|
|
88
|
+
makeEngine();
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
90
|
+
cap.restore();
|
|
91
|
+
|
|
92
|
+
const warning = cap.warnings.find((w) => w.includes("memory server is"));
|
|
93
|
+
expect(warning).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does NOT warn when health payload lacks a version field", async () => {
|
|
97
|
+
// Older servers that predate the version field in /health — silent,
|
|
98
|
+
// don't spam warnings on something the user can't diagnose.
|
|
99
|
+
mockHealth({ status: "ok" });
|
|
100
|
+
const cap = captureWarnings();
|
|
101
|
+
makeEngine();
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
103
|
+
cap.restore();
|
|
104
|
+
|
|
105
|
+
const warning = cap.warnings.find((w) => w.includes("memory server is"));
|
|
106
|
+
expect(warning).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does NOT warn when server is unreachable", async () => {
|
|
110
|
+
globalThis.fetch = async () => {
|
|
111
|
+
throw new Error("connection refused");
|
|
112
|
+
};
|
|
113
|
+
const cap = captureWarnings();
|
|
114
|
+
makeEngine();
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
116
|
+
cap.restore();
|
|
117
|
+
|
|
118
|
+
const warning = cap.warnings.find((w) => w.includes("memory server is"));
|
|
119
|
+
expect(warning).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("only warns once per server version (deduplicates)", async () => {
|
|
123
|
+
mockHealth({ status: "ok", version: "0.4.0" });
|
|
124
|
+
const cap = captureWarnings();
|
|
125
|
+
// Register twice — simulates multiple context-engine factory calls.
|
|
126
|
+
makeEngine();
|
|
127
|
+
makeEngine();
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
129
|
+
cap.restore();
|
|
130
|
+
|
|
131
|
+
const warnings = cap.warnings.filter((w) =>
|
|
132
|
+
w.includes("memory server is 0.4.0")
|
|
133
|
+
);
|
|
134
|
+
expect(warnings).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
});
|