@dotdrelle/wiki-manager 0.15.32 → 0.15.35
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/.env.example +8 -5
- package/agents.docker-compose.yml +11 -16
- package/docker-compose.yml +9 -8
- package/package.json +2 -2
- package/src/agent/graph.js +20 -0
- package/src/commands/slash.js +73 -10
- package/src/commands/slash.test.js +37 -3
- package/src/core/agentsCompose.js +86 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +24 -2
- package/src/core/dockerCompose.test.js +18 -0
- package/src/core/googleGrants.js +38 -0
- package/src/core/googleGrants.test.js +59 -0
- package/src/core/mcp.js +1 -1
- package/src/core/modelFetch.js +449 -47
- package/src/core/modelFetch.test.js +290 -2
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +43 -4
- package/src/core/wikiWorkspace.test.js +9 -3
- package/src/core/wikirc.test.js +59 -5
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/shell/SetupWizard.tsx +617 -66
- package/src/shell/repl.js +37 -11
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +16 -3
- package/src/shell/setupWizardSuggestions.test.js +58 -0
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- package/wiki-workspace +116 -32
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DISCOVERY_TIMEOUT_MS,
|
|
5
|
+
classifyModelEntry,
|
|
6
|
+
describeFetchError,
|
|
7
|
+
fetchServerCatalog,
|
|
8
|
+
fallbackModels,
|
|
9
|
+
fetchGatewayCatalog,
|
|
10
|
+
fetchModels,
|
|
11
|
+
requiresBaseUrl,
|
|
12
|
+
transportSummary,
|
|
13
|
+
} from './modelFetch.js';
|
|
4
14
|
|
|
5
15
|
test('fetchModels returns remote OpenAI-compatible model ids', async () => {
|
|
6
16
|
const originalFetch = globalThis.fetch;
|
|
@@ -34,5 +44,283 @@ test('fetchModels falls back on invalid remote response', async () => {
|
|
|
34
44
|
});
|
|
35
45
|
|
|
36
46
|
test('fallbackModels leaves custom-model to the wizard append action', () => {
|
|
37
|
-
assert.deepEqual(fallbackModels('
|
|
47
|
+
assert.deepEqual(fallbackModels('generic'), ['gpt-4.1-mini', 'llama3.2']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('fetchGatewayCatalog types models from /model/info', async () => {
|
|
51
|
+
const originalFetch = globalThis.fetch;
|
|
52
|
+
globalThis.fetch = async (url) => {
|
|
53
|
+
assert.equal(url, 'http://gw:4000/model/info');
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
json: async () => ({
|
|
57
|
+
data: [
|
|
58
|
+
{ model_name: 'anthropic/claude-sonnet-4-5', model_info: { mode: 'chat' } },
|
|
59
|
+
{ model_name: 'infinity/bge-m3', model_info: { mode: 'embedding' } },
|
|
60
|
+
{ model_name: 'infinity/bge-reranker', model_info: { mode: 'rerank' } },
|
|
61
|
+
{ model_name: 'dalle', model_info: { mode: 'image_generation' } },
|
|
62
|
+
],
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
68
|
+
assert.equal(result.ok, true);
|
|
69
|
+
assert.equal(result.typed, true);
|
|
70
|
+
assert.deepEqual(result.chat, ['anthropic/claude-sonnet-4-5']);
|
|
71
|
+
assert.deepEqual(result.embedding, ['infinity/bge-m3']);
|
|
72
|
+
assert.deepEqual(result.rerank, ['infinity/bge-reranker']);
|
|
73
|
+
} finally {
|
|
74
|
+
globalThis.fetch = originalFetch;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('fetchGatewayCatalog degrades to an untyped /v1/models list', async () => {
|
|
79
|
+
const originalFetch = globalThis.fetch;
|
|
80
|
+
globalThis.fetch = async (url) => {
|
|
81
|
+
if (url.endsWith('/model/info')) return { ok: false, status: 404 };
|
|
82
|
+
return { ok: true, json: async () => ({ data: [{ id: 'b' }, { id: 'a' }] }) };
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
86
|
+
assert.equal(result.ok, true);
|
|
87
|
+
assert.equal(result.typed, false);
|
|
88
|
+
// Non typé : les trois listes reçoivent la même chose, à charge du wizard
|
|
89
|
+
// de le signaler.
|
|
90
|
+
assert.deepEqual(result.chat, ['a', 'b']);
|
|
91
|
+
assert.deepEqual(result.embedding, ['a', 'b']);
|
|
92
|
+
assert.deepEqual(result.rerank, ['a', 'b']);
|
|
93
|
+
assert.match(result.error, /HTTP 404/);
|
|
94
|
+
} finally {
|
|
95
|
+
globalThis.fetch = originalFetch;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('fetchGatewayCatalog reports an unreachable gateway without inventing models', async () => {
|
|
100
|
+
const originalFetch = globalThis.fetch;
|
|
101
|
+
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
|
102
|
+
try {
|
|
103
|
+
const result = await fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
104
|
+
assert.equal(result.ok, false);
|
|
105
|
+
assert.equal(result.source, 'unreachable');
|
|
106
|
+
assert.deepEqual(result.chat, []);
|
|
107
|
+
assert.match(result.error, /ECONNREFUSED/);
|
|
108
|
+
} finally {
|
|
109
|
+
globalThis.fetch = originalFetch;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('fetchGatewayCatalog queries /model/info and /v1/models in parallel', async () => {
|
|
114
|
+
const originalFetch = globalThis.fetch;
|
|
115
|
+
const started = [];
|
|
116
|
+
let releaseModelInfo;
|
|
117
|
+
const modelInfoGate = new Promise((resolve) => { releaseModelInfo = resolve; });
|
|
118
|
+
globalThis.fetch = async (url) => {
|
|
119
|
+
started.push(url);
|
|
120
|
+
if (url.endsWith('/model/info')) {
|
|
121
|
+
await modelInfoGate;
|
|
122
|
+
return { ok: false, status: 404 };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, json: async () => ({ data: [{ id: 'a' }] }) };
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
const pending = fetchGatewayCatalog('http://gw:4000/v1', 'key', { timeoutMs: 100 });
|
|
128
|
+
// Le repli ne doit pas attendre la fin du chemin typé : les deux requêtes
|
|
129
|
+
// sont déjà parties quand /model/info est encore en vol.
|
|
130
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
131
|
+
assert.equal(started.length, 2);
|
|
132
|
+
releaseModelInfo();
|
|
133
|
+
const result = await pending;
|
|
134
|
+
assert.equal(result.ok, true);
|
|
135
|
+
assert.equal(result.typed, false);
|
|
136
|
+
assert.deepEqual(result.chat, ['a']);
|
|
137
|
+
} finally {
|
|
138
|
+
globalThis.fetch = originalFetch;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('HTTP failures name the likely cause instead of a bare status', async () => {
|
|
143
|
+
const originalFetch = globalThis.fetch;
|
|
144
|
+
globalThis.fetch = async () => ({ ok: false, status: 401 });
|
|
145
|
+
try {
|
|
146
|
+
const result = await fetchModels('openai', 'https://api.openai.com/v1', 'bad', {
|
|
147
|
+
timeoutMs: 50,
|
|
148
|
+
});
|
|
149
|
+
assert.equal(result.ok, false);
|
|
150
|
+
assert.match(result.error, /HTTP 401/);
|
|
151
|
+
assert.match(result.error, /API key rejected/);
|
|
152
|
+
} finally {
|
|
153
|
+
globalThis.fetch = originalFetch;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('describeFetchError turns transport codes into an actionable sentence', () => {
|
|
158
|
+
const tls = Object.assign(new Error('fetch failed'), {
|
|
159
|
+
cause: { code: 'SELF_SIGNED_CERT_IN_CHAIN' },
|
|
160
|
+
});
|
|
161
|
+
assert.match(describeFetchError(tls, { url: 'https://gw:4000/v1/models' }), /--cacert/);
|
|
162
|
+
|
|
163
|
+
const refused = Object.assign(new Error('fetch failed'), {
|
|
164
|
+
cause: { code: 'ECONNREFUSED' },
|
|
165
|
+
});
|
|
166
|
+
assert.match(describeFetchError(refused, { url: 'http://gw:4000/v1' }), /gw:4000/);
|
|
167
|
+
|
|
168
|
+
const dns = Object.assign(new Error('fetch failed'), { cause: { code: 'ENOTFOUND' } });
|
|
169
|
+
assert.match(describeFetchError(dns, { url: 'http://nope.local/v1' }), /host not found/);
|
|
170
|
+
|
|
171
|
+
// Le délai en millisecondes est un détail d'implémentation : le message
|
|
172
|
+
// nomme l'hôte muet et les causes probables, pas la valeur du timeout.
|
|
173
|
+
const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
174
|
+
const timedOut = describeFetchError(aborted, { url: 'http://gw:4000/v1', timeoutMs: 8000 });
|
|
175
|
+
assert.match(timedOut, /gw:4000 did not answer in time/);
|
|
176
|
+
assert.match(timedOut, /proxy or firewall/);
|
|
177
|
+
assert.doesNotMatch(timedOut, /\d+ ms/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('transportSummary warns when a declared proxy is not actually used', () => {
|
|
181
|
+
assert.match(
|
|
182
|
+
transportSummary({ HTTPS_PROXY: 'http://proxy:3128' }),
|
|
183
|
+
/NODE_USE_ENV_PROXY not set/,
|
|
184
|
+
);
|
|
185
|
+
assert.match(
|
|
186
|
+
transportSummary({ HTTPS_PROXY: 'http://proxy:3128', NODE_USE_ENV_PROXY: '1' }),
|
|
187
|
+
/proxy http:\/\/proxy:3128/,
|
|
188
|
+
);
|
|
189
|
+
assert.match(transportSummary({}), /direct connection/);
|
|
190
|
+
assert.match(transportSummary({ WIKI_MANAGER_CACERT_PATH: '/ca.pem' }), /CA \/ca\.pem/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('the discovery timeout only bounds silent failures, never a healthy endpoint', () => {
|
|
194
|
+
// Assez long pour un endpoint distant lent, assez court pour qu'un proxy qui
|
|
195
|
+
// avale la connexion finisse par se dénoncer. Il n'est jamais attendu par
|
|
196
|
+
// une étape du wizard : la découverte tourne en tâche de fond.
|
|
197
|
+
assert.ok(DISCOVERY_TIMEOUT_MS >= 5000 && DISCOVERY_TIMEOUT_MS <= 15000);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the gateway hands over the flat list before /model/info completes', async () => {
|
|
201
|
+
const originalFetch = globalThis.fetch;
|
|
202
|
+
let releaseModelInfo;
|
|
203
|
+
const modelInfoGate = new Promise((resolve) => { releaseModelInfo = resolve; });
|
|
204
|
+
globalThis.fetch = async (url) => {
|
|
205
|
+
if (url.endsWith('/model/info')) {
|
|
206
|
+
await modelInfoGate;
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
json: async () => ({
|
|
210
|
+
data: [{ model_name: 'chat-a', model_info: { mode: 'chat' } }],
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, json: async () => ({ data: [{ id: 'chat-a' }, { id: 'embed-b' }] }) };
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
const partials = [];
|
|
218
|
+
const pending = fetchGatewayCatalog('http://gw:4000/v1', 'key', {
|
|
219
|
+
timeoutMs: 100,
|
|
220
|
+
onPartial: (partial) => partials.push(partial),
|
|
221
|
+
});
|
|
222
|
+
// La liste plate doit être livrée pendant que /model/info est encore en vol.
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
224
|
+
assert.equal(partials.length, 1);
|
|
225
|
+
assert.equal(partials[0].typed, false);
|
|
226
|
+
assert.deepEqual(partials[0].chat, ['chat-a', 'embed-b']);
|
|
227
|
+
|
|
228
|
+
releaseModelInfo();
|
|
229
|
+
const result = await pending;
|
|
230
|
+
assert.equal(result.typed, true);
|
|
231
|
+
assert.deepEqual(result.chat, ['chat-a']);
|
|
232
|
+
// Le typage arrivé, plus aucune livraison partielle ne doit suivre.
|
|
233
|
+
assert.equal(partials.length, 1);
|
|
234
|
+
} finally {
|
|
235
|
+
globalThis.fetch = originalFetch;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('classifyModelEntry reads the type hints real servers actually send', () => {
|
|
240
|
+
// Albert.
|
|
241
|
+
assert.equal(classifyModelEntry({ type: 'text-generation' }), 'chat');
|
|
242
|
+
assert.equal(classifyModelEntry({ type: 'text-embeddings-inference' }), 'embedding');
|
|
243
|
+
assert.equal(classifyModelEntry({ type: 'text-classification' }), 'rerank');
|
|
244
|
+
// Hors périmètre : absent des trois listes plutôt que mal classé.
|
|
245
|
+
assert.equal(classifyModelEntry({ type: 'automatic-speech-recognition' }), null);
|
|
246
|
+
// LiteLLM.
|
|
247
|
+
assert.equal(classifyModelEntry({ model_info: { mode: 'embedding' } }), 'embedding');
|
|
248
|
+
// OpenAI ne type rien : `object: "model"` ne doit pas être pris pour un type.
|
|
249
|
+
assert.equal(classifyModelEntry({ id: 'gpt-4.1', object: 'model' }), null);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('fetchServerCatalog splits a direct server catalog by model type', async () => {
|
|
253
|
+
const originalFetch = globalThis.fetch;
|
|
254
|
+
globalThis.fetch = async () => ({
|
|
255
|
+
ok: true,
|
|
256
|
+
json: async () => ({
|
|
257
|
+
data: [
|
|
258
|
+
{ id: 'openai/gpt-oss-120b', type: 'text-generation' },
|
|
259
|
+
{ id: 'bge-m3', type: 'text-embeddings-inference' },
|
|
260
|
+
{ id: 'bge-reranker-v2-m3', type: 'text-classification' },
|
|
261
|
+
{ id: 'whisper-large-v3', type: 'automatic-speech-recognition' },
|
|
262
|
+
],
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
try {
|
|
266
|
+
const result = await fetchServerCatalog('openai-compatible', 'https://albert.example/v1', 'key', {
|
|
267
|
+
engine: 'albert',
|
|
268
|
+
timeoutMs: 100,
|
|
269
|
+
});
|
|
270
|
+
assert.equal(result.ok, true);
|
|
271
|
+
assert.equal(result.typed, true);
|
|
272
|
+
assert.deepEqual(result.chat, ['openai/gpt-oss-120b']);
|
|
273
|
+
assert.deepEqual(result.embedding, ['bge-m3']);
|
|
274
|
+
assert.deepEqual(result.rerank, ['bge-reranker-v2-m3']);
|
|
275
|
+
} finally {
|
|
276
|
+
globalThis.fetch = originalFetch;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('fetchServerCatalog stays untyped when the server types nothing', async () => {
|
|
281
|
+
const originalFetch = globalThis.fetch;
|
|
282
|
+
globalThis.fetch = async () => ({
|
|
283
|
+
ok: true,
|
|
284
|
+
json: async () => ({ data: [{ id: 'b', object: 'model' }, { id: 'a', object: 'model' }] }),
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
const result = await fetchServerCatalog('openai-compatible', 'https://api.openai.com/v1', 'key', {
|
|
288
|
+
engine: 'openai',
|
|
289
|
+
timeoutMs: 100,
|
|
290
|
+
});
|
|
291
|
+
assert.equal(result.typed, false);
|
|
292
|
+
assert.deepEqual(result.chat, ['a', 'b']);
|
|
293
|
+
assert.deepEqual(result.embedding, ['a', 'b']);
|
|
294
|
+
} finally {
|
|
295
|
+
globalThis.fetch = originalFetch;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('a partially typed catalog falls back to the full list, never to an empty one', async () => {
|
|
300
|
+
const originalFetch = globalThis.fetch;
|
|
301
|
+
globalThis.fetch = async () => ({
|
|
302
|
+
ok: true,
|
|
303
|
+
json: async () => ({
|
|
304
|
+
data: [{ id: 'embed-only', type: 'text-embeddings-inference' }, { id: 'mystery' }],
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
const result = await fetchServerCatalog('openai-compatible', 'https://x/v1', 'key', {
|
|
309
|
+
timeoutMs: 100,
|
|
310
|
+
});
|
|
311
|
+
assert.equal(result.typed, true);
|
|
312
|
+
assert.deepEqual(result.embedding, ['embed-only']);
|
|
313
|
+
// Aucun modèle annoncé comme chat : proposer toute la liste vaut mieux
|
|
314
|
+
// qu'une étape sans aucune suggestion.
|
|
315
|
+
assert.deepEqual(result.chat, ['embed-only', 'mystery']);
|
|
316
|
+
} finally {
|
|
317
|
+
globalThis.fetch = originalFetch;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('requiresBaseUrl follows the engine, and the gateway always needs one', () => {
|
|
322
|
+
assert.equal(requiresBaseUrl('openai-compatible', 'ollama'), true);
|
|
323
|
+
assert.equal(requiresBaseUrl('openai-compatible', 'openai'), false);
|
|
324
|
+
assert.equal(requiresBaseUrl('openai-compatible', 'anthropic'), false);
|
|
325
|
+
assert.equal(requiresBaseUrl('ai-gateway', 'openai'), true);
|
|
38
326
|
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { PROFILE_FLAGS, profileServiceStatus } from './agentsCompose.js';
|
|
6
|
+
|
|
7
|
+
test('a disabled service says which flag, in which file, and what to do', () => {
|
|
8
|
+
const status = profileServiceStatus('connectors', { env: {} });
|
|
9
|
+
assert.equal(status.enabled, false);
|
|
10
|
+
assert.equal(status.flag, 'CONNECTORS_ENABLED');
|
|
11
|
+
// Le message d'origine — « unavailable or disabled » — ne disait ni lequel
|
|
12
|
+
// des deux, ni où regarder : Donna, faute de fait à citer, inventait un
|
|
13
|
+
// `cme.yaml` et un « manifeste des services actifs ».
|
|
14
|
+
assert.match(status.message, /CONNECTORS_ENABLED is not set/);
|
|
15
|
+
assert.match(status.message, /set CONNECTORS_ENABLED=true/);
|
|
16
|
+
assert.match(status.message, /\/start agents/);
|
|
17
|
+
assert.match(status.message, /\.env/);
|
|
18
|
+
// Le piège du profil Compose : le conteneur n'existe pas du tout, ce qui
|
|
19
|
+
// ressemble à un service planté.
|
|
20
|
+
assert.match(status.message, /does not exist at all/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a disabled service quotes the value actually found', () => {
|
|
24
|
+
const status = profileServiceStatus('connectors', { env: { CONNECTORS_ENABLED: 'false' } });
|
|
25
|
+
assert.equal(status.enabled, false);
|
|
26
|
+
assert.match(status.message, /CONNECTORS_ENABLED is "false"/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('an enabled but unreachable service is a different diagnosis', () => {
|
|
30
|
+
const status = profileServiceStatus('connectors', { env: { CONNECTORS_ENABLED: 'true' } });
|
|
31
|
+
assert.equal(status.enabled, true);
|
|
32
|
+
assert.match(status.message, /enabled .* but not reachable/);
|
|
33
|
+
assert.doesNotMatch(status.message, /set CONNECTORS_ENABLED=true/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('an unknown service is not described as disabled', () => {
|
|
37
|
+
const status = profileServiceStatus('nope', { env: {} });
|
|
38
|
+
assert.equal(status.flag, null);
|
|
39
|
+
assert.match(status.message, /no "nope" service/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('/connector reports the reason instead of a bare "unavailable or disabled"', () => {
|
|
43
|
+
const source = readFileSync(
|
|
44
|
+
fileURLToPath(new URL('../commands/slash.js', import.meta.url)),
|
|
45
|
+
'utf8',
|
|
46
|
+
);
|
|
47
|
+
assert.match(source, /profileServiceStatus\('connectors'\)\.message/);
|
|
48
|
+
assert.doesNotMatch(source, /The connectors service is unavailable or disabled\./);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('every profile flag is documented where the operator will look', () => {
|
|
52
|
+
const doc = readFileSync(
|
|
53
|
+
fileURLToPath(new URL('../../../llm-wiki/help-doc/06-troubleshooting.md', import.meta.url)),
|
|
54
|
+
'utf8',
|
|
55
|
+
);
|
|
56
|
+
// Donna répond aux questions produit depuis cette documentation : un drapeau
|
|
57
|
+
// absent d'ici est un drapeau qu'elle ne peut que deviner.
|
|
58
|
+
for (const flag of Object.values(PROFILE_FLAGS)) {
|
|
59
|
+
assert.ok(doc.includes(flag), `${flag} must be documented in help-doc/06-troubleshooting.md`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('stopping the agents resolves the same manager files as starting them', () => {
|
|
64
|
+
const source = readFileSync(
|
|
65
|
+
fileURLToPath(new URL('./wikiSetup.js', import.meta.url)),
|
|
66
|
+
'utf8',
|
|
67
|
+
);
|
|
68
|
+
// `stopAgents` n'épinglait pas WIKI_MANAGER_ENV_FILE : le script résolvait
|
|
69
|
+
// `.env` contre le répertoire du paquet, `connectors_enabled` y lisait un
|
|
70
|
+
// profil inactif, et `docker compose down` partait sans
|
|
71
|
+
// `--profile connectors`. Un service à profil inactif étant invisible à
|
|
72
|
+
// Compose, le conteneur restait debout — `/stop agents` annonçait un succès
|
|
73
|
+
// en laissant tourner ce qu'il prétendait arrêter.
|
|
74
|
+
const stopBlock = source.slice(source.indexOf('export async function stopAgents'));
|
|
75
|
+
for (const key of ['WIKI_MANAGER_ENV_FILE: managerEnvFile()', 'WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile()']) {
|
|
76
|
+
assert.ok(stopBlock.includes(key), `stopAgents must pin ${key}`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('"all" means the same thing to start and to stop', () => {
|
|
81
|
+
const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
|
|
82
|
+
// `/start all` démarrait les agents, `/stop all` ne les arrêtait pas : la
|
|
83
|
+
// séquence start/stop ne revenait donc pas à l'état de départ.
|
|
84
|
+
assert.match(slash, /const startsAgents = service === 'all';/);
|
|
85
|
+
assert.match(slash, /const stopsAgents = service === 'all';/);
|
|
86
|
+
assert.match(slash, /if \(stopsAgents\) \{\s*\n\s*const agentsResult = await runAgentCommand\(stopAgents, 'stop'\);/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('the addressable agents are read from the compose file, never hard-coded', async () => {
|
|
90
|
+
const { agentServiceNames, togglableAgentNames } = await import('./agentsCompose.js');
|
|
91
|
+
const declared = agentServiceNames();
|
|
92
|
+
|
|
93
|
+
// Une liste figée annonçait `mailer`, qui ne vit que dans l'exemple
|
|
94
|
+
// d'override : proposer un service absent, c'est promettre un
|
|
95
|
+
// « no such service ».
|
|
96
|
+
assert.ok(declared.includes('connectors'));
|
|
97
|
+
assert.ok(declared.includes('cme'));
|
|
98
|
+
assert.ok(!declared.includes('mailer'), 'mailer is not declared until the operator uncomments it');
|
|
99
|
+
|
|
100
|
+
// Et seuls les agents à drapeau se proposent un par un dans l'interface.
|
|
101
|
+
assert.deepEqual(togglableAgentNames(), ['connectors']);
|
|
102
|
+
for (const name of togglableAgentNames()) {
|
|
103
|
+
assert.ok(declared.includes(name), `${name} must actually exist in the agents stack`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('an agent can be started and stopped on its own', () => {
|
|
108
|
+
const setup = readFileSync(fileURLToPath(new URL('./wikiSetup.js', import.meta.url)), 'utf8');
|
|
109
|
+
const slash = readFileSync(fileURLToPath(new URL('../commands/slash.js', import.meta.url)), 'utf8');
|
|
110
|
+
const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
|
|
111
|
+
|
|
112
|
+
assert.match(setup, /'agents', 'up', \.\.\.\(options\.services \?\? \[\]\)/);
|
|
113
|
+
assert.match(setup, /'agents', 'down', \.\.\.\(options\.services \?\? \[\]\)/);
|
|
114
|
+
// La pile agents est un projet Compose distinct : sans ce routage, `/start
|
|
115
|
+
// connectors` allait chercher un service inexistant côté workspace.
|
|
116
|
+
assert.match(slash, /agentServiceNames\(\)\.includes\(service\)/);
|
|
117
|
+
// `down <service>` supprimerait le réseau commun : Compose impose stop + rm.
|
|
118
|
+
assert.match(script, /_agents_dc stop "\$@"/);
|
|
119
|
+
assert.match(script, /_agents_dc rm -f "\$@"/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a connector read tool is not mistaken for the limit of its agent', () => {
|
|
123
|
+
const graph = readFileSync(fileURLToPath(new URL('../agent/graph.js', import.meta.url)), 'utf8');
|
|
124
|
+
// « récupère ce mail » et « envoie un mail » ont été refusés comme
|
|
125
|
+
// impossibles, alors que l'agent connectors déclare `external-source.collect`
|
|
126
|
+
// et `communication.send-email`. Donna raisonnait sur les outils de lecture
|
|
127
|
+
// exposés au chat — qui ne rendent que des métadonnées — au lieu de déléguer.
|
|
128
|
+
assert.match(graph, /direct read tools of a connector are a preview/);
|
|
129
|
+
assert.match(graph, /delegated through runtime__delegate, not answered from a read tool/);
|
|
130
|
+
// Et un droit manquant doit être nommé comme tel, pas présenté comme une
|
|
131
|
+
// fonctionnalité absente.
|
|
132
|
+
assert.match(graph, /lack of an authorization grant or scope/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('the system prompt points product questions at the documentation tools', () => {
|
|
136
|
+
const graph = readFileSync(
|
|
137
|
+
fileURLToPath(new URL('../agent/graph.js', import.meta.url)),
|
|
138
|
+
'utf8',
|
|
139
|
+
);
|
|
140
|
+
// Les outils help_* sont exposés (allow-list du mode chat) mais le prompt ne
|
|
141
|
+
// disait nulle part qu'ils sont LA source des questions produit : Donna
|
|
142
|
+
// répondait de mémoire, et inventait.
|
|
143
|
+
assert.match(graph, /bundled product documentation is your source/);
|
|
144
|
+
assert.match(graph, /call the documentation search\/read tools FIRST/);
|
|
145
|
+
assert.match(graph, /Configuration facts are never answered from memory/);
|
|
146
|
+
});
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -72,7 +72,7 @@ export async function startAgents(options = {}) {
|
|
|
72
72
|
: await missingAgentImages(composeContext);
|
|
73
73
|
if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
|
|
74
74
|
const exec = options.exec ?? execFileAsync;
|
|
75
|
-
const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
|
|
75
|
+
const { stdout, stderr } = await exec(join(managerRoot(), 'wiki-workspace'), ['agents', 'up', ...(options.services ?? [])], {
|
|
76
76
|
cwd: managerRoot(),
|
|
77
77
|
env: {
|
|
78
78
|
...process.env,
|
|
@@ -148,11 +148,20 @@ function agentsStartFailure({ downServices = [], missingServices = [], profiles
|
|
|
148
148
|
|
|
149
149
|
export async function stopAgents(options = {}) {
|
|
150
150
|
try {
|
|
151
|
-
const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down'], {
|
|
151
|
+
const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down', ...(options.services ?? [])], {
|
|
152
152
|
cwd: managerRoot(),
|
|
153
153
|
env: {
|
|
154
154
|
...process.env,
|
|
155
155
|
WIKI_WORKSPACES_DIR: workspacesDir(),
|
|
156
|
+
// Même épinglage que startAgents, et pour la même raison : cwd est la
|
|
157
|
+
// racine du paquet, donc sans ces variables le script résout `.env`
|
|
158
|
+
// contre $PWD. `connectors_enabled` lisait alors le .env du PAQUET,
|
|
159
|
+
// concluait que le profil était inactif, et `docker compose down`
|
|
160
|
+
// partait sans `--profile connectors` — un service à profil inactif est
|
|
161
|
+
// invisible à Compose, donc le conteneur restait debout. `/stop agents`
|
|
162
|
+
// annonçait un succès en laissant tourner ce qu'il prétendait arrêter.
|
|
163
|
+
WIKI_MANAGER_ENV_FILE: managerEnvFile(),
|
|
164
|
+
WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
|
|
156
165
|
AGENTS_DATA_DIR: resolveAgentsDataDir(),
|
|
157
166
|
},
|
|
158
167
|
timeout: options.timeout ?? 120_000,
|
|
@@ -261,11 +270,37 @@ export function writeLanguageConfig(workspacePath, profileName, language) {
|
|
|
261
270
|
return patchWikircProfile(workspacePath, profileName || 'default', { language });
|
|
262
271
|
}
|
|
263
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Le wizard tourne sur l'hôte, les services dans Docker : une baseUrl saisie
|
|
275
|
+
* en `localhost` répond au wizard et échoue dans le container. On la réécrit
|
|
276
|
+
* vers `host.docker.internal`, que les services déclarent déjà en
|
|
277
|
+
* `extra_hosts`. Jamais sur un hostname réel — seulement sur les trois formes
|
|
278
|
+
* de boucle locale. L'appelant affiche la réécriture : la faire en silence
|
|
279
|
+
* rend le diagnostic impossible quand elle se trompe.
|
|
280
|
+
*/
|
|
281
|
+
export function containerReachableUrl(baseUrl) {
|
|
282
|
+
if (!baseUrl) return { url: baseUrl, rewritten: false };
|
|
283
|
+
try {
|
|
284
|
+
const parsed = new URL(baseUrl);
|
|
285
|
+
if (!['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname)) {
|
|
286
|
+
return { url: baseUrl, rewritten: false };
|
|
287
|
+
}
|
|
288
|
+
const original = parsed.hostname;
|
|
289
|
+
parsed.hostname = 'host.docker.internal';
|
|
290
|
+
return { url: parsed.toString().replace(/\/$/, ''), rewritten: true, from: original };
|
|
291
|
+
} catch {
|
|
292
|
+
return { url: baseUrl, rewritten: false };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
264
296
|
export function writeLlmConfig(workspacePath, profileName, config) {
|
|
265
297
|
const patches = {
|
|
266
298
|
llm: {
|
|
267
299
|
provider: config.provider,
|
|
268
|
-
|
|
300
|
+
// Absent derrière une gateway : l'endpoint est opaque, il n'y a pas un
|
|
301
|
+
// moteur mais un par modèle.
|
|
302
|
+
...(config.engine ? { engine: config.engine } : {}),
|
|
303
|
+
...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
|
|
269
304
|
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
270
305
|
model: config.model,
|
|
271
306
|
},
|
|
@@ -278,7 +313,11 @@ export function writeVectorConfig(workspacePath, profileName, config) {
|
|
|
278
313
|
retrieval: {
|
|
279
314
|
vector: {
|
|
280
315
|
enabled: true,
|
|
281
|
-
|
|
316
|
+
// baseUrl et apiKey absents = hérités du bloc llm par resolveConfig.
|
|
317
|
+
// Le wizard ne les transmet que lorsqu'ils divergent réellement, pour
|
|
318
|
+
// que le wikirc reste lisible et que la clé du LLM ne soit pas
|
|
319
|
+
// recopiée vers un autre hôte.
|
|
320
|
+
...(config.baseUrl ? { baseUrl: containerReachableUrl(config.baseUrl).url } : {}),
|
|
282
321
|
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
283
322
|
timeoutMs: config.timeoutMs ?? 600_000,
|
|
284
323
|
embeddingModel: config.embeddingModel,
|
|
@@ -66,11 +66,17 @@ test('wiki-workspace provisions connector secrets and persistent state', async (
|
|
|
66
66
|
assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test('
|
|
69
|
+
test('the manager never builds an image, it only runs published ones', async () => {
|
|
70
70
|
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// Ce script est livré dans le paquet npm, où le dépôt agent-connectors
|
|
73
|
+
// n'existe pas : il ne peut ni construire l'image, ni lire le
|
|
74
|
+
// .env.build.local qui porte l'application OAuth. La construction appartient
|
|
75
|
+
// à build-and-push.sh. Pour utiliser sa propre application Google,
|
|
76
|
+
// l'opérateur surcharge GOOGLE_OAUTH_CLIENT_ID / _SECRET dans le .env.
|
|
77
|
+
assert.doesNotMatch(script, /--build\b/);
|
|
78
|
+
assert.doesNotMatch(script, /\.env\.build\.local/);
|
|
79
|
+
assert.doesNotMatch(script, /require_connectors_build_credentials/);
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
test('workspace creation keeps mutable manager files outside the installed package', async () => {
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -5,7 +5,12 @@ import { join } from 'node:path';
|
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { loadWikircProfile, normalizeCapabilityRouting, patchWikircProfile } from './wikirc.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
containerReachableUrl,
|
|
10
|
+
finalizeCreatedWorkspace,
|
|
11
|
+
writeLlmConfig,
|
|
12
|
+
writeVectorConfig,
|
|
13
|
+
} from './wikiSetup.js';
|
|
9
14
|
|
|
10
15
|
test('patchWikircProfile merges keys and preserves existing values', () => {
|
|
11
16
|
const root = mkdtempSync(join(tmpdir(), 'wikirc-patch-'));
|
|
@@ -106,7 +111,7 @@ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
|
|
|
106
111
|
].join('\n'), 'utf8');
|
|
107
112
|
|
|
108
113
|
writeVectorConfig(root, 'default', {
|
|
109
|
-
baseUrl: 'http://
|
|
114
|
+
baseUrl: 'http://host.docker.internal:7997/v1',
|
|
110
115
|
apiKey: 'vector-key',
|
|
111
116
|
embeddingModel: 'BAAI/bge-m3',
|
|
112
117
|
rerankEnabled: true,
|
|
@@ -115,7 +120,7 @@ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
|
|
|
115
120
|
|
|
116
121
|
const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
|
|
117
122
|
assert.equal(parsed.retrieval.vector.enabled, true);
|
|
118
|
-
assert.equal(parsed.retrieval.vector.baseUrl, 'http://
|
|
123
|
+
assert.equal(parsed.retrieval.vector.baseUrl, 'http://host.docker.internal:7997/v1');
|
|
119
124
|
assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
|
|
120
125
|
assert.equal(parsed.retrieval.vector.embeddingModel, 'BAAI/bge-m3');
|
|
121
126
|
assert.equal(parsed.retrieval.vector.rerankEnabled, true);
|
|
@@ -143,7 +148,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
|
|
|
143
148
|
].join('\n'), 'utf8');
|
|
144
149
|
|
|
145
150
|
writeVectorConfig(root, 'default', {
|
|
146
|
-
baseUrl: 'http://
|
|
151
|
+
baseUrl: 'http://host.docker.internal:7997/v1',
|
|
147
152
|
apiKey: 'vector-key',
|
|
148
153
|
embeddingModel: 'BAAI/bge-m3',
|
|
149
154
|
rerankEnabled: true,
|
|
@@ -154,7 +159,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
|
|
|
154
159
|
const parsed = YAML.parse(raw);
|
|
155
160
|
assert.doesNotMatch(raw, /^\s*#\s*baseUrl:/m);
|
|
156
161
|
assert.doesNotMatch(raw, /^\s*#\s*apiKey:/m);
|
|
157
|
-
assert.equal(parsed.retrieval.vector.baseUrl, 'http://
|
|
162
|
+
assert.equal(parsed.retrieval.vector.baseUrl, 'http://host.docker.internal:7997/v1');
|
|
158
163
|
assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
|
|
159
164
|
});
|
|
160
165
|
|
|
@@ -195,3 +200,52 @@ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc',
|
|
|
195
200
|
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
196
201
|
}
|
|
197
202
|
});
|
|
203
|
+
|
|
204
|
+
test('writeLlmConfig writes provider + engine and rewrites loopback hosts for containers', () => {
|
|
205
|
+
const root = mkdtempSync(join(tmpdir(), 'wikirc-llm-'));
|
|
206
|
+
writeFileSync(join(root, '.wikirc.yaml'), ['language: en', ''].join('\n'), 'utf8');
|
|
207
|
+
|
|
208
|
+
writeLlmConfig(root, 'default', {
|
|
209
|
+
provider: 'openai-compatible',
|
|
210
|
+
engine: 'ollama',
|
|
211
|
+
baseUrl: 'http://localhost:11434/v1',
|
|
212
|
+
apiKey: 'ollama',
|
|
213
|
+
model: 'qwen2.5',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
|
|
217
|
+
assert.equal(parsed.llm.provider, 'openai-compatible');
|
|
218
|
+
assert.equal(parsed.llm.engine, 'ollama');
|
|
219
|
+
// Le wizard tourne sur l'hôte, serve dans Docker : localhost y désigne le
|
|
220
|
+
// container lui-meme.
|
|
221
|
+
assert.equal(parsed.llm.baseUrl, 'http://host.docker.internal:11434/v1');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('writeLlmConfig omits engine behind a gateway and leaves real hostnames alone', () => {
|
|
225
|
+
const root = mkdtempSync(join(tmpdir(), 'wikirc-llm-gw-'));
|
|
226
|
+
writeFileSync(join(root, '.wikirc.yaml'), ['language: en', ''].join('\n'), 'utf8');
|
|
227
|
+
|
|
228
|
+
writeLlmConfig(root, 'default', {
|
|
229
|
+
provider: 'ai-gateway',
|
|
230
|
+
engine: null,
|
|
231
|
+
baseUrl: 'https://gateway.internal.example/v1',
|
|
232
|
+
apiKey: 'sk-virtual',
|
|
233
|
+
model: 'anthropic/claude-sonnet-4-5',
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
|
|
237
|
+
assert.equal(parsed.llm.provider, 'ai-gateway');
|
|
238
|
+
assert.equal(parsed.llm.engine, undefined);
|
|
239
|
+
assert.equal(parsed.llm.baseUrl, 'https://gateway.internal.example/v1');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('containerReachableUrl only touches loopback hosts', () => {
|
|
243
|
+
assert.deepEqual(containerReachableUrl('http://127.0.0.1:8000/v1'), {
|
|
244
|
+
url: 'http://host.docker.internal:8000/v1',
|
|
245
|
+
rewritten: true,
|
|
246
|
+
from: '127.0.0.1',
|
|
247
|
+
});
|
|
248
|
+
assert.equal(containerReachableUrl('https://api.openai.com/v1').rewritten, false);
|
|
249
|
+
assert.equal(containerReachableUrl('http://gateway:4000/v1').rewritten, false);
|
|
250
|
+
assert.equal(containerReachableUrl(undefined).rewritten, false);
|
|
251
|
+
});
|