@dotdrelle/wiki-manager 0.15.41 → 0.15.42

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.
@@ -105,3 +105,33 @@ test('container refresh pulls and renews only services that are already running'
105
105
  assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
106
106
  assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
107
107
  });
108
+
109
+ // `${arr[@]}` on an empty array is an unbound-variable error under `set -u` in
110
+ // bash <= 4.3, and macOS still ships /bin/bash 3.2. `parse_global_options`
111
+ // tripped on it, so `wiki-workspace` with no global option died before reaching
112
+ // any subcommand.
113
+ //
114
+ // The execution check below cannot catch a regression on a CI running bash 5,
115
+ // where an empty array is legal — hence the source assertion that every
116
+ // optional array keeps the `${arr[@]+"${arr[@]}"}` guard. Both are needed.
117
+ test('optional arrays keep the empty-array guard required by bash 3.2', async () => {
118
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
119
+
120
+ for (const name of ['parsed', 'PARSED_ARGS', 'log_args']) {
121
+ const unguarded = new RegExp(String.raw`(?<!\+)"\$\{${name}\[@\]\}"`);
122
+ assert.doesNotMatch(script, unguarded, `${name}[@] must use the \${name[@]+"..."} guard`);
123
+ }
124
+ assert.match(script, /set -- \$\{parsed\[@\]\+"\$\{parsed\[@\]\}"\}/);
125
+ assert.match(script, /set -- \$\{PARSED_ARGS\[@\]\+"\$\{PARSED_ARGS\[@\]\}"\}/);
126
+ });
127
+
128
+ test('wiki-workspace reaches its usage text with no arguments', async () => {
129
+ const { spawnSync } = await import('node:child_process');
130
+ const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
131
+
132
+ const result = spawnSync('bash', [scriptPath], { encoding: 'utf8' });
133
+
134
+ assert.doesNotMatch(`${result.stdout}${result.stderr}`, /unbound variable/);
135
+ assert.match(result.stdout, /^Usage:/);
136
+ assert.equal(result.status, 2);
137
+ });
@@ -163,7 +163,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
163
163
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
164
164
  });
165
165
 
166
- test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', () => {
166
+ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', async () => {
167
167
  const root = mkdtempSync(join(tmpdir(), 'wikirc-workspace-token-'));
168
168
  const registryRoot = join(root, 'registry');
169
169
  const registryPath = join(registryRoot, 'demo');
@@ -190,7 +190,7 @@ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc',
190
190
  const previousDir = process.env.WIKI_WORKSPACES_DIR;
191
191
  process.env.WIKI_WORKSPACES_DIR = registryRoot;
