@dotdrelle/wiki-manager 0.15.35 → 0.15.38
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 +42 -0
- package/package.json +1 -1
- package/src/cli/wiki-manager.js +20 -0
- package/src/commands/slash.js +12 -3
- package/src/commands/slash.test.js +48 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +2 -1
- package/src/core/env.test.js +14 -0
- package/src/core/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -0
- package/src/core/mcpEndpoints.js +96 -0
- package/src/core/mcpEndpoints.test.js +126 -0
- package/src/core/wikiWorkspace.test.js +7 -0
- package/src/orchestrator/agentRegistry.js +13 -1
- package/src/orchestrator/agentRegistry.test.js +20 -0
- package/src/runtime/server.js +28 -0
- package/src/shell/LeftPane.tsx +55 -41
- package/src/shell/repl.js +24 -1
- package/src/shell/repl.test.js +48 -19
- package/wiki-workspace +7 -0
package/README.md
CHANGED
|
@@ -556,6 +556,48 @@ including under `"*"`. Multi-step work belongs to `/agent`.
|
|
|
556
556
|
An `allowActions` key written by an older manager is folded into `allow` on
|
|
557
557
|
read and removed on the next `agents up`.
|
|
558
558
|
|
|
559
|
+
### Adding a connector from the served chat UI
|
|
560
|
+
|
|
561
|
+
`mcp.endpoints.json` stays hand-editable, but the Connectors panel of
|
|
562
|
+
`llm-wiki serve` can now write it. Connecting a card there upserts the endpoint
|
|
563
|
+
through the runtime (`POST /mcp/endpoints`), and the runtime immediately
|
|
564
|
+
re-reads the file and rediscovers tools and agents — no restart, and the new
|
|
565
|
+
tools are usable in the same breath by `/chat`, `/agent` and any subsequent
|
|
566
|
+
plan.
|
|
567
|
+
|
|
568
|
+
Because a server absent from `chatAccess` gets **zero** tools in `/chat`, the
|
|
569
|
+
upsert writes `"allow": "*"` for it. That is the deliberate difference between
|
|
570
|
+
a connector declared by hand — where you choose the tool list — and one added
|
|
571
|
+
from the UI, where the person adding it is the person who will use it. Narrow
|
|
572
|
+
it afterwards by editing the file.
|
|
573
|
+
|
|
574
|
+
Three origins are distinguished, and the UI labels each card:
|
|
575
|
+
|
|
576
|
+
| origin | shown as | who owns it |
|
|
577
|
+
| --- | --- | --- |
|
|
578
|
+
| `wiki`, `production`, `llm-wiki`, `wiki-production` | `internal` | the workspace stack. Fields read-only, no delete — the runtime rejects any change to these names |
|
|
579
|
+
| declared in `mcp.endpoints.json` by hand or by `agents up` | `global config` | the operator. CME, Documents, Mailer, Connectors, Exa… |
|
|
580
|
+
| added from the UI | `added here` | carries `"managedBy": "serve-ui"` in the file |
|
|
581
|
+
|
|
582
|
+
Removing a `global config` connector is a workspace-wide act — it leaves every
|
|
583
|
+
chat, agent and future plan — so the UI says so before confirming. The
|
|
584
|
+
container and its data are untouched; only the wiring is removed. The name is
|
|
585
|
+
also pushed into `disabledMcpServers`, which the scaffold honours, so a
|
|
586
|
+
connector you removed on purpose is not silently restored by the next
|
|
587
|
+
`agents up` merging the packaged example back in.
|
|
588
|
+
|
|
589
|
+
Renaming is atomic: the UI sends `previousName`, and the endpoint, its
|
|
590
|
+
`Authorization` header and its `chatAccess` entry move together under the new
|
|
591
|
+
key. A rename onto an existing name, or from a name that is not there, is
|
|
592
|
+
rejected rather than half-applied.
|
|
593
|
+
|
|
594
|
+
`POST /mcp/endpoints` returns **409 while a plan is running** — connector
|
|
595
|
+
wiring must not change under a run that already resolved its agents. The chat
|
|
596
|
+
UI treats that as what it is: the MCP handshake succeeded, so the card stays
|
|
597
|
+
connected and usable in this browser, badged `local only` with
|
|
598
|
+
"runtime synchronization pending", and the write is retried on the next
|
|
599
|
+
reconnect. A busy runtime never presents itself as a broken connector.
|
|
600
|
+
|
|
559
601
|
MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
|
|
560
602
|
They also share a per-endpoint outbound control budget (45 RPM by default,
|
|
561
603
|
configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
|
package/package.json
CHANGED
package/src/cli/wiki-manager.js
CHANGED
|
@@ -14,6 +14,7 @@ import { refreshRunningContainers } from '../core/wikiSetup.js';
|
|
|
14
14
|
import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
15
15
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
16
16
|
import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
|
|
17
|
+
import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from '../core/mcpEndpoints.js';
|
|
17
18
|
import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
18
19
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
19
20
|
import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
@@ -740,6 +741,14 @@ async function runRuntime(argv, agent) {
|
|
|
740
741
|
let serverHandle = null;
|
|
741
742
|
const contexts = new Map();
|
|
742
743
|
|
|
744
|
+
async function refreshAllMcpContexts() {
|
|
745
|
+
const resolved = await Promise.all([...new Set(contexts.values())].map((value) => Promise.resolve(value)));
|
|
746
|
+
await Promise.all(resolved.map(async (context) => {
|
|
747
|
+
await refreshMcpRuntimeStatus(context.session);
|
|
748
|
+
await discoverAgentsOnce(context.session);
|
|
749
|
+
}));
|
|
750
|
+
}
|
|
751
|
+
|
|
743
752
|
async function getWorkspaceContext(workspaceName = null) {
|
|
744
753
|
const requestedWorkspace = workspaceName ? String(workspaceName).trim() : null;
|
|
745
754
|
const key = requestedWorkspace ?? '__default__';
|
|
@@ -1416,6 +1425,17 @@ async function runRuntime(argv, agent) {
|
|
|
1416
1425
|
config,
|
|
1417
1426
|
};
|
|
1418
1427
|
},
|
|
1428
|
+
listMcpEndpoints: async () => ({ endpoints: listManagedMcpEndpoints() }),
|
|
1429
|
+
upsertMcpEndpoint: async (_context, body) => {
|
|
1430
|
+
const endpoint = upsertManagedMcpEndpoint(body);
|
|
1431
|
+
await refreshAllMcpContexts();
|
|
1432
|
+
return { ok: true, endpoint };
|
|
1433
|
+
},
|
|
1434
|
+
deleteMcpEndpoint: async (_context, body) => {
|
|
1435
|
+
const endpoint = deleteManagedMcpEndpoint(body.name);
|
|
1436
|
+
await refreshAllMcpContexts();
|
|
1437
|
+
return { ok: true, endpoint };
|
|
1438
|
+
},
|
|
1419
1439
|
token: auth.token,
|
|
1420
1440
|
});
|
|
1421
1441
|
const recovery = await recoverRuntime();
|
package/src/commands/slash.js
CHANGED
|
@@ -338,7 +338,7 @@ function workspaceStatsColumns(stats, session) {
|
|
|
338
338
|
};
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
function workspaceLoadedText(workspace, summary, session) {
|
|
341
|
+
function workspaceLoadedText(workspace, summary, session, mcpError = null) {
|
|
342
342
|
const profiles = listWikircProfiles(workspace.workspacePath);
|
|
343
343
|
const profileLines = profiles.length > 0
|
|
344
344
|
? profiles.map((profile) => {
|
|
@@ -374,6 +374,7 @@ function workspaceLoadedText(workspace, summary, session) {
|
|
|
374
374
|
'',
|
|
375
375
|
`llm: ${session.llm ? 'configured' : 'missing config'}`,
|
|
376
376
|
`mcp: ${Object.values(session.mcp ?? {}).filter((value) => value.status === 'connected').length} connected`,
|
|
377
|
+
...(mcpError ? ['', `MCP discovery failed: ${mcpError}`] : []),
|
|
377
378
|
].join('\n');
|
|
378
379
|
}
|
|
379
380
|
|
|
@@ -941,9 +942,17 @@ export async function handleSlashCommand(line, context) {
|
|
|
941
942
|
context.session.workspaceEnv = workspace.env;
|
|
942
943
|
context.session.workspaceEnvFile = workspace.envFile;
|
|
943
944
|
context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
|
|
945
|
+
let summary;
|
|
944
946
|
try {
|
|
945
947
|
step(`Workspace: loading ${workspace.name} config…`);
|
|
946
|
-
|
|
948
|
+
({ summary } = applySessionWikircProfile(context.session, 'default'));
|
|
949
|
+
} catch (err) {
|
|
950
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
951
|
+
return {
|
|
952
|
+
output: workspaceLoadedWithoutConfigText(workspace, message),
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
947
956
|
step(`Workspace: discovering ${workspace.name} MCP tools…`);
|
|
948
957
|
await refreshMcpRuntimeStatus(context.session);
|
|
949
958
|
return {
|
|
@@ -952,7 +961,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
952
961
|
} catch (err) {
|
|
953
962
|
const message = err instanceof Error ? err.message : String(err);
|
|
954
963
|
return {
|
|
955
|
-
output:
|
|
964
|
+
output: workspaceLoadedText(workspace, summary, context.session, message),
|
|
956
965
|
};
|
|
957
966
|
}
|
|
958
967
|
}
|
|
@@ -345,6 +345,54 @@ test('/use loads only workspaces and /config use switches wikirc profiles', asyn
|
|
|
345
345
|
}
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
test('/use keeps a loaded wikirc when MCP discovery fails', async () => {
|
|
349
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-mcp-error-'));
|
|
350
|
+
const registryRoot = join(root, 'registry');
|
|
351
|
+
const workspacePath = join(root, 'workspace');
|
|
352
|
+
const registryPath = join(registryRoot, 'demo');
|
|
353
|
+
mkdirSync(registryPath, { recursive: true });
|
|
354
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
355
|
+
mkdirSync(join(root, 'mcp.endpoints.json'));
|
|
356
|
+
writeFileSync(join(root, '.env'), '', 'utf8');
|
|
357
|
+
writeFileSync(join(registryPath, '.env'), [
|
|
358
|
+
'WORKSPACE_NAME=demo',
|
|
359
|
+
`WIKI_WORKSPACE_PATH=${workspacePath}`,
|
|
360
|
+
'',
|
|
361
|
+
].join('\n'), 'utf8');
|
|
362
|
+
writeFileSync(join(workspacePath, '.wikirc.yaml'), [
|
|
363
|
+
'language: fr',
|
|
364
|
+
'llm:',
|
|
365
|
+
' provider: openai-compatible',
|
|
366
|
+
' engine: openai',
|
|
367
|
+
' model: test-model',
|
|
368
|
+
' apiKey: test-key',
|
|
369
|
+
'',
|
|
370
|
+
].join('\n'), 'utf8');
|
|
371
|
+
|
|
372
|
+
const previousDir = process.env.WIKI_WORKSPACES_DIR;
|
|
373
|
+
const previousEnvFile = process.env.WIKI_MANAGER_ENV_FILE;
|
|
374
|
+
process.env.WIKI_WORKSPACES_DIR = registryRoot;
|
|
375
|
+
process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
|
|
376
|
+
try {
|
|
377
|
+
const session = {};
|
|
378
|
+
const result = await handleSlashCommand('/use demo', {
|
|
379
|
+
packageJson: { version: 'test' },
|
|
380
|
+
session,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
assert.equal(session.wikirc?.profile, 'default');
|
|
384
|
+
assert.equal(session.wikircConfig?.llm?.model, 'test-model');
|
|
385
|
+
assert.match(result.output ?? '', /profile: default/);
|
|
386
|
+
assert.match(result.output ?? '', /MCP discovery failed:.*EISDIR/s);
|
|
387
|
+
assert.doesNotMatch(result.output ?? '', /Wikirc not loaded/);
|
|
388
|
+
} finally {
|
|
389
|
+
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
390
|
+
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
391
|
+
if (previousEnvFile === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
|
|
392
|
+
else process.env.WIKI_MANAGER_ENV_FILE = previousEnvFile;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
348
396
|
test('/queue cancel refuses runtime-managed items instead of fake-cancelling locally', async () => {
|
|
349
397
|
// syncRuntimeState replaces session.jobQueue with the runtime queue and tags
|
|
350
398
|
// origin:'runtime' — a local cancel would be reverted by the next SSE sync,
|
package/src/core/buildInfo.json
CHANGED
package/src/core/env.js
CHANGED
|
@@ -121,9 +121,10 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
|
|
|
121
121
|
const missing = Object.keys(example).filter((key) => !(key in current));
|
|
122
122
|
const currentServers = current.mcpServers;
|
|
123
123
|
const exampleServers = example.mcpServers;
|
|
124
|
+
const disabledServers = new Set(Array.isArray(current.disabledMcpServers) ? current.disabledMcpServers.map(String) : []);
|
|
124
125
|
const missingServers = currentServers && typeof currentServers === 'object' && !Array.isArray(currentServers)
|
|
125
126
|
&& exampleServers && typeof exampleServers === 'object' && !Array.isArray(exampleServers)
|
|
126
|
-
? Object.keys(exampleServers).filter((key) => !(key in currentServers))
|
|
127
|
+
? Object.keys(exampleServers).filter((key) => !(key in currentServers) && !disabledServers.has(key))
|
|
127
128
|
: [];
|
|
128
129
|
if (missing.length > 0) {
|
|
129
130
|
for (const key of missing) current[key] = example[key];
|
package/src/core/env.test.js
CHANGED
|
@@ -102,6 +102,20 @@ test('scaffold never overwrites an existing chatAccess, including explicit null'
|
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test('scaffold does not restore a packaged MCP explicitly removed in the UI', () => {
|
|
106
|
+
withTempManagerDir((dir) => {
|
|
107
|
+
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
|
108
|
+
writeFileSync(endpointsFile, JSON.stringify({
|
|
109
|
+
mcpServers: {},
|
|
110
|
+
disabledMcpServers: ['cme'],
|
|
111
|
+
}, null, 2));
|
|
112
|
+
ensureManagerScaffold();
|
|
113
|
+
const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
|
|
114
|
+
assert.equal(after.mcpServers.cme, undefined);
|
|
115
|
+
assert.ok(after.mcpServers.documents);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
105
119
|
test('scaffold leaves an invalid endpoints file strictly alone', () => {
|
|
106
120
|
withTempManagerDir((dir) => {
|
|
107
121
|
const endpointsFile = join(dir, 'mcp.endpoints.json');
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.38';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/mcp.test.js
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { managerMcpEndpointsFile } from './env.js';
|
|
3
|
+
|
|
4
|
+
const PROTECTED_SERVERS = new Set(['wiki', 'production', 'llm-wiki', 'wiki-production']);
|
|
5
|
+
|
|
6
|
+
function readDocument() {
|
|
7
|
+
const filePath = managerMcpEndpointsFile();
|
|
8
|
+
const raw = existsSync(filePath) ? JSON.parse(readFileSync(filePath, 'utf8')) : {};
|
|
9
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
10
|
+
throw new Error('mcp.endpoints.json must contain a JSON object.');
|
|
11
|
+
}
|
|
12
|
+
raw.mcpServers ??= {};
|
|
13
|
+
raw.disabledMcpServers = Array.isArray(raw.disabledMcpServers)
|
|
14
|
+
? raw.disabledMcpServers.map(String).filter(Boolean)
|
|
15
|
+
: [];
|
|
16
|
+
raw.chatAccess ??= { maxToolIterations: 8, servers: {} };
|
|
17
|
+
raw.chatAccess.servers ??= {};
|
|
18
|
+
return { filePath, raw };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeDocument(filePath, raw) {
|
|
22
|
+
const temporary = `${filePath}.tmp.${process.pid}`;
|
|
23
|
+
writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
|
|
24
|
+
chmodSync(temporary, 0o600);
|
|
25
|
+
renameSync(temporary, filePath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeName(value) {
|
|
29
|
+
const name = String(value ?? '').trim();
|
|
30
|
+
if (!name || name.length > 80 || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
|
|
31
|
+
throw new Error('MCP server name must use letters, numbers, dots, underscores or hyphens.');
|
|
32
|
+
}
|
|
33
|
+
if (PROTECTED_SERVERS.has(name)) throw new Error(`Built-in MCP server cannot be changed: ${name}`);
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeUrl(value) {
|
|
38
|
+
const url = String(value ?? '').trim();
|
|
39
|
+
let parsed;
|
|
40
|
+
try { parsed = new URL(url); } catch { throw new Error('MCP server URL is invalid.'); }
|
|
41
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('MCP server URL must use HTTP or HTTPS.');
|
|
42
|
+
return url;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listManagedMcpEndpoints() {
|
|
46
|
+
const { raw } = readDocument();
|
|
47
|
+
return Object.entries(raw.mcpServers).map(([name, endpoint]) => ({
|
|
48
|
+
name,
|
|
49
|
+
url: String(endpoint?.url ?? ''),
|
|
50
|
+
bearer: String(endpoint?.headers?.Authorization ?? endpoint?.headers?.authorization ?? '').replace(/^Bearer\s+/i, ''),
|
|
51
|
+
allow: raw.chatAccess?.servers?.[name]?.allow ?? null,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function upsertManagedMcpEndpoint({ name: rawName, previousName: rawPreviousName = null, url: rawUrl, bearer = '' } = {}) {
|
|
56
|
+
const name = normalizeName(rawName);
|
|
57
|
+
const previousName = rawPreviousName ? normalizeName(rawPreviousName) : name;
|
|
58
|
+
const url = normalizeUrl(rawUrl);
|
|
59
|
+
const { filePath, raw } = readDocument();
|
|
60
|
+
if (previousName !== name && !Object.hasOwn(raw.mcpServers, previousName)) {
|
|
61
|
+
throw new Error(`MCP server to rename was not found: ${previousName}`);
|
|
62
|
+
}
|
|
63
|
+
if (previousName !== name && Object.hasOwn(raw.mcpServers, name)) {
|
|
64
|
+
throw new Error(`MCP server already exists: ${name}`);
|
|
65
|
+
}
|
|
66
|
+
const previous = raw.mcpServers[previousName] && typeof raw.mcpServers[previousName] === 'object'
|
|
67
|
+
? raw.mcpServers[previousName]
|
|
68
|
+
: {};
|
|
69
|
+
const headers = { ...(previous.headers ?? {}) };
|
|
70
|
+
delete headers.authorization;
|
|
71
|
+
delete headers.Authorization;
|
|
72
|
+
if (String(bearer).trim()) headers.Authorization = `Bearer ${String(bearer).trim()}`;
|
|
73
|
+
const managedBy = previous.managedBy === 'serve-ui' ? 'serve-ui' : (Object.keys(previous).length ? null : 'serve-ui');
|
|
74
|
+
raw.mcpServers[name] = { ...previous, url, ...(managedBy ? { managedBy } : {}), ...(Object.keys(headers).length ? { headers } : {}) };
|
|
75
|
+
if (!Object.keys(headers).length) delete raw.mcpServers[name].headers;
|
|
76
|
+
if (previousName !== name) {
|
|
77
|
+
delete raw.mcpServers[previousName];
|
|
78
|
+
delete raw.chatAccess.servers[previousName];
|
|
79
|
+
if (!raw.disabledMcpServers.includes(previousName)) raw.disabledMcpServers.push(previousName);
|
|
80
|
+
}
|
|
81
|
+
raw.chatAccess.servers[name] = { allow: '*' };
|
|
82
|
+
raw.disabledMcpServers = raw.disabledMcpServers.filter((item) => item !== name);
|
|
83
|
+
writeDocument(filePath, raw);
|
|
84
|
+
return { name, previousName, url, allow: '*', hasBearer: Boolean(String(bearer).trim()), origin: managedBy ? 'ui' : 'global' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function deleteManagedMcpEndpoint(rawName) {
|
|
88
|
+
const name = normalizeName(rawName);
|
|
89
|
+
const { filePath, raw } = readDocument();
|
|
90
|
+
const existed = Object.hasOwn(raw.mcpServers, name);
|
|
91
|
+
delete raw.mcpServers[name];
|
|
92
|
+
delete raw.chatAccess.servers[name];
|
|
93
|
+
if (!raw.disabledMcpServers.includes(name)) raw.disabledMcpServers.push(name);
|
|
94
|
+
writeDocument(filePath, raw);
|
|
95
|
+
return { name, deleted: existed };
|
|
96
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from './mcpEndpoints.js';
|
|
8
|
+
import { buildMcpStatus, discoverMcpTools, resetMcpSessionsForTests } from './mcp.js';
|
|
9
|
+
import { chatAllowedTools } from '../shell/repl.js';
|
|
10
|
+
import { buildAgentSystemPrompt } from '../agent/graph.js';
|
|
11
|
+
|
|
12
|
+
async function withEndpoints(fn) {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-mcp-endpoints-'));
|
|
14
|
+
const envFile = join(root, '.env');
|
|
15
|
+
writeFileSync(envFile, '', 'utf8');
|
|
16
|
+
writeFileSync(join(root, 'mcp.endpoints.json'), JSON.stringify({
|
|
17
|
+
mcpServers: { cme: { url: 'http://localhost:3336/mcp/' } },
|
|
18
|
+
chatAccess: { maxToolIterations: 8, servers: { cme: { allow: ['cme_status'] } } },
|
|
19
|
+
}), 'utf8');
|
|
20
|
+
const previous = process.env.WIKI_MANAGER_ENV_FILE;
|
|
21
|
+
process.env.WIKI_MANAGER_ENV_FILE = envFile;
|
|
22
|
+
try { await fn(root); } finally {
|
|
23
|
+
if (previous === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
|
|
24
|
+
else process.env.WIKI_MANAGER_ENV_FILE = previous;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('UI-managed MCP endpoints persist with bearer auth and chat wildcard access', async () => {
|
|
29
|
+
await withEndpoints(async (root) => {
|
|
30
|
+
upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'secret' });
|
|
31
|
+
const raw = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
|
|
32
|
+
assert.equal(raw.mcpServers.exa.url, 'https://mcp.exa.ai/mcp');
|
|
33
|
+
assert.equal(raw.mcpServers.exa.headers.Authorization, 'Bearer secret');
|
|
34
|
+
assert.equal(raw.chatAccess.servers.exa.allow, '*');
|
|
35
|
+
assert.deepEqual(listManagedMcpEndpoints().find((item) => item.name === 'exa'), {
|
|
36
|
+
name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'secret', allow: '*',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('deleting an external MCP removes endpoint and chat access without touching others', async () => {
|
|
42
|
+
await withEndpoints(async (root) => {
|
|
43
|
+
upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp' });
|
|
44
|
+
assert.equal(deleteManagedMcpEndpoint('cme').deleted, true);
|
|
45
|
+
const raw = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
|
|
46
|
+
assert.equal(raw.mcpServers.cme, undefined);
|
|
47
|
+
assert.equal(raw.chatAccess.servers.cme, undefined);
|
|
48
|
+
assert.deepEqual(raw.disabledMcpServers, ['cme']);
|
|
49
|
+
assert.equal(raw.mcpServers.exa.url, 'https://mcp.exa.ai/mcp');
|
|
50
|
+
upsertManagedMcpEndpoint({ name: 'cme', url: 'http://localhost:3336/mcp/' });
|
|
51
|
+
const restored = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
|
|
52
|
+
assert.deepEqual(restored.disabledMcpServers, []);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('built-in workspace MCP endpoints cannot be changed from the connectors UI', async () => {
|
|
57
|
+
await withEndpoints(async () => {
|
|
58
|
+
assert.throws(() => deleteManagedMcpEndpoint('llm-wiki'), /cannot be changed/);
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => upsertManagedMcpEndpoint({ name: 'wiki-production', url: 'https://example.test/mcp' }),
|
|
61
|
+
/cannot be changed/,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('renaming a persisted MCP moves endpoint, chat access and deletion identity atomically', async () => {
|
|
67
|
+
await withEndpoints(async (root) => {
|
|
68
|
+
upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'first' });
|
|
69
|
+
const renamed = upsertManagedMcpEndpoint({
|
|
70
|
+
name: 'exa-search', previousName: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'second',
|
|
71
|
+
});
|
|
72
|
+
assert.equal(renamed.previousName, 'exa');
|
|
73
|
+
const raw = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
|
|
74
|
+
assert.equal(raw.mcpServers.exa, undefined);
|
|
75
|
+
assert.equal(raw.chatAccess.servers.exa, undefined);
|
|
76
|
+
assert.equal(raw.mcpServers['exa-search'].headers.Authorization, 'Bearer second');
|
|
77
|
+
assert.equal(raw.chatAccess.servers['exa-search'].allow, '*');
|
|
78
|
+
assert.ok(raw.disabledMcpServers.includes('exa'));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a UI-added MCP is rediscovered for both chat wildcard and direct agent tools', async () => {
|
|
83
|
+
await withEndpoints(async () => {
|
|
84
|
+
upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'secret' });
|
|
85
|
+
const originalFetch = globalThis.fetch;
|
|
86
|
+
const seenAuthorization = [];
|
|
87
|
+
resetMcpSessionsForTests();
|
|
88
|
+
globalThis.fetch = async (_url, init) => {
|
|
89
|
+
const body = JSON.parse(init.body);
|
|
90
|
+
seenAuthorization.push(init.headers.authorization);
|
|
91
|
+
if (body.method === 'initialize') {
|
|
92
|
+
return {
|
|
93
|
+
ok: true, status: 200,
|
|
94
|
+
headers: { get: (name) => name === 'mcp-session-id' ? 'exa-session' : null },
|
|
95
|
+
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18"}}',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (body.method === 'notifications/initialized') {
|
|
99
|
+
return { ok: true, status: 202, headers: { get: () => null }, text: async () => '' };
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ok: true, status: 200, headers: { get: () => null },
|
|
103
|
+
text: async () => JSON.stringify({
|
|
104
|
+
jsonrpc: '2.0', id: 2,
|
|
105
|
+
result: { tools: [
|
|
106
|
+
{ name: 'web_search_exa', inputSchema: { type: 'object', properties: {} } },
|
|
107
|
+
{ name: 'get_code_context_exa', inputSchema: { type: 'object', properties: {} } },
|
|
108
|
+
] },
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
const session = { workspace: 'docs', workspaceEnv: {}, wikircConfig: {}, commands: [] };
|
|
114
|
+
session.mcp = await discoverMcpTools(buildMcpStatus(session));
|
|
115
|
+
const chatTools = chatAllowedTools(session).map((tool) => tool.function.name).sort();
|
|
116
|
+
assert.deepEqual(chatTools, ['exa__get_code_context_exa', 'exa__web_search_exa']);
|
|
117
|
+
const agentPrompt = buildAgentSystemPrompt({ session });
|
|
118
|
+
assert.match(agentPrompt, /exa__web_search_exa/);
|
|
119
|
+
assert.match(agentPrompt, /exa__get_code_context_exa/);
|
|
120
|
+
assert.ok(seenAuthorization.includes('Bearer secret'));
|
|
121
|
+
} finally {
|
|
122
|
+
globalThis.fetch = originalFetch;
|
|
123
|
+
resetMcpSessionsForTests();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -88,6 +88,13 @@ test('workspace creation keeps mutable manager files outside the installed packa
|
|
|
88
88
|
assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test('wiki-workspace rejects an MCP endpoints directory instead of copying into it', async () => {
|
|
92
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
93
|
+
|
|
94
|
+
assert.match(script, /\[\[ -e "\$MANAGER_ENDPOINTS_FILE" && ! -f "\$MANAGER_ENDPOINTS_FILE" \]\]/);
|
|
95
|
+
assert.match(script, /MCP endpoints path is not a file/);
|
|
96
|
+
});
|
|
97
|
+
|
|
91
98
|
test('container refresh pulls and renews only services that are already running', async () => {
|
|
92
99
|
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
93
100
|
|
|
@@ -15,10 +15,22 @@ export function createAgentRegistry({
|
|
|
15
15
|
return {
|
|
16
16
|
async discover(session, { signal = null } = {}) {
|
|
17
17
|
const discovered = [];
|
|
18
|
-
|
|
18
|
+
const endpoints = Object.entries(session?.mcp ?? {});
|
|
19
|
+
const activeServers = new Set(endpoints.map(([serverName]) => serverName));
|
|
20
|
+
for (const [serverName, endpoint] of endpoints) {
|
|
19
21
|
const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
|
|
20
22
|
discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
|
|
21
23
|
}
|
|
24
|
+
for (const [serverName, instanceId] of instanceByServer) {
|
|
25
|
+
if (activeServers.has(serverName)) continue;
|
|
26
|
+
const previous = agentsByInstance.get(instanceId);
|
|
27
|
+
instanceByServer.delete(serverName);
|
|
28
|
+
agentsByInstance.delete(instanceId);
|
|
29
|
+
if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
|
|
30
|
+
agentInstanceId: instanceId,
|
|
31
|
+
serverName,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
22
34
|
session.agentRegistry = this;
|
|
23
35
|
session.agentRegistrySnapshot = this.snapshot();
|
|
24
36
|
return discovered;
|
|
@@ -85,6 +85,26 @@ test('agentRegistry records legacy visible agents when no contract tool exists',
|
|
|
85
85
|
assert.equal(agent.health, 'available');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test('agentRegistry removes a server that disappeared during MCP refresh', async () => {
|
|
89
|
+
const events = [];
|
|
90
|
+
const session = {
|
|
91
|
+
mcp: {
|
|
92
|
+
cme: { status: 'connected', tools: [{ name: 'cme_status' }] },
|
|
93
|
+
exa: { status: 'connected', tools: [{ name: 'web_search_exa' }] },
|
|
94
|
+
},
|
|
95
|
+
_onAgentEvent: (event) => events.push(event),
|
|
96
|
+
};
|
|
97
|
+
const registry = createAgentRegistry({ callTool: async () => assert.fail('no contract tool expected') });
|
|
98
|
+
await registry.discover(session);
|
|
99
|
+
assert.deepEqual(registry.snapshot().map((agent) => agent.serverName), ['cme', 'exa']);
|
|
100
|
+
|
|
101
|
+
delete session.mcp.cme;
|
|
102
|
+
await registry.discover(session);
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(registry.snapshot().map((agent) => agent.serverName), ['exa']);
|
|
105
|
+
assert.ok(events.some((event) => event.type === 'agent.unregistered' && event.payload.serverName === 'cme'));
|
|
106
|
+
});
|
|
107
|
+
|
|
88
108
|
test('agentRegistry marks unavailable boot agents and emits health changes on re-scan', async () => {
|
|
89
109
|
const events = [];
|
|
90
110
|
let health = 'unavailable';
|
package/src/runtime/server.js
CHANGED
|
@@ -24,6 +24,9 @@ export function startRuntimeServer({
|
|
|
24
24
|
approve,
|
|
25
25
|
configProfiles,
|
|
26
26
|
useConfigProfile,
|
|
27
|
+
listMcpEndpoints,
|
|
28
|
+
upsertMcpEndpoint,
|
|
29
|
+
deleteMcpEndpoint,
|
|
27
30
|
listActiveRuns = null,
|
|
28
31
|
exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
|
|
29
32
|
} = {}) {
|
|
@@ -230,6 +233,31 @@ export function startRuntimeServer({
|
|
|
230
233
|
sendJson(response, 200, result);
|
|
231
234
|
return;
|
|
232
235
|
}
|
|
236
|
+
if (request.method === 'GET' && url.pathname === '/mcp/endpoints') {
|
|
237
|
+
const workspace = workspaceFromUrl(url);
|
|
238
|
+
const context = await resolveContext({ workspace });
|
|
239
|
+
const result = await listMcpEndpoints?.(context);
|
|
240
|
+
sendJson(response, 200, result ?? { endpoints: [] });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (request.method === 'POST' && url.pathname === '/mcp/endpoints') {
|
|
244
|
+
const activeRuns = typeof listActiveRuns === 'function' ? listActiveRuns() : [];
|
|
245
|
+
if (activeRuns.length > 0) {
|
|
246
|
+
sendJson(response, 409, { error: 'MCP connectors cannot be changed while a plan is running.' });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
250
|
+
const action = String(body.action ?? 'upsert').trim().toLowerCase();
|
|
251
|
+
const result = action === 'delete'
|
|
252
|
+
? await deleteMcpEndpoint?.(context, body)
|
|
253
|
+
: await upsertMcpEndpoint?.(context, body);
|
|
254
|
+
if (!result) {
|
|
255
|
+
sendJson(response, 501, { error: 'MCP endpoint management is not supported.' });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
sendJson(response, 200, result);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
233
261
|
if (request.method === 'GET' && url.pathname === '/events/stream') {
|
|
234
262
|
const workspace = workspaceFromUrl(url);
|
|
235
263
|
const context = workspace ? await resolveContext({ workspace }) : null;
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -139,20 +139,52 @@ function WelcomeHelpPanels(props: { width: number }) {
|
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
const COPY_BTN = '
|
|
142
|
+
const COPY_BTN = ' copy';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Barre de gouttière, préfixée à chaque ligne d'un message.
|
|
146
|
+
*
|
|
147
|
+
* Remplace le filet horizontal pleine largeur qui ouvrait chaque message.
|
|
148
|
+
* Celui-ci était l'élément le plus encré de l'écran, répété à chaque tour, et
|
|
149
|
+
* il séparait là où il fallait grouper : rien n'indiquait où un bloc finissait,
|
|
150
|
+
* seulement où le suivant commençait. Une barre colorée porte la même
|
|
151
|
+
* information — à qui est le tour, où le bloc commence ET où il s'arrête — pour
|
|
152
|
+
* un caractère par ligne au lieu de toute la largeur.
|
|
153
|
+
*/
|
|
154
|
+
const GUTTER = '▌ ';
|
|
155
|
+
|
|
156
|
+
function clockLabel(at?: number): string {
|
|
157
|
+
if (!Number.isFinite(at)) return '';
|
|
158
|
+
const date = new Date(at as number);
|
|
159
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
160
|
+
}
|
|
143
161
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
162
|
+
/**
|
|
163
|
+
* En-tête d'un message : gouttière, locuteur, heure, et `copy` à droite.
|
|
164
|
+
*
|
|
165
|
+
* Le bouton de copie était collé au libellé, précédé d'un filet de soixante-dix
|
|
166
|
+
* tirets. Poussé au bord droit et dégrisé, il reste atteignable sans peser sur
|
|
167
|
+
* la ligne. L'heure était reléguée dans une colonne latérale, détachée de ce
|
|
168
|
+
* qu'elle datait ; elle coûte cinq caractères ici et devient lisible.
|
|
169
|
+
*/
|
|
170
|
+
function messageHeaderSegments(role: string, columns: number, at?: number): Segment[] {
|
|
171
|
+
const time = clockLabel(at);
|
|
172
|
+
const label = roleLabel(role);
|
|
173
|
+
const used = GUTTER.length + label.length + (time ? time.length + 2 : 0);
|
|
174
|
+
const pad = Math.max(1, columns - used - COPY_BTN.length);
|
|
149
175
|
return [
|
|
150
|
-
{ text:
|
|
176
|
+
{ text: GUTTER, color: roleColor(role) },
|
|
151
177
|
{ text: label, color: roleColor(role) },
|
|
152
|
-
{ text:
|
|
178
|
+
...(time ? [{ text: ` ${time}`, color: '#4B5563' }] : []),
|
|
179
|
+
{ text: ' '.repeat(pad), color: '#4B5563' },
|
|
153
180
|
];
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
/** Préfixe la gouttière à une ligne de corps de message. */
|
|
184
|
+
function withGutter(line: RenderedLine, role: string): RenderedLine {
|
|
185
|
+
return { ...line, segments: [{ text: GUTTER, color: roleColor(role) }, ...line.segments] };
|
|
186
|
+
}
|
|
187
|
+
|
|
156
188
|
// Split " /cmd [<arg>...] description" into two segments.
|
|
157
189
|
// Uses non-greedy to stop at the first double-space separator.
|
|
158
190
|
function splitCmdDesc(line: string): [string, string] | null {
|
|
@@ -413,21 +445,24 @@ function headerGroupKey(role: string) {
|
|
|
413
445
|
return isDonnaRole(role) ? 'donna' : roleLabel(role);
|
|
414
446
|
}
|
|
415
447
|
|
|
416
|
-
function conversationLines(messages: Array<{ role: string; content: string }>, columns: number): RenderedLine[] {
|
|
448
|
+
function conversationLines(messages: Array<{ role: string; content: string; at?: number }>, columns: number): RenderedLine[] {
|
|
417
449
|
return messages.flatMap((message, index) => {
|
|
418
450
|
const raw = String(message.content || '');
|
|
419
451
|
const previous = index > 0 ? messages[index - 1] : null;
|
|
420
452
|
const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
|
|
453
|
+
// Plus de ligne vide sous l'en-tête : le filet et son rembourrage
|
|
454
|
+
// coûtaient deux lignes par message, en plus de la ligne de séparation.
|
|
455
|
+
// La gouttière rattache visuellement l'en-tête à son corps, la respiration
|
|
456
|
+
// entre tours suffit.
|
|
421
457
|
const headerLines: RenderedLine[] = showHeader
|
|
422
|
-
? [
|
|
423
|
-
{
|
|
424
|
-
segments: messageHeaderSegments(message.role, columns),
|
|
425
|
-
copyContent: raw,
|
|
426
|
-
},
|
|
427
|
-
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
428
|
-
]
|
|
458
|
+
? [{ segments: messageHeaderSegments(message.role, columns, message.at), copyContent: raw }]
|
|
429
459
|
: [];
|
|
430
|
-
|
|
460
|
+
// Les messages utilisateur étaient repliés à 60 % de la largeur pour tenir
|
|
461
|
+
// dans une bulle alignée à droite. Cela coupait les phrases en plein milieu
|
|
462
|
+
// et faisait repartir l'œil de deux colonnes différentes selon le locuteur.
|
|
463
|
+
// La couleur de gouttière distingue les tours ; la colonne de lecture reste
|
|
464
|
+
// unique. On retire aussi la largeur de la gouttière du texte disponible.
|
|
465
|
+
const contentColumns = Math.max(12, columns - GUTTER.length);
|
|
431
466
|
if (isStatusOutput(message)) {
|
|
432
467
|
const statusLines: RenderedLine[] = [
|
|
433
468
|
...headerLines,
|
|
@@ -454,15 +489,9 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
454
489
|
lines.push({ text: line, isCode: inFence });
|
|
455
490
|
}
|
|
456
491
|
const renderedBody = renderMarkdownLines(lines, message.role, contentColumns);
|
|
457
|
-
const userBubbleWidth = message.role === 'user'
|
|
458
|
-
? Math.min(
|
|
459
|
-
Math.max(1, ...renderedBody.map((line) => line.segments.reduce((sum, segment) => sum + segment.text.length, 0))) + 2,
|
|
460
|
-
Math.max(12, Math.floor(columns * 0.6)),
|
|
461
|
-
)
|
|
462
|
-
: undefined;
|
|
463
492
|
const bodyLines: RenderedLine[] = [
|
|
464
493
|
...headerLines,
|
|
465
|
-
...renderedBody.map((line) => (
|
|
494
|
+
...renderedBody.map((line) => withGutter(line, message.role)),
|
|
466
495
|
{ segments: [{ text: ' ', color: '#D6DEE8' }] },
|
|
467
496
|
];
|
|
468
497
|
return bodyLines.map((line) => ({ ...line, role: message.role }));
|
|
@@ -470,7 +499,7 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
470
499
|
}
|
|
471
500
|
|
|
472
501
|
export function ConversationView(props: {
|
|
473
|
-
messages: Array<{ role: string; content: string }>;
|
|
502
|
+
messages: Array<{ role: string; content: string; at?: number }>;
|
|
474
503
|
rows: number;
|
|
475
504
|
columns: number;
|
|
476
505
|
scroll: number;
|
|
@@ -536,21 +565,6 @@ export function ConversationView(props: {
|
|
|
536
565
|
</For>
|
|
537
566
|
<text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
|
|
538
567
|
</box>
|
|
539
|
-
) : line.role === 'user' && line.userBubbleWidth ? (
|
|
540
|
-
<box height={1} flexDirection="row" overflow="hidden">
|
|
541
|
-
<box flexGrow={1} />
|
|
542
|
-
<box
|
|
543
|
-
width={line.userBubbleWidth}
|
|
544
|
-
height={1}
|
|
545
|
-
flexDirection="row"
|
|
546
|
-
flexShrink={1}
|
|
547
|
-
justifyContent="flex-start"
|
|
548
|
-
paddingX={1}
|
|
549
|
-
overflow="hidden"
|
|
550
|
-
>
|
|
551
|
-
<text>{styledSegments(line.segments)}</text>
|
|
552
|
-
</box>
|
|
553
|
-
</box>
|
|
554
568
|
) : line.status ? (
|
|
555
569
|
<box height={1} flexDirection="row" gap={2} overflow="hidden">
|
|
556
570
|
<box
|
|
@@ -786,7 +800,7 @@ export function LeftPane(props: {
|
|
|
786
800
|
statusLine: string;
|
|
787
801
|
hintLine?: string | null;
|
|
788
802
|
showWelcome: boolean;
|
|
789
|
-
messages: Array<{ role: string; content: string }>;
|
|
803
|
+
messages: Array<{ role: string; content: string; at?: number }>;
|
|
790
804
|
prompt: string;
|
|
791
805
|
input: string;
|
|
792
806
|
busy: boolean;
|
package/src/shell/repl.js
CHANGED
|
@@ -169,11 +169,34 @@ export function conversationKey(session) {
|
|
|
169
169
|
return session.workspace || GLOBAL_CONVERSATION_KEY;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Horodate un message au moment où il entre dans la conversation.
|
|
174
|
+
*
|
|
175
|
+
* Posé sur le `push` du tableau plutôt qu'aux trente-deux endroits qui créent
|
|
176
|
+
* un message : un seul de ces endroits oublié, et la ligne perdrait son heure
|
|
177
|
+
* sans que rien ne le signale. Un message qui porte déjà `at` — rejoué depuis
|
|
178
|
+
* l'historique, par exemple — garde le sien.
|
|
179
|
+
*/
|
|
180
|
+
function stampConversation(messages) {
|
|
181
|
+
if (messages.__stamped) return messages;
|
|
182
|
+
Object.defineProperty(messages, '__stamped', { value: true });
|
|
183
|
+
const push = messages.push.bind(messages);
|
|
184
|
+
Object.defineProperty(messages, 'push', {
|
|
185
|
+
value: (...entries) =>
|
|
186
|
+
push(...entries.map((entry) =>
|
|
187
|
+
entry && typeof entry === 'object' && entry.at === undefined
|
|
188
|
+
? Object.assign(entry, { at: Date.now() })
|
|
189
|
+
: entry,
|
|
190
|
+
)),
|
|
191
|
+
});
|
|
192
|
+
return messages;
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
export function conversationMessages(session) {
|
|
173
196
|
const key = conversationKey(session);
|
|
174
197
|
session.conversations ??= { [GLOBAL_CONVERSATION_KEY]: [] };
|
|
175
198
|
session.conversations[key] ??= [];
|
|
176
|
-
return session.conversations[key];
|
|
199
|
+
return stampConversation(session.conversations[key]);
|
|
177
200
|
}
|
|
178
201
|
|
|
179
202
|
// Ordered from verifiable to hopeful. Mirrors tui.tsx's clipboardCommands():
|
package/src/shell/repl.test.js
CHANGED
|
@@ -31,28 +31,50 @@ test('ShellUI inserts StyledText as a child instead of stringifying it through c
|
|
|
31
31
|
assert.match(source, /<text[^>]*>\{styledSegments\(line\.segments\)\}<\/text>/);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
test('
|
|
34
|
+
test('every message line carries its speaker gutter, and no full-width rule', async () => {
|
|
35
35
|
const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
assert.match(source, /
|
|
41
|
-
assert.
|
|
42
|
-
assert.match(source
|
|
43
|
-
assert.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
// Le filet pleine largeur ouvrait chaque message : l'élément le plus encré de
|
|
37
|
+
// l'écran, répété à chaque tour, et qui séparait au lieu de grouper. La
|
|
38
|
+
// gouttière dit la même chose — à qui est le tour, où le bloc commence et où
|
|
39
|
+
// il finit — pour un caractère par ligne.
|
|
40
|
+
assert.match(source, /const GUTTER = '▌ ';/);
|
|
41
|
+
assert.match(source, /function withGutter\(/);
|
|
42
|
+
assert.match(source, /renderedBody\.map\(\(line\) => withGutter\(line, message\.role\)\)/);
|
|
43
|
+
assert.doesNotMatch(source, /'─'\.repeat\(rightLength\)/);
|
|
44
|
+
|
|
45
|
+
// La bulle alignée à droite repliait les messages utilisateur à 60 % de la
|
|
46
|
+
// largeur : phrases coupées en plein milieu, et deux colonnes de lecture
|
|
47
|
+
// selon le locuteur.
|
|
48
|
+
assert.doesNotMatch(source, /userBubbleWidth\s*=/);
|
|
49
|
+
assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.6\)/);
|
|
50
|
+
assert.match(source, /const contentColumns = Math\.max\(12, columns - GUTTER\.length\)/);
|
|
47
51
|
});
|
|
48
52
|
|
|
49
|
-
test('
|
|
53
|
+
test('the header carries the clock and the copy action, pushed to the right edge', async () => {
|
|
50
54
|
const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
|
|
51
|
-
assert.match(source, /const COPY_BTN = '
|
|
55
|
+
assert.match(source, /const COPY_BTN = ' copy';/);
|
|
56
|
+
assert.match(source, /messageHeaderSegments\(message\.role, columns, message\.at\)/);
|
|
57
|
+
// Le remplissage pousse `copy` au bord droit au lieu de le coller au libellé.
|
|
58
|
+
assert.match(source, /const pad = Math\.max\(1, columns - used - COPY_BTN\.length\)/);
|
|
52
59
|
assert.doesNotMatch(source, /\[\s*(?:reply|redo)\s*\]/i);
|
|
53
60
|
assert.doesNotMatch(source, /onRedo|redoContent|redoIndex|REDO_BTN/);
|
|
54
61
|
});
|
|
55
62
|
|
|
63
|
+
test('a message is stamped once, when it enters the conversation', async () => {
|
|
64
|
+
const { conversationMessages } = await import('./repl.js');
|
|
65
|
+
const session = { workspace: null };
|
|
66
|
+
const messages = conversationMessages(session);
|
|
67
|
+
// Posé sur le `push` du tableau : trente-deux endroits créent un message, et
|
|
68
|
+
// un seul oublié perdrait son heure sans que rien ne le signale.
|
|
69
|
+
messages.push({ role: 'user', content: 'bonjour' });
|
|
70
|
+
assert.ok(Number.isFinite(messages[0].at));
|
|
71
|
+
// Un message qui porte déjà son heure garde la sienne.
|
|
72
|
+
messages.push({ role: 'donna', content: 'rejoué', at: 42 });
|
|
73
|
+
assert.equal(messages[1].at, 42);
|
|
74
|
+
// Et le tableau n'est instrumenté qu'une fois.
|
|
75
|
+
assert.equal(conversationMessages(session), messages);
|
|
76
|
+
});
|
|
77
|
+
|
|
56
78
|
test('redo translates the local thread index into a runtime conversation index', async () => {
|
|
57
79
|
const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
|
|
58
80
|
const redo = session.slice(
|
|
@@ -559,7 +581,10 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
|
|
|
559
581
|
assert.equal(session.chatMode, true);
|
|
560
582
|
assert.equal(result.oneShotAgent, true);
|
|
561
583
|
assert.equal(result.runtimeOutcome.kind, 'accepted');
|
|
562
|
-
assert.deepEqual(
|
|
584
|
+
assert.deepEqual(
|
|
585
|
+
conversationMessages(session).map(({ at, ...rest }) => rest),
|
|
586
|
+
[{ role: 'user', content: 'lance ingestion', _pending: true }],
|
|
587
|
+
);
|
|
563
588
|
} finally {
|
|
564
589
|
restore();
|
|
565
590
|
}
|
|
@@ -658,10 +683,14 @@ test('agent mode without runtime records a visible error instead of falling back
|
|
|
658
683
|
const message = recordRuntimeUnavailableAgentInput(session, 'salut', { error: 'port 7788 already in use' });
|
|
659
684
|
|
|
660
685
|
assert.equal(message, '⚠ Runtime indisponible : port 7788 already in use — /agent désactivé, /chat reste possible');
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
{
|
|
664
|
-
|
|
686
|
+
// `at` est posé à l'insertion : on compare le reste.
|
|
687
|
+
assert.deepEqual(
|
|
688
|
+
conversationMessages(session).map(({ at, ...rest }) => rest),
|
|
689
|
+
[
|
|
690
|
+
{ role: 'user', content: 'salut' },
|
|
691
|
+
{ role: 'command', content: message },
|
|
692
|
+
],
|
|
693
|
+
);
|
|
665
694
|
});
|
|
666
695
|
|
|
667
696
|
test('runtime status exposes the disconnected reason', () => {
|
package/wiki-workspace
CHANGED
|
@@ -534,7 +534,11 @@ ensure_endpoints_file() {
|
|
|
534
534
|
# Without mcp.endpoints.json the shell never connects to the agents it
|
|
535
535
|
# just started (and `agents status` refuses to run). Seed it from the
|
|
536
536
|
# packaged example; the \${VAR} placeholders resolve against the .env.
|
|
537
|
+
if [[ -e "$MANAGER_ENDPOINTS_FILE" && ! -f "$MANAGER_ENDPOINTS_FILE" ]]; then
|
|
538
|
+
die "MCP endpoints path is not a file: $MANAGER_ENDPOINTS_FILE"
|
|
539
|
+
fi
|
|
537
540
|
if [[ ! -f "$MANAGER_ENDPOINTS_FILE" && -f "$ROOT_DIR/mcp.endpoints.example.json" ]]; then
|
|
541
|
+
mkdir -p "$(dirname "$MANAGER_ENDPOINTS_FILE")"
|
|
538
542
|
cp "$ROOT_DIR/mcp.endpoints.example.json" "$MANAGER_ENDPOINTS_FILE"
|
|
539
543
|
printf 'Created %s from packaged example\n' "$MANAGER_ENDPOINTS_FILE"
|
|
540
544
|
fi
|
|
@@ -918,6 +922,9 @@ ensure_compose_override() {
|
|
|
918
922
|
}
|
|
919
923
|
|
|
920
924
|
ensure_manager_endpoints_file() {
|
|
925
|
+
if [[ -e "$MANAGER_ENDPOINTS_FILE" && ! -f "$MANAGER_ENDPOINTS_FILE" ]]; then
|
|
926
|
+
die "MCP endpoints path is not a file: $MANAGER_ENDPOINTS_FILE"
|
|
927
|
+
fi
|
|
921
928
|
if [[ ! -f "$MANAGER_ENDPOINTS_FILE" && -f "$ROOT_DIR/mcp.endpoints.example.json" ]]; then
|
|
922
929
|
mkdir -p "$(dirname "$MANAGER_ENDPOINTS_FILE")"
|
|
923
930
|
cp "$ROOT_DIR/mcp.endpoints.example.json" "$MANAGER_ENDPOINTS_FILE"
|