@dotdrelle/wiki-manager 0.15.35 → 0.15.40
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 +47 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/cli/wiki-manager.js +25 -3
- package/src/commands/slash.js +12 -3
- package/src/commands/slash.test.js +48 -0
- package/src/core/agentEvents.js +1 -0
- package/src/core/agentEvents.test.js +32 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +8 -0
- 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/orchestrator/dispatcher.js +4 -2
- package/src/orchestrator/dispatcher.test.js +33 -0
- package/src/runtime/server.js +52 -1
- package/src/runtime/server.test.js +113 -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
|
|
@@ -729,6 +771,11 @@ capabilityRouting:
|
|
|
729
771
|
allowedAgents: [connectors]
|
|
730
772
|
```
|
|
731
773
|
|
|
774
|
+
The production agent also advertises `workspace.restore` for Git-backed
|
|
775
|
+
rollback. It is workspace-scoped and remains subject to the normal runtime
|
|
776
|
+
approval and lock checks; it can be pinned in the same way when several agents
|
|
777
|
+
provide that capability.
|
|
778
|
+
|
|
732
779
|
#### Compose overrides — optional agents, proxies, local fixes
|
|
733
780
|
|
|
734
781
|
Two override files sit under **`.wiki/compose/`**, one per stack:
|
package/docker-compose.yml
CHANGED
|
@@ -124,7 +124,7 @@ services:
|
|
|
124
124
|
- WORKSPACE_NAME=${WORKSPACE_NAME:-workspace}
|
|
125
125
|
- WIKI_WORKSPACE_PATH=/workspace
|
|
126
126
|
- WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
|
|
127
|
-
- PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,pipeline}
|
|
127
|
+
- PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,restore,pipeline}
|
|
128
128
|
- PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
|
|
129
129
|
# Parallelism levers — effective concurrency ≈ recommendedConcurrency.
|
|
130
130
|
# Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
|
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__';
|
|
@@ -1222,9 +1231,11 @@ async function runRuntime(argv, agent) {
|
|
|
1222
1231
|
),
|
|
1223
1232
|
requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
|
|
1224
1233
|
},
|
|
1225
|
-
...(
|
|
1226
|
-
? { arguments:
|
|
1227
|
-
:
|
|
1234
|
+
...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
|
|
1235
|
+
? { arguments: body.capabilityPlan.arguments }
|
|
1236
|
+
: Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
|
|
1237
|
+
? { arguments: { inputs: body.capabilityPlan.inputs } }
|
|
1238
|
+
: {}),
|
|
1228
1239
|
})));
|
|
1229
1240
|
if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
|
|
1230
1241
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
@@ -1416,6 +1427,17 @@ async function runRuntime(argv, agent) {
|
|
|
1416
1427
|
config,
|
|
1417
1428
|
};
|
|
1418
1429
|
},
|
|
1430
|
+
listMcpEndpoints: async () => ({ endpoints: listManagedMcpEndpoints() }),
|
|
1431
|
+
upsertMcpEndpoint: async (_context, body) => {
|
|
1432
|
+
const endpoint = upsertManagedMcpEndpoint(body);
|
|
1433
|
+
await refreshAllMcpContexts();
|
|
1434
|
+
return { ok: true, endpoint };
|
|
1435
|
+
},
|
|
1436
|
+
deleteMcpEndpoint: async (_context, body) => {
|
|
1437
|
+
const endpoint = deleteManagedMcpEndpoint(body.name);
|
|
1438
|
+
await refreshAllMcpContexts();
|
|
1439
|
+
return { ok: true, endpoint };
|
|
1440
|
+
},
|
|
1419
1441
|
token: auth.token,
|
|
1420
1442
|
});
|
|
1421
1443
|
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/agentEvents.js
CHANGED
|
@@ -556,6 +556,7 @@ function applyEvent(state, event) {
|
|
|
556
556
|
status: 'queued',
|
|
557
557
|
createdAt: event.payload?.createdAt ?? event.ts,
|
|
558
558
|
updatedAt: event.ts,
|
|
559
|
+
...(event.payload?.capabilityPlan !== undefined ? { capabilityPlan: event.payload.capabilityPlan } : {}),
|
|
559
560
|
});
|
|
560
561
|
return;
|
|
561
562
|
case 'control_started':
|
|
@@ -364,6 +364,38 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
|
|
|
364
364
|
assert.equal(projection.controlQueue[1].status, 'cancelled');
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
+
test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan across replay', () => {
|
|
368
|
+
const capabilityPlan = {
|
|
369
|
+
capability: 'workspace.restore',
|
|
370
|
+
operation: 'restore',
|
|
371
|
+
arguments: { run: 'abc123' },
|
|
372
|
+
requireApproval: true,
|
|
373
|
+
};
|
|
374
|
+
const projection = reduceAgentEvents([
|
|
375
|
+
createAgentEvent('control_enqueued', {
|
|
376
|
+
origin: 'runtime',
|
|
377
|
+
workspace: 'docs',
|
|
378
|
+
payload: {
|
|
379
|
+
id: 'control-restore',
|
|
380
|
+
workspace: 'docs',
|
|
381
|
+
input: 'restore',
|
|
382
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
383
|
+
capabilityPlan,
|
|
384
|
+
},
|
|
385
|
+
}),
|
|
386
|
+
createAgentEvent('control_started', {
|
|
387
|
+
origin: 'runtime',
|
|
388
|
+
runId: 'run-control-restore',
|
|
389
|
+
workspace: 'docs',
|
|
390
|
+
payload: { id: 'control-restore', runId: 'run-control-restore' },
|
|
391
|
+
}),
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
const item = projection.controlQueue.find((entry) => entry.id === 'control-restore');
|
|
395
|
+
assert.deepEqual(item.capabilityPlan, capabilityPlan);
|
|
396
|
+
assert.equal(item.status, 'running');
|
|
397
|
+
});
|
|
398
|
+
|
|
367
399
|
test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
|
|
368
400
|
const projection = reduceAgentEvents([
|
|
369
401
|
createAgentEvent('activity_upserted', {
|
package/src/core/buildInfo.json
CHANGED
|
@@ -19,6 +19,14 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
|
|
|
19
19
|
);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
test('workspace production agent enables restore by default', async () => {
|
|
23
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
24
|
+
const compose = YAML.parse(raw);
|
|
25
|
+
const allowed = compose.services['production-mcp'].environment
|
|
26
|
+
.find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
|
|
27
|
+
assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
|
|
28
|
+
});
|
|
29
|
+
|
|
22
30
|
test('shipped compose files never carry a build context', async () => {
|
|
23
31
|
// Ces deux fichiers partent dans le paquet npm, où les dépôts frères
|
|
24
32
|
// (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
|
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.40';
|
|
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';
|
|
@@ -53,7 +53,7 @@ export async function execute(task, assignment, {
|
|
|
53
53
|
session.mcp,
|
|
54
54
|
serverName,
|
|
55
55
|
executeTool,
|
|
56
|
-
executeRequest(task, session),
|
|
56
|
+
executeRequest(task, session, runId),
|
|
57
57
|
signal,
|
|
58
58
|
));
|
|
59
59
|
if (accepted?.accepted === false || accepted?.ok === false) {
|
|
@@ -157,9 +157,11 @@ export async function execute(task, assignment, {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
function executeRequest(task, session) {
|
|
160
|
+
function executeRequest(task, session, runId) {
|
|
161
161
|
return {
|
|
162
162
|
taskId: String(task.id ?? task.step),
|
|
163
|
+
...(runId ? { runId: String(runId) } : {}),
|
|
164
|
+
...(task.requiredCapability ? { capability: String(task.requiredCapability) } : {}),
|
|
163
165
|
idempotencyKey: task.idempotencyKey ?? undefined,
|
|
164
166
|
operation: task.operation,
|
|
165
167
|
workspace: workspaceRequest(session),
|
|
@@ -33,6 +33,39 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
|
|
|
33
33
|
assert.equal(result.error.retryable, true);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
test('dispatcher forwards the orchestration run and required capability to agent_execute', async () => {
|
|
37
|
+
let executeArgs;
|
|
38
|
+
const session = {
|
|
39
|
+
workspace: 'test',
|
|
40
|
+
mcp: {
|
|
41
|
+
production: {
|
|
42
|
+
tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
activities: {},
|
|
46
|
+
};
|
|
47
|
+
const dispatcher = createDispatcher({
|
|
48
|
+
session,
|
|
49
|
+
pollIntervalMs: 1,
|
|
50
|
+
callTool: async (_mcp, _server, tool, args) => {
|
|
51
|
+
if (tool === 'agent_execute') {
|
|
52
|
+
executeArgs = args;
|
|
53
|
+
return { accepted: true, jobId: 'job-build', status: 'queued' };
|
|
54
|
+
}
|
|
55
|
+
return { jobId: 'job-build', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await dispatcher.execute(
|
|
60
|
+
{ id: 'build-a', requiredCapability: 'document.build', operation: 'build', arguments: {} },
|
|
61
|
+
{ serverName: 'production', agentInstanceId: 'production-main' },
|
|
62
|
+
{ runId: 'run-donna-1', attempt: { attemptId: 'build-a:attempt-1', locks: [], release() {} } },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(executeArgs.runId, 'run-donna-1');
|
|
66
|
+
assert.equal(executeArgs.capability, 'document.build');
|
|
67
|
+
});
|
|
68
|
+
|
|
36
69
|
test('dispatcher completes when an executor-only agent reports succeeded', async () => {
|
|
37
70
|
const session = {
|
|
38
71
|
workspace: 'test',
|
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;
|
|
@@ -255,6 +283,27 @@ export function startRuntimeServer({
|
|
|
255
283
|
}
|
|
256
284
|
validateContractInDev('runRequest', { ...body, input });
|
|
257
285
|
if (context.running) {
|
|
286
|
+
// A structured capability plan must never degrade into a free-text
|
|
287
|
+
// control message: its exact arguments and approval policy would
|
|
288
|
+
// otherwise be discarded by the control lane. It can still be
|
|
289
|
+
// queued as a future run when the caller explicitly opts in via
|
|
290
|
+
// intent: 'enqueue' — the control queue carries capabilityPlan
|
|
291
|
+
// through untouched (see enqueueControlRequest/
|
|
292
|
+
// startNextControlRequest) instead of collapsing it to text.
|
|
293
|
+
if (body.capabilityPlan) {
|
|
294
|
+
if (body.intent === 'enqueue') {
|
|
295
|
+
const item = enqueueControlRequest(context, input, { capabilityPlan: body.capabilityPlan });
|
|
296
|
+
void startNextControlRequest(context);
|
|
297
|
+
sendJson(response, 202, {
|
|
298
|
+
accepted: true,
|
|
299
|
+
item,
|
|
300
|
+
...controlStatus(context, store),
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
sendJson(response, 409, { error: 'run_active' });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
258
307
|
// Blind enqueueing made every message typed during a run
|
|
259
308
|
// invisible until the run ended (serve UI symptom: "no result
|
|
260
309
|
// until the job finished or was stopped"). Classify instead:
|
|
@@ -602,6 +651,7 @@ export function startRuntimeServer({
|
|
|
602
651
|
startRuntimeRun(context, {
|
|
603
652
|
input: item.input,
|
|
604
653
|
workspace: item.workspace ?? context.workspace ?? null,
|
|
654
|
+
...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
|
|
605
655
|
}, { controlItemId: item.id });
|
|
606
656
|
return true;
|
|
607
657
|
}
|
|
@@ -815,7 +865,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
815
865
|
: controlMessage(context?.session, 'converse_while_idle'));
|
|
816
866
|
}
|
|
817
867
|
|
|
818
|
-
function enqueueControlRequest(context, input) {
|
|
868
|
+
function enqueueControlRequest(context, input, { capabilityPlan } = {}) {
|
|
819
869
|
const now = new Date().toISOString();
|
|
820
870
|
const item = {
|
|
821
871
|
id: `control-${randomUUID()}`,
|
|
@@ -825,6 +875,7 @@ function enqueueControlRequest(context, input) {
|
|
|
825
875
|
status: 'queued',
|
|
826
876
|
createdAt: now,
|
|
827
877
|
updatedAt: now,
|
|
878
|
+
...(capabilityPlan !== undefined ? { capabilityPlan } : {}),
|
|
828
879
|
};
|
|
829
880
|
dispatchAgentEvent(context.session, createAgentEvent('control_enqueued', {
|
|
830
881
|
origin: 'runtime',
|
|
@@ -347,6 +347,119 @@ test('runtime server accepts only one active run', async (t) => {
|
|
|
347
347
|
}
|
|
348
348
|
});
|
|
349
349
|
|
|
350
|
+
test('runtime server rejects a structured capability plan while a run is active', async (t) => {
|
|
351
|
+
let releaseRun;
|
|
352
|
+
let handle;
|
|
353
|
+
try {
|
|
354
|
+
handle = await startRuntimeServer({
|
|
355
|
+
host: '127.0.0.1',
|
|
356
|
+
port: 0,
|
|
357
|
+
store: {
|
|
358
|
+
dbPath: ':memory:',
|
|
359
|
+
getState: () => ({ status: 'idle' }),
|
|
360
|
+
listEvents: () => [],
|
|
361
|
+
},
|
|
362
|
+
session: {},
|
|
363
|
+
run: async () => new Promise((resolve) => { releaseRun = resolve; }),
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (err?.code === 'EPERM') {
|
|
367
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
throw err;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
375
|
+
const first = await fetch(url, {
|
|
376
|
+
method: 'POST',
|
|
377
|
+
headers: { 'Content-Type': 'application/json' },
|
|
378
|
+
body: JSON.stringify({ input: 'active build' }),
|
|
379
|
+
});
|
|
380
|
+
assert.equal(first.status, 202);
|
|
381
|
+
|
|
382
|
+
const restore = await fetch(url, {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { 'Content-Type': 'application/json' },
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
input: 'restore',
|
|
387
|
+
capabilityPlan: {
|
|
388
|
+
capability: 'workspace.restore',
|
|
389
|
+
operation: 'restore',
|
|
390
|
+
arguments: { run: 'abc123' },
|
|
391
|
+
requireApproval: true,
|
|
392
|
+
},
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
assert.equal(restore.status, 409);
|
|
396
|
+
assert.deepEqual(await restore.json(), { error: 'run_active' });
|
|
397
|
+
} finally {
|
|
398
|
+
releaseRun?.();
|
|
399
|
+
await handle.close();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('runtime server queues a structured capability plan when intent is enqueue', async (t) => {
|
|
404
|
+
let releaseRun;
|
|
405
|
+
let handle;
|
|
406
|
+
try {
|
|
407
|
+
handle = await startRuntimeServer({
|
|
408
|
+
host: '127.0.0.1',
|
|
409
|
+
port: 0,
|
|
410
|
+
store: {
|
|
411
|
+
dbPath: ':memory:',
|
|
412
|
+
getState: () => ({ status: 'idle' }),
|
|
413
|
+
listEvents: () => [],
|
|
414
|
+
},
|
|
415
|
+
session: {},
|
|
416
|
+
run: async () => new Promise((resolve) => { releaseRun = resolve; }),
|
|
417
|
+
});
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err?.code === 'EPERM') {
|
|
420
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
throw err;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
428
|
+
const first = await fetch(url, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: { 'Content-Type': 'application/json' },
|
|
431
|
+
body: JSON.stringify({ input: 'active build' }),
|
|
432
|
+
});
|
|
433
|
+
assert.equal(first.status, 202);
|
|
434
|
+
|
|
435
|
+
const capabilityPlan = {
|
|
436
|
+
capability: 'workspace.restore',
|
|
437
|
+
operation: 'restore',
|
|
438
|
+
arguments: { run: 'abc123' },
|
|
439
|
+
requireApproval: true,
|
|
440
|
+
};
|
|
441
|
+
const restore = await fetch(url, {
|
|
442
|
+
method: 'POST',
|
|
443
|
+
headers: { 'Content-Type': 'application/json' },
|
|
444
|
+
body: JSON.stringify({ input: 'restore', intent: 'enqueue', capabilityPlan }),
|
|
445
|
+
});
|
|
446
|
+
assert.equal(restore.status, 202);
|
|
447
|
+
const restoreBody = await restore.json();
|
|
448
|
+
assert.equal(restoreBody.accepted, true);
|
|
449
|
+
assert.deepEqual(restoreBody.item.capabilityPlan, capabilityPlan);
|
|
450
|
+
assert.equal(restoreBody.item.status, 'queued');
|
|
451
|
+
|
|
452
|
+
const status = await fetch(`http://127.0.0.1:${handle.port}/control`);
|
|
453
|
+
const statusBody = await status.json();
|
|
454
|
+
const queued = statusBody.controlQueue.find((item) => item.id === restoreBody.item.id);
|
|
455
|
+
assert.ok(queued, 'queued item should be visible in control status');
|
|
456
|
+
assert.deepEqual(queued.capabilityPlan, capabilityPlan);
|
|
457
|
+
} finally {
|
|
458
|
+
releaseRun?.();
|
|
459
|
+
await handle.close();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
350
463
|
test('runtime server returns the accepted run id and passes it to the runner', async (t) => {
|
|
351
464
|
let receivedBody = null;
|
|
352
465
|
let handle;
|
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"
|