192
192
  try {
193
- const workspace = finalizeCreatedWorkspace('demo');
193
+ const { workspace } = await finalizeCreatedWorkspace('demo');
194
194
  const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
195
195
 
196
196
  assert.equal(workspace.name, 'demo');
@@ -249,3 +249,122 @@ test('containerReachableUrl only touches loopback hosts', () => {
249
249
  assert.equal(containerReachableUrl('http://gateway:4000/v1').rewritten, false);
250
250
  assert.equal(containerReachableUrl(undefined).rewritten, false);
251
251
  });
252
+
253
+ // /new used to leave the operator re-entering the same endpoint, key and model
254
+ // they had just proven working in the workspace next door.
255
+ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async () => {
256
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-inherit-'));
257
+ const registryRoot = join(root, 'registry');
258
+ const agentsData = join(root, '.agents-data');
259
+ const token = 'b'.repeat(64);
260
+
261
+ const makeWorkspace = (name, wikirc) => {
262
+ const registryPath = join(registryRoot, name);
263
+ const workspacePath = join(root, name);
264
+ mkdirSync(registryPath, { recursive: true });
265
+ mkdirSync(workspacePath, { recursive: true });
266
+ writeFileSync(join(registryPath, '.env'), [
267
+ `WORKSPACE_NAME=${name}`,
268
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
269
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
270
+ '',
271
+ ].join('\n'), 'utf8');
272
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), wikirc, 'utf8');
273
+ return workspacePath;
274
+ };
275
+
276
+ makeWorkspace('acpi', [
277
+ 'language: en',
278
+ 'llm:',
279
+ ' provider: ai-gateway',
280
+ ' engine: generic',
281
+ ' baseUrl: https://itsdonna.events/v1',
282
+ ' model: deepseek-v4-pro',
283
+ ' apiKey: sk-real-gateway-key',
284
+ 'mcp:',
285
+ ' accessKey: ' + 'c'.repeat(64),
286
+ '',
287
+ ].join('\n'));
288
+
289
+ // Exactly what the scaffold writes: placeholders everywhere.
290
+ const targetPath = makeWorkspace('nouveau', [
291
+ 'language: en',
292
+ 'llm:',
293
+ ' provider: openai-compatible',
294
+ ' engine: generic',
295
+ ' baseUrl: https://mon-provider.example.com/v1',
296
+ ' model: YOUR_MODEL_NAME',
297
+ ' apiKey: YOUR_LLM_API_KEY',
298
+ 'mcp:',
299
+ ' # accessKey: your-secret-key',
300
+ '',
301
+ ].join('\n'));
302
+
303
+ mkdirSync(join(agentsData, 'cme', 'acpi', 'cme'), { recursive: true });
304
+ writeFileSync(join(agentsData, 'cme', 'acpi', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
+
306
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
307
+ const previousData = process.env.AGENTS_DATA_DIR;
308
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
309
+ process.env.AGENTS_DATA_DIR = agentsData;
310
+ try {
311
+ const { inherited } = await finalizeCreatedWorkspace('nouveau', { inheritFrom: 'acpi' });
312
+ const parsed = YAML.parse(readFileSync(join(targetPath, '.wikirc.yaml'), 'utf8'));
313
+
314
+ assert.equal(parsed.llm.baseUrl, 'https://itsdonna.events/v1');
315
+ assert.equal(parsed.llm.model, 'deepseek-v4-pro');
316
+ assert.equal(parsed.llm.apiKey, 'sk-real-gateway-key');
317
+ assert.equal(parsed.llm.provider, 'ai-gateway');
318
+ // The workspace keeps ITS own MCP credential, written just before.
319
+ assert.equal(parsed.mcp.accessKey, token);
320
+ assert.ok(inherited.includes('llm.baseUrl'));
321
+ assert.ok(inherited.includes('cme.app_data.json'));
322
+ assert.equal(
323
+ readFileSync(join(agentsData, 'cme', 'nouveau', 'cme', 'app_data.json'), 'utf8'),
324
+ '{"pat":"x"}',
325
+ );
326
+ } finally {
327
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
328
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
329
+ if (previousData === undefined) delete process.env.AGENTS_DATA_DIR;
330
+ else process.env.AGENTS_DATA_DIR = previousData;
331
+ }
332
+ });
333
+
334
+ test('finalizeCreatedWorkspace without a source keeps scaffold defaults', async () => {
335
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-noinherit-'));
336
+ const registryRoot = join(root, 'registry');
337
+ const registryPath = join(registryRoot, 'solo');
338
+ const workspacePath = join(root, 'solo');
339
+ const token = 'd'.repeat(64);
340
+ mkdirSync(registryPath, { recursive: true });
341
+ mkdirSync(workspacePath, { recursive: true });
342
+ writeFileSync(join(registryPath, '.env'), [
343
+ 'WORKSPACE_NAME=solo',
344
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
345
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
346
+ '',
347
+ ].join('\n'), 'utf8');
348
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
349
+ 'language: en',
350
+ 'llm:',
351
+ ' baseUrl: https://mon-provider.example.com/v1',
352
+ 'mcp:',
353
+ ' # accessKey: your-secret-key',
354
+ '',
355
+ ].join('\n'), 'utf8');
356
+
357
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
358
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
359
+ try {
360
+ const { inherited } = await finalizeCreatedWorkspace('solo');
361
+ const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
362
+
363
+ assert.deepEqual(inherited, []);
364
+ assert.equal(parsed.llm.baseUrl, 'https://mon-provider.example.com/v1');
365
+ assert.equal(parsed.mcp.accessKey, token);
366
+ } finally {
367
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
368
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
369
+ }
370
+ });
@@ -0,0 +1,149 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Config a freshly created workspace inherits from an existing one.
7
+ *
8
+ * A new workspace was scaffolded with placeholder values only
9
+ * (`YOUR_MODEL_NAME`, `https://mon-provider.example.com/v1`…), so the operator
10
+ * had to redo the whole LLM setup by hand — reachable endpoint, gateway key,
11
+ * model name, vector settings — every single time, for values they had already
12
+ * proven working next door. `/new` now carries them over.
13
+ *
14
+ * Two rules make this safe rather than merely convenient:
15
+ *
16
+ * - Only ABSENT or PLACEHOLDER target values are filled. Anything the
17
+ * scaffold or the operator already set for real is left alone, so this can
18
+ * never overwrite a deliberate choice.
19
+ * - `mcp.accessKey` is never inherited. It is the workspace's own credential
20
+ * and `initializeWorkspaceWikirc` has already written the right one.
21
+ */
22
+
23
+ // Kept identical to SetupWizard.tsx on purpose — the wizard applies the same
24
+ // "a scaffolded value is not an answer" rule. `workspaceInherit.test.js`
25
+ // asserts the two literals stay in sync.
26
+ export const PLACEHOLDER_VALUE_RE = /YOUR_|<your|example\.com|infinity\.local/i;
27
+
28
+ const INHERITED_LLM_KEYS = ['baseUrl', 'model', 'apiKey', 'temperature', 'timeoutMs'];
29
+
30
+ // `provider` and `engine` describe HOW the endpoint in `baseUrl` behaves, so
31
+ // they are bound to it rather than inherited on their own merit.
32
+ //
33
+ // The scaffold ships `provider: openai-compatible` / `engine: generic` as
34
+ // defaults, and those are real values — not placeholders the fill rule would
35
+ // replace. Inheriting a gateway's `baseUrl` while leaving them behind produced
36
+ // a config claiming a direct openai-compatible server against an AI gateway:
37
+ // exactly the conflation the provider/engine split was introduced to end.
38
+ const ENDPOINT_BOUND_KEYS = ['provider', 'engine'];
39
+
40
+ // `retrieval.vector` stays independent from `llm` but inherits provider/engine/
41
+ // baseUrl/apiKey when absent, so only keys the source states EXPLICITLY are
42
+ // worth carrying: copying an inherited value would freeze it and break the
43
+ // link the source deliberately left open.
44
+ const INHERITED_VECTOR_KEYS = [
45
+ 'enabled',
46
+ 'provider',
47
+ 'engine',
48
+ 'baseUrl',
49
+ 'apiKey',
50
+ 'embeddingModel',
51
+ 'rerankerModel',
52
+ ];
53
+
54
+ /** A value the operator actually chose, as opposed to scaffold filler. */
55
+ export function isRealValue(value) {
56
+ if (value === null || value === undefined) return false;
57
+ if (typeof value === 'boolean' || typeof value === 'number') return true;
58
+ if (typeof value !== 'string') return false;
59
+ const text = value.trim();
60
+ return text.length > 0 && !PLACEHOLDER_VALUE_RE.test(text);
61
+ }
62
+
63
+ function inheritSection(sourceSection, targetSection, keys, prefix, patch, inherited) {
64
+ for (const key of keys) {
65
+ const from = sourceSection?.[key];
66
+ if (!isRealValue(from)) continue;
67
+ // Present AND real on the target: a deliberate value, never overwritten.
68
+ if (isRealValue(targetSection?.[key])) continue;
69
+ patch[key] = from;
70
+ inherited.push(`${prefix}.${key}`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param sourceConfig parsed `.wikirc.yaml` of the workspace to copy from
76
+ * @param targetConfig parsed `.wikirc.yaml` of the freshly created workspace
77
+ * @returns `{ patch, inherited }` — `patch` is shaped for `patchWikircProfile`
78
+ * and is `{}` when there is nothing to carry over.
79
+ */
80
+ export function buildInheritedWikircPatch(sourceConfig, targetConfig) {
81
+ const inherited = [];
82
+ const llm = {};
83
+ const vector = {};
84
+
85
+ inheritSection(sourceConfig?.llm, targetConfig?.llm, INHERITED_LLM_KEYS, 'llm', llm, inherited);
86
+ // Only once the endpoint itself has been taken over.
87
+ if (llm.baseUrl) {
88
+ for (const key of ENDPOINT_BOUND_KEYS) {
89
+ const from = sourceConfig?.llm?.[key];
90
+ if (!isRealValue(from) || from === targetConfig?.llm?.[key]) continue;
91
+ llm[key] = from;
92
+ inherited.push(`llm.${key}`);
93
+ }
94
+ }
95
+
96
+ inheritSection(
97
+ sourceConfig?.retrieval?.vector,
98
+ targetConfig?.retrieval?.vector,
99
+ INHERITED_VECTOR_KEYS,
100
+ 'retrieval.vector',
101
+ vector,
102
+ inherited,
103
+ );
104
+
105
+ // The gateway key unlocks every provider behind it. If the vector endpoint
106
+ // ends up on a different host than the LLM one, an inherited apiKey would be
107
+ // sent there — the exact leak the wizard already refuses to create. Drop the
108
+ // inherited baseUrl rather than the key: staying on the LLM endpoint is the
109
+ // conservative default, and the operator can still point it elsewhere.
110
+ const effectiveLlmBaseUrl = llm.baseUrl ?? targetConfig?.llm?.baseUrl ?? sourceConfig?.llm?.baseUrl;
111
+ const effectiveVectorBaseUrl = vector.baseUrl ?? targetConfig?.retrieval?.vector?.baseUrl;
112
+ const hasOwnVectorKey = isRealValue(vector.apiKey ?? targetConfig?.retrieval?.vector?.apiKey);
113
+ if (effectiveVectorBaseUrl && effectiveVectorBaseUrl !== effectiveLlmBaseUrl && !hasOwnVectorKey) {
114
+ delete vector.baseUrl;
115
+ const index = inherited.indexOf('retrieval.vector.baseUrl');
116
+ if (index >= 0) inherited.splice(index, 1);
117
+ }
118
+
119
+ const patch = {};
120
+ if (Object.keys(llm).length > 0) patch.llm = llm;
121
+ if (Object.keys(vector).length > 0) patch.retrieval = { vector };
122
+ return { patch, inherited };
123
+ }
124
+
125
+ export function cmeCredentialsPath(agentsDataDir, workspaceName) {
126
+ return join(agentsDataDir, 'cme', workspaceName, 'cme', 'app_data.json');
127
+ }
128
+
129
+ /**
130
+ * Carry the Confluence credentials of an existing workspace over to a new one.
131
+ *
132
+ * `app_data.json` only — NOT `sources-manifest.yaml`. The credentials are a
133
+ * property of the operator's Confluence account and are identical everywhere;
134
+ * which spaces and pages a workspace exports is precisely what makes it a
135
+ * different workspace, and copying that would silently re-export someone
136
+ * else's scope on the first run.
137
+ */
138
+ export async function copyCmeCredentials(agentsDataDir, sourceWorkspace, targetWorkspace) {
139
+ if (!agentsDataDir || !sourceWorkspace || !targetWorkspace) return null;
140
+ if (sourceWorkspace === targetWorkspace) return null;
141
+ const from = cmeCredentialsPath(agentsDataDir, sourceWorkspace);
142
+ const to = cmeCredentialsPath(agentsDataDir, targetWorkspace);
143
+ if (!existsSync(from)) return null;
144
+ // An existing target file is a real configuration; never clobber it.
145
+ if (existsSync(to)) return null;
146
+ await mkdir(join(agentsDataDir, 'cme', targetWorkspace, 'cme'), { recursive: true });
147
+ await copyFile(from, to);
148
+ return to;
149
+ }
@@ -0,0 +1,181 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import {
8
+ PLACEHOLDER_VALUE_RE,
9
+ buildInheritedWikircPatch,
10
+ cmeCredentialsPath,
11
+ copyCmeCredentials,
12
+ isRealValue,
13
+ } from './workspaceInherit.js';
14
+
15
+ const SCAFFOLD_LLM = {
16
+ provider: 'openai-compatible',
17
+ engine: 'generic',
18
+ baseUrl: 'https://mon-provider.example.com/v1',
19
+ model: 'YOUR_MODEL_NAME',
20
+ apiKey: 'YOUR_LLM_API_KEY',
21
+ };
22
+
23
+ const WORKING_LLM = {
24
+ provider: 'ai-gateway',
25
+ engine: 'generic',
26
+ baseUrl: 'https://itsdonna.events/v1',
27
+ model: 'deepseek-v4-pro',
28
+ apiKey: 'sk-real-gateway-key',
29
+ temperature: 0.1,
30
+ };
31
+
32
+ test('scaffold placeholders never count as configured values', () => {
33
+ for (const value of Object.values(SCAFFOLD_LLM).filter((v) => typeof v === 'string')) {
34
+ if (value === 'openai-compatible' || value === 'generic') continue;
35
+ assert.equal(isRealValue(value), false, `${value} must be treated as a placeholder`);
36
+ }
37
+ assert.equal(isRealValue('https://itsdonna.events/v1'), true);
38
+ assert.equal(isRealValue(''), false);
39
+ assert.equal(isRealValue(null), false);
40
+ // Booleans and numbers are legitimate answers; `false` and `0` must survive.
41
+ assert.equal(isRealValue(false), true);
42
+ assert.equal(isRealValue(0), true);
43
+ });
44
+
45
+ test('the placeholder rule stays identical to the wizard', () => {
46
+ // The wizard applies the same "a scaffolded value is not an answer" rule. If
47
+ // the two drift, /new starts inheriting values the wizard would refuse.
48
+ const wizard = readFileSync(
49
+ fileURLToPath(new URL('../shell/SetupWizard.tsx', import.meta.url)),
50
+ 'utf8',
51
+ );
52
+ const match = wizard.match(/^const PLACEHOLDER_VALUE_RE = (\/.+\/i);$/m);
53
+ assert.ok(match, 'SetupWizard.tsx must keep PLACEHOLDER_VALUE_RE on one line');
54
+ assert.equal(match[1], String(PLACEHOLDER_VALUE_RE));
55
+ });
56
+
57
+ test('a new workspace inherits the working LLM block over scaffold placeholders', () => {
58
+ const { patch, inherited } = buildInheritedWikircPatch(
59
+ { llm: WORKING_LLM },
60
+ { llm: SCAFFOLD_LLM },
61
+ );
62
+
63
+ assert.equal(patch.llm.baseUrl, 'https://itsdonna.events/v1');
64
+ assert.equal(patch.llm.model, 'deepseek-v4-pro');
65
+ assert.equal(patch.llm.apiKey, 'sk-real-gateway-key');
66
+ assert.equal(patch.llm.provider, 'ai-gateway');
67
+ assert.equal(patch.llm.temperature, 0.1);
68
+ assert.ok(inherited.includes('llm.baseUrl'));
69
+ });
70
+
71
+ test('provider and engine follow the endpoint they describe', () => {
72
+ // Keeping the scaffold's `openai-compatible` while adopting a gateway URL
73
+ // would describe the wrong kind of server — the conflation the
74
+ // provider/engine split exists to prevent.
75
+ const taken = buildInheritedWikircPatch({ llm: WORKING_LLM }, { llm: SCAFFOLD_LLM });
76
+ assert.equal(taken.patch.llm.provider, 'ai-gateway');
77
+ assert.ok(taken.inherited.includes('llm.provider'));
78
+
79
+ // Endpoint not taken over: provider and engine stay where they are, whatever
80
+ // the source says.
81
+ const kept = buildInheritedWikircPatch(
82
+ { llm: WORKING_LLM },
83
+ { llm: { ...SCAFFOLD_LLM, baseUrl: 'http://localhost:11434/v1' } },
84
+ );
85
+ assert.equal(kept.patch.llm?.provider, undefined);
86
+ assert.equal(kept.patch.llm?.engine, undefined);
87
+ });
88
+
89
+ test('a value already set on the target is never overwritten', () => {
90
+ const { patch } = buildInheritedWikircPatch(
91
+ { llm: WORKING_LLM },
92
+ { llm: { ...SCAFFOLD_LLM, model: 'mistral-large', baseUrl: 'http://localhost:11434/v1' } },
93
+ );
94
+
95
+ assert.equal(patch.llm.model, undefined, 'a deliberate model must survive');
96
+ assert.equal(patch.llm.baseUrl, undefined, 'a deliberate endpoint must survive');
97
+ assert.equal(patch.llm.apiKey, 'sk-real-gateway-key', 'the still-placeholder key is filled');
98
+ });
99
+
100
+ test('mcp.accessKey is never part of the inherited patch', () => {
101
+ const { patch, inherited } = buildInheritedWikircPatch(
102
+ { llm: WORKING_LLM, mcp: { accessKey: 'source-secret' } },
103
+ { llm: SCAFFOLD_LLM, mcp: { accessKey: 'target-secret' } },
104
+ );
105
+
106
+ assert.equal(patch.mcp, undefined);
107
+ assert.ok(!inherited.some((key) => key.startsWith('mcp')));
108
+ });
109
+
110
+ test('a divergent vector endpoint is not inherited without its own key', () => {
111
+ // Carrying baseUrl alone would send the gateway key — which unlocks every
112
+ // provider behind it — to a different host.
113
+ const { patch, inherited } = buildInheritedWikircPatch(
114
+ {
115
+ llm: WORKING_LLM,
116
+ retrieval: { vector: { enabled: true, baseUrl: 'http://infinity.internal:7997/v1' } },
117
+ },
118
+ { llm: SCAFFOLD_LLM, retrieval: { vector: {} } },
119
+ );
120
+
121
+ assert.equal(patch.retrieval?.vector?.baseUrl, undefined);
122
+ assert.ok(!inherited.includes('retrieval.vector.baseUrl'));
123
+ assert.equal(patch.retrieval.vector.enabled, true, 'the rest of the block still travels');
124
+ });
125
+
126
+ test('a divergent vector endpoint IS inherited when it carries its own key', () => {
127
+ const { patch } = buildInheritedWikircPatch(
128
+ {
129
+ llm: WORKING_LLM,
130
+ retrieval: {
131
+ vector: { enabled: true, baseUrl: 'http://infinity.internal:7997/v1', apiKey: 'vec-key' },
132
+ },
133
+ },
134
+ { llm: SCAFFOLD_LLM, retrieval: { vector: {} } },
135
+ );
136
+
137
+ assert.equal(patch.retrieval.vector.baseUrl, 'http://infinity.internal:7997/v1');
138
+ assert.equal(patch.retrieval.vector.apiKey, 'vec-key');
139
+ });
140
+
141
+ test('nothing to inherit yields an empty patch', () => {
142
+ const { patch, inherited } = buildInheritedWikircPatch({ llm: SCAFFOLD_LLM }, { llm: WORKING_LLM });
143
+ assert.deepEqual(patch, {});
144
+ assert.deepEqual(inherited, []);
145
+ });
146
+
147
+ test('CME credentials are copied, and the source manifest is not', async () => {
148
+ const root = mkdtempSync(join(tmpdir(), 'cme-inherit-'));
149
+ const sourceDir = join(root, 'cme', 'acpi', 'cme');
150
+ mkdirSync(sourceDir, { recursive: true });
151
+ writeFileSync(join(sourceDir, 'app_data.json'), '{"auth":{"pat":"secret"}}', 'utf8');
152
+ // Export scope is what makes a workspace different — it must NOT travel.
153
+ writeFileSync(join(root, 'cme', 'acpi', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
154
+
155
+ const copied = await copyCmeCredentials(root, 'acpi', 'nouveau');
156
+
157
+ assert.equal(copied, cmeCredentialsPath(root, 'nouveau'));
158
+ assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
159
+ assert.equal(existsSync(join(root, 'cme', 'nouveau', 'sources-manifest.yaml')), false);
160
+ });
161
+
162
+ test('existing CME credentials on the target are never clobbered', async () => {
163
+ const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
164
+ mkdirSync(join(root, 'cme', 'acpi', 'cme'), { recursive: true });
165
+ mkdirSync(join(root, 'cme', 'nouveau', 'cme'), { recursive: true });
166
+ writeFileSync(join(root, 'cme', 'acpi', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
+ writeFileSync(join(root, 'cme', 'nouveau', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
168
+
169
+ assert.equal(await copyCmeCredentials(root, 'acpi', 'nouveau'), null);
170
+ assert.equal(
171
+ readFileSync(cmeCredentialsPath(root, 'nouveau'), 'utf8'),
172
+ '{"from":"target"}',
173
+ );
174
+ });
175
+
176
+ test('copying is a no-op without a source, a target, or a source file', async () => {
177
+ const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
178
+ assert.equal(await copyCmeCredentials(root, 'absent', 'nouveau'), null);
179
+ assert.equal(await copyCmeCredentials(root, null, 'nouveau'), null);
180
+ assert.equal(await copyCmeCredentials(root, 'acpi', 'acpi'), null);
181
+ });
@@ -76,6 +76,34 @@ test('interactive turns publish a fallback assistant message exactly once', () =
76
76
  assert.equal(published[0].payload.content, 'Réponse concise.');
77
77
  });
78
78
 
79
+ test('a turn that produces nothing still ends with an assistant message', () => {
80
+ // C'est la condition de fin que serve attend : la bulle « Request received ·
81
+ // Donna is preparing… » n'est retirée qu'à l'arrivée d'un message assistant
82
+ // non vide. Sans message, le point d'attente tournait indéfiniment, sans
83
+ // erreur nulle part et sans autre issue qu'un rechargement de page.
84
+ const published = [];
85
+ const session = { agentEvents: [], _onAgentEvent: (event) => published.push(event) };
86
+
87
+ assert.equal(ensureInteractiveAssistantMessage(session, '', { turnId: 'turn-1', workspace: 'demo' }), true);
88
+
89
+ assert.equal(published.length, 1);
90
+ assert.equal(published[0].type, 'assistant_message');
91
+ assert.match(published[0].payload.content, /No answer was produced/);
92
+ // Le message doit dire quoi faire, pas seulement constater.
93
+ assert.match(published[0].payload.content, /\/agent/);
94
+ });
95
+
96
+ test('a turn whose loop already answered does not answer twice', () => {
97
+ const published = [];
98
+ const session = {
99
+ agentEvents: [{ type: 'assistant_message' }],
100
+ _onAgentEvent: (event) => published.push(event),
101
+ };
102
+
103
+ assert.equal(ensureInteractiveAssistantMessage(session, '', { turnId: 'turn-1' }), false);
104
+ assert.equal(published.length, 0);
105
+ });
106
+
79
107
  test('runtime state rebuilds interactive conversation from persisted events', () => {
80
108
  const user = {
81
109
  ...createAgentEvent('user_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Bonjour' } }),
package/src/shell/repl.js CHANGED
@@ -345,7 +345,7 @@ function completionValuesFor(parts, inputBuffer, session) {
345
345
  // Vocabulaire de l'opérateur uniquement : `serviceChoices()` écarte les noms
346
346
  // Compose bruts que les alias désignent déjà. Ils restent tapables.
347
347
  if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
348
- if (command === '/stop' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
348
+ if (command === '/stop' && tokenIndex === 1) return ['all', 'everything', 'agents', 'services', ...serviceChoices(), ...togglableAgents()];
349
349
  if (command === '/logs' && tokenIndex === 1) return ['all', ...serviceChoices()];
350
350
  return [];
351
351
  }
@@ -583,7 +583,8 @@ export function completionDescription(value, parts) {
583
583
  return serviceDescription(value) ?? 'Start this Docker Compose service.';
584
584
  }
585
585
  if (command === '/stop') {
586
- if (value === 'all') return 'Everything: workspace services AND external agents.';
586
+ if (value === 'all') return 'This workspace, agents included — unless another workspace still needs them.';
587
+ if (value === 'everything') return 'This workspace AND the shared agents, even if other workspaces are using them.';
587
588
  if (value === 'agents' || value === 'agent') return 'External agents only.';
588
589
  if (value === 'services') return 'Workspace services only.';
589
590
  if (togglableAgents().includes(value)) return `Stop only the ${value} agent, leaving the other agents running.`;
@@ -1471,7 +1472,7 @@ async function runAgentTurn(input, {
1471
1472
  // unitary actions only — it never plans or delegates, which is why the
1472
1473
  // orchestration tools are filtered out upstream. maxToolIterations caps the
1473
1474
  // loop.
1474
- async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools, openWikiPages, contextMessages = [] }) {
1475
+ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, onTextDelta, onTextReset, allowedTools, openWikiPages, contextMessages = [] }) {
1475
1476
  const allowed = new Set(allowedTools.map((item) => item.function.name));
1476
1477
  // /chat only supplies the policy; the loop mechanic lives in runBoundedToolLoop.
1477
1478
  // executeCall enforces the allow-list and turns each call into a text result;
@@ -1503,6 +1504,8 @@ async function runChatToolLoop({ input, session, history, donnaMessage, onUpdate
1503
1504
  maxIterations: Math.min(8, Number(session?.chatAccess?.maxToolIterations) || 4),
1504
1505
  signal: session._abortSignal,
1505
1506
  onStep: (i, cap) => onStep?.(`Chat: consulting… [${i}/${cap}]`),
1507
+ onTextDelta,
1508
+ onTextReset,
1506
1509
  });
1507
1510
  donnaMessage.content = capped
1508
1511
  ? 'Je n’ai pas pu conclure dans la limite d’itérations du mode chat. Passe en /agent si besoin.'
@@ -1574,7 +1577,15 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1574
1577
  // implementation of chat access; it just returns the final text instead of
1575
1578
  // driving a live repl bubble. The caller must have seeded session.chatAccess
1576
1579
  // (and session.mcp) so chatAllowedTools can resolve the allow-listed tools.
1577
- export async function runHeadlessChatTurn(session, input, { history = [], onStep, openWikiPages, openWikiPage } = {}) {
1580
+ /**
1581
+ * @param onTextDelta fragments de la réponse au fil de la génération. Fourni
1582
+ * par le tour runtime, qui les publie en `assistant_delta` : le réducteur
1583
+ * fait grandir la dernière entrée de conversation, et les deux interfaces
1584
+ * affichent la réponse en train de s'écrire au lieu d'un point d'attente.
1585
+ * @param onTextReset fragments à jeter (itération qui s'est terminée par des
1586
+ * appels d'outils plutôt que par une réponse).
1587
+ */
1588
+ export async function runHeadlessChatTurn(session, input, { history = [], onStep, onTextDelta, onTextReset, openWikiPages, openWikiPage } = {}) {
1578
1589
  const donnaMessage = { role: 'donna', content: '' };
1579
1590
  const allowedTools = chatAllowedTools(session);
1580
1591
  // Keep the singular option as a compatibility input for older serve builds.
@@ -1590,7 +1601,7 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1590
1601
  ];
1591
1602
  const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1592
1603
  if (canUseTools) {
1593
- await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
1604
+ await runChatToolLoop({ input, session, history, donnaMessage, onStep, onTextDelta, onTextReset, allowedTools, openWikiPages: selectedPages, contextMessages });
1594
1605
  return donnaMessage.content;
1595
1606
  }
1596
1607
  if (typeof session.llm?.stream === 'function') {
@@ -1601,7 +1612,7 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1601
1612
  signal: session._abortSignal,
1602
1613
  })) {
1603
1614
  const clean = stripDsmlArtifacts(delta);
1604
- if (clean) content += clean;
1615
+ if (clean) { content += clean; onTextDelta?.(clean); }
1605
1616
  }
1606
1617
  return stripDsmlArtifacts(content).trimEnd() || formatLlmUnavailableMessage('flux vide');
1607
1618
  }
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // The shell renders in a terminal, so there is no DOM to drive and no
7
+ // typechecker on these .tsx files (bun strips the types at run time). Source
8
+ // assertions are the only mechanized guard available here — they are narrow on
9
+ // purpose: each one names the exact invariant that broke.
10
+ const tuiPath = fileURLToPath(new URL('./tui.tsx', import.meta.url));
11
+
12
+ function tuiSource() {
13
+ return readFileSync(tuiPath, 'utf8');
14
+ }
15
+
16
+ test('the chat composer loses focus while a modal is open', () => {
17
+ const source = tuiSource();
18
+
19
+ // The setup wizard used to leave `chatFocused` true underneath itself, so
20
+ // every keystroke answering a wizard question was ALSO typed into the chat
21
+ // input at the bottom of the window — visible, and submitted on Enter.
22
+ assert.match(
23
+ source,
24
+ /chatFocused=\{!state\.activeEditor\(\) && screen\(\) === 'main'\}/,
25
+ 'chatFocused must exclude every non-main screen, not just the file editor',
26
+ );
27
+ assert.doesNotMatch(
28
+ source,
29
+ /chatFocused=\{!state\.activeEditor\(\)\}/,
30
+ 'the editor-only form of chatFocused lets wizard keystrokes reach the composer',
31
+ );
32
+ });
33
+
34
+ test('the setup wizard renders over an opaque backdrop', () => {
35
+ const source = tuiSource();
36
+ const setupBranch = source.slice(source.indexOf("{screen() === 'setup' ?"));
37
+
38
+ assert.ok(setupBranch, "the setup branch must exist in tui.tsx");
39
+ // Without a full-bleed backdrop the wizard floats over a live two-pane
40
+ // layout and the panes show through around its border.
41
+ assert.match(setupBranch, /position="absolute"[\s\S]{0,200}backgroundColor="#0B0D12"/);
42
+ assert.match(setupBranch, /width=\{dimensions\(\)\.width\}[\s\S]{0,120}height=\{dimensions\(\)\.height\}/);
43
+ // The backdrop must sit under the wizard dialog (zIndex 40 in SetupWizard).
44
+ const zIndex = setupBranch.match(/zIndex=\{(\d+)\}/);
45
+ assert.ok(zIndex, 'the backdrop must declare a zIndex');
46
+ assert.ok(Number(zIndex[1]) < 40, 'the backdrop must sit below the wizard dialog');
47
+ });
48
+
49
+ test('the global key handler ignores every screen but main', () => {
50
+ const source = tuiSource();
51
+
52
+ // Belt to the chatFocused braces: even if a future modal forgets the prop,
53
+ // the shared shortcuts (history, slash completion, Esc) stay out of it.
54
+ assert.match(source, /if \(screen\(\) !== 'main'\) return;/);
55
+ });