@dotdrelle/wiki-manager 0.14.6 → 0.14.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/README.md +4 -0
- package/package.json +6 -1
- package/src/agent/graph.js +67 -8
- package/src/agent/graph.test.js +112 -1
- package/src/cli/wiki-manager.js +102 -4
- package/src/commands/slash.js +18 -17
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +101 -21
- package/src/core/mcp.test.js +39 -1
- package/src/runtime/server.js +82 -2
- package/src/runtime/server.test.js +162 -1
- package/src/shell/LeftPane.tsx +1 -1
- package/src/shell/repl.js +29 -0
- package/src/shell/repl.test.js +34 -0
package/.env.example
CHANGED
|
@@ -59,6 +59,9 @@ DOCUMENTS_MCP_AUTH_TOKEN=
|
|
|
59
59
|
# Tool calls are retried on transient HTTP/MCP errors before the run fails.
|
|
60
60
|
# WIKI_MANAGER_MCP_RETRY_MAX_ATTEMPTS=2
|
|
61
61
|
# WIKI_MANAGER_MCP_RETRY_BACKOFF_MS=500
|
|
62
|
+
# Outbound MCP control budget, separate from .wikirc LLM requestsPerMinute.
|
|
63
|
+
# Default: 45 RPM (headroom for MCP servers limited to 50 RPM).
|
|
64
|
+
# WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE=45
|
|
62
65
|
|
|
63
66
|
# ── Runtime evaluator (optional) ───────────────────────────────────────────────
|
|
64
67
|
|
package/README.md
CHANGED
|
@@ -498,6 +498,10 @@ Copy `mcp.endpoints.example.json` to `mcp.endpoints.json` and set the matching
|
|
|
498
498
|
token variables in `.env`.
|
|
499
499
|
|
|
500
500
|
MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
|
|
501
|
+
They also share a per-endpoint outbound control budget (45 RPM by default,
|
|
502
|
+
configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
|
|
503
|
+
independent from `.wikirc` `requestsPerMinute`, which remains reserved for LLM,
|
|
504
|
+
embedding, and reranking provider calls.
|
|
501
505
|
Set global defaults with `WIKI_MANAGER_MCP_RETRY_MAX_ATTEMPTS` and
|
|
502
506
|
`WIKI_MANAGER_MCP_RETRY_BACKOFF_MS`, or override them per endpoint with `retry`
|
|
503
507
|
and per tool with `toolRetries`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.8",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"author": "dotrelle",
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"cli"
|
|
47
47
|
],
|
|
48
48
|
"packageManager": "pnpm@10.29.2",
|
|
49
|
+
"pnpm": {
|
|
50
|
+
"onlyBuiltDependencies": [
|
|
51
|
+
"bun"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
49
54
|
"dependencies": {
|
|
50
55
|
"@langchain/langgraph": "^1.3.2",
|
|
51
56
|
"@opentui/core": "^0.3.2",
|
package/src/agent/graph.js
CHANGED
|
@@ -403,6 +403,23 @@ function summarizeToolArguments(rawArguments) {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
function googleOAuthUrlFromMessages(messages) {
|
|
407
|
+
for (const message of [...(messages ?? [])].reverse()) {
|
|
408
|
+
if (message?.role !== 'tool') continue;
|
|
409
|
+
const match = String(message.content ?? '').match(/https:\/\/accounts\.google\.com\/[^\s<>"')\]]+/i);
|
|
410
|
+
if (match) return match[0];
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function preserveRequiredOAuthUrl(content, messages) {
|
|
416
|
+
const text = String(content ?? '');
|
|
417
|
+
const url = googleOAuthUrlFromMessages(messages);
|
|
418
|
+
if (!url || text.includes(url)) return text;
|
|
419
|
+
const prefix = text.trimEnd();
|
|
420
|
+
return `${prefix}${prefix ? '\n\n' : ''}Lien d’autorisation Google : ${url}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
406
423
|
function buildQueuedResult(session, item, activeJobId = null) {
|
|
407
424
|
const message = activeJobId != null
|
|
408
425
|
? `Production job queued as ${item.id}; waiting for ${activeJobId}.`
|
|
@@ -709,9 +726,21 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
709
726
|
if (tool === 'delegate') {
|
|
710
727
|
const objective = String(args.objective ?? '').trim();
|
|
711
728
|
if (!objective) return 'Delegation rejected: missing objective.';
|
|
729
|
+
const connectorConfig = connectorConfigurationTarget(session, objective);
|
|
730
|
+
if (connectorConfig?.setupTool) {
|
|
731
|
+
return `Delegation rejected: configuring or authenticating ${connectorConfig.serverName} is not an orchestrated export. Call the offered ${connectorConfig.serverName}__${connectorConfig.setupTool} tool directly and present its authorization instructions or URL to the user.`;
|
|
732
|
+
}
|
|
733
|
+
if (connectorConfig) {
|
|
734
|
+
return `Delegation rejected: ${connectorConfig.serverName} advertises no setup or authentication tool. Do not call an unrelated data tool and do not delegate to export. Explain conversationally that authentication must be completed outside MCP, using only configuration instructions already available in the current context.`;
|
|
735
|
+
}
|
|
712
736
|
const result = await postRuntimeDelegate(objective, { url, workspace });
|
|
713
737
|
return result?.runId
|
|
714
|
-
?
|
|
738
|
+
? JSON.stringify({
|
|
739
|
+
delegated: true,
|
|
740
|
+
runId: result.runId,
|
|
741
|
+
summary: result.delegation ?? null,
|
|
742
|
+
message: `Action lancée (${String(result.runId).slice(0, 8)}) après validation du plan réel : ${result.delegation?.tasks ?? 0} tâche(s), ${result.delegation?.agent ?? 'agent résolu'}. Exécution en cours.`,
|
|
743
|
+
})
|
|
715
744
|
: `Délégation refusée : ${result?.error ?? JSON.stringify(result)}`;
|
|
716
745
|
}
|
|
717
746
|
if (tool === 'enqueue') {
|
|
@@ -741,6 +770,24 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
741
770
|
}
|
|
742
771
|
}
|
|
743
772
|
|
|
773
|
+
function connectorConfigurationTarget(session, objective) {
|
|
774
|
+
const text = String(objective ?? '').toLowerCase();
|
|
775
|
+
if (!/(?:configur|connect|authent|oauth|setup|sign[ -]?in)/i.test(text)) return null;
|
|
776
|
+
for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
|
|
777
|
+
if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
|
|
778
|
+
const aliases = String(serverName).toLowerCase().split(/[^a-z0-9]+/).filter((part) => part.length >= 3);
|
|
779
|
+
if (!aliases.some((alias) => text.includes(alias))) continue;
|
|
780
|
+
const setupTool = server.tools.find((tool) => {
|
|
781
|
+
const name = String(tool?.name ?? '').toLowerCase();
|
|
782
|
+
const description = String(tool?.description ?? '').toLowerCase();
|
|
783
|
+
return /(?:^|_)(?:setup|config|configure|auth|authenticate|oauth|connect)(?:_|$)/.test(name)
|
|
784
|
+
|| /(?:initiat|start|configure|authenticate).{0,30}(?:oauth|authentication)/.test(description);
|
|
785
|
+
})?.name ?? null;
|
|
786
|
+
return { serverName, setupTool };
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
|
|
744
791
|
function handleWikiTool(session, tool, args) {
|
|
745
792
|
if (tool === 'plan_set') {
|
|
746
793
|
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
@@ -883,10 +930,10 @@ export function buildAgentSystemPrompt(state) {
|
|
|
883
930
|
'For service actions, recommend only available service primitives from Available primitives, with the exact service name when the primitive supports one.',
|
|
884
931
|
'Scope discipline: execute ONLY the action(s) the user explicitly requested. Never chain additional mutating operations (ingest, build, export, polish, delete, send…) that the user did not ask for — even when diagnostics or recommendations suggest them. Finish with the requested result and stop. Example: "applique les recommandations de config" means apply the config; it does NOT authorize launching the ingest those recommendations mention.',
|
|
885
932
|
state.session.runtime?.url
|
|
886
|
-
? 'The runtime is connected and runtime__delegate is bound and available
|
|
933
|
+
? 'The runtime is connected and runtime__delegate is bound and available for heavy orchestrated operations (ingest, build, export, polish, pipeline). Single-step connector actions such as configure, authenticate, add a source, convert, search, or send use the connected MCP tool directly. Never delegate connector configuration to an export capability.'
|
|
887
934
|
: 'No runtime is connected, so you cannot execute actions. State that plainly and name the runtime connection as the missing capability — do not invent a workaround.',
|
|
888
935
|
'If the connector or service needed for a requested read or action is absent from the Connected MCP tools above (its service is not running — e.g. CME, documents, or production), say plainly that this service is not connected and name it as the missing capability. Never redirect a simple read (e.g. "give me the CME config") to an "agent action", never invent its result, and never propose a workaround. Only requests you can actually serve with a listed tool are answered with data.',
|
|
889
|
-
'For
|
|
936
|
+
'For heavy orchestrated operations only (ingest, build, export, polish, pipeline), call runtime__delegate with the user objective only. For a single-step connector action, call the offered connector tool directly. Never choose a capability, operation, agent, plan, or implementation yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
|
|
890
937
|
'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
|
|
891
938
|
'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
|
|
892
939
|
'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
|
|
@@ -958,15 +1005,18 @@ function toolsForClassification(classification, writeTools, session = null) {
|
|
|
958
1005
|
return [SHELL_READ_COMMAND_TOOL, ...controlTools, ...capabilityRunTools, ...writeTools];
|
|
959
1006
|
}
|
|
960
1007
|
|
|
1008
|
+
const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch']);
|
|
1009
|
+
|
|
961
1010
|
export function isDonnaReadTool(item) {
|
|
962
1011
|
const name = String(item?.function?.name ?? '');
|
|
963
1012
|
if (!name || name.startsWith('shell__') || name === 'wiki__plan_set' || name === 'wiki__plan_done') return false;
|
|
964
1013
|
if (item?.readOnly === true) return true;
|
|
965
1014
|
const tool = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1015
|
+
if (tool === 'wiki_workspace_status' || tool === 'agent_describe' || tool === 'agent_status') return true;
|
|
1016
|
+
// Match a read verb anywhere in the underscore-tokenized name, not just as
|
|
1017
|
+
// a trailing suffix — third-party MCPs don't all name tools verb-last
|
|
1018
|
+
// (e.g. exa's "web_search_exa"/"web_fetch_exa" put the verb in the middle).
|
|
1019
|
+
return tool.split('_').some((segment) => DONNA_READ_VERBS.has(segment));
|
|
970
1020
|
}
|
|
971
1021
|
|
|
972
1022
|
// Two-tier tool policy. Donna may call any connected MCP tool directly
|
|
@@ -1187,6 +1237,15 @@ export function createAgentGraph(options = {}) {
|
|
|
1187
1237
|
};
|
|
1188
1238
|
}
|
|
1189
1239
|
|
|
1240
|
+
// Authentication URLs are execution outputs, not optional prose. Small
|
|
1241
|
+
// models sometimes summarize an OAuth tool result as "open the supplied
|
|
1242
|
+
// link" while dropping the link itself, leaving ShellUI unusable even
|
|
1243
|
+
// though the MCP call succeeded. Preserve that exact URL deterministically.
|
|
1244
|
+
const finalContent = preserveRequiredOAuthUrl(result.content, conversationMessages);
|
|
1245
|
+
if (finalContent !== String(result.content ?? '')) {
|
|
1246
|
+
result.content = finalContent;
|
|
1247
|
+
result.message = { ...(result.message ?? { role: 'assistant' }), content: finalContent };
|
|
1248
|
+
}
|
|
1190
1249
|
const invalidCommands = invalidSuggestedSlashCommands(result.content, state.session);
|
|
1191
1250
|
const leakedTools = invalidUserFacingToolNames(result.content, state.session);
|
|
1192
1251
|
if (invalidCommands.length > 0 || leakedTools.length > 0) {
|
|
@@ -1241,7 +1300,7 @@ export function createAgentGraph(options = {}) {
|
|
|
1241
1300
|
|
|
1242
1301
|
// Fallback path (streamWithTools unavailable): hand off to runLine for streaming.
|
|
1243
1302
|
state.session._onStep?.('Agent: streaming final answer…');
|
|
1244
|
-
if (typeof llm.stream === 'function') {
|
|
1303
|
+
if (typeof llm.stream === 'function' && !googleOAuthUrlFromMessages(conversationMessages)) {
|
|
1245
1304
|
return {
|
|
1246
1305
|
response: null,
|
|
1247
1306
|
pendingToolCalls: null,
|
package/src/agent/graph.test.js
CHANGED
|
@@ -372,17 +372,128 @@ test('Donna delegates the objective without choosing technical identifiers', asy
|
|
|
372
372
|
}],
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
|
+
const delegateResult = (messages ?? []).filter((message) => message.role === 'tool').at(-1);
|
|
376
|
+
const parsedDelegateResult = JSON.parse(String(delegateResult?.content ?? '{}'));
|
|
377
|
+
assert.equal(parsedDelegateResult.delegated, true);
|
|
378
|
+
assert.equal(parsedDelegateResult.runId, 'run-1');
|
|
379
|
+
assert.equal(parsedDelegateResult.summary.tasks, 5);
|
|
375
380
|
return { content: 'Plan validé.', message: { role: 'assistant', content: 'Plan validé.' }, tool_calls: null };
|
|
376
381
|
},
|
|
377
382
|
},
|
|
378
383
|
});
|
|
379
384
|
|
|
380
385
|
try {
|
|
381
|
-
await createAgentGraph().invoke({ input: 'ingère tout', session });
|
|
386
|
+
const result = await createAgentGraph().invoke({ input: 'ingère tout', session });
|
|
382
387
|
assert.match(request.url, /\/delegate/);
|
|
383
388
|
assert.deepEqual(request.body, { objective: 'Ingérer tous les fichiers en attente', workspace: 'docs' });
|
|
384
389
|
assert.equal('capability' in request.body, false);
|
|
385
390
|
assert.equal('operation' in request.body, false);
|
|
391
|
+
assert.equal(result.response, 'Plan validé.');
|
|
392
|
+
assert.doesNotMatch(result.response, /delegated|run-1|production/);
|
|
393
|
+
} finally {
|
|
394
|
+
globalThis.fetch = originalFetch;
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('Donna refuses to delegate connector authentication to an export capability', async () => {
|
|
399
|
+
const originalFetch = globalThis.fetch;
|
|
400
|
+
const fetchedUrls = [];
|
|
401
|
+
globalThis.fetch = async (url, options = {}) => {
|
|
402
|
+
fetchedUrls.push(String(url));
|
|
403
|
+
const body = JSON.parse(String(options.body ?? '{}'));
|
|
404
|
+
assert.equal(body.params?.name, 'start_google_auth');
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
status: 200,
|
|
408
|
+
headers: { get: () => null },
|
|
409
|
+
text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: 'ACTION REQUIRED: authorize at https://accounts.google.com/o/oauth2/auth?client_id=test&state=abc' }] } }),
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
let turn = 0;
|
|
413
|
+
const session = sessionBase({
|
|
414
|
+
runtime: { url: 'http://runtime.test' },
|
|
415
|
+
mcp: {
|
|
416
|
+
'google-workspace': {
|
|
417
|
+
status: 'connected',
|
|
418
|
+
url: 'http://google.test/mcp',
|
|
419
|
+
tools: [
|
|
420
|
+
{
|
|
421
|
+
name: 'start_google_auth',
|
|
422
|
+
description: 'Manually initiate Google OAuth authentication flow.',
|
|
423
|
+
inputSchema: { type: 'object', additionalProperties: true },
|
|
424
|
+
},
|
|
425
|
+
{ name: 'search_gmail_messages', inputSchema: { type: 'object', additionalProperties: true } },
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
llm: {
|
|
430
|
+
async completeWithTools() {
|
|
431
|
+
turn += 1;
|
|
432
|
+
if (turn === 1) return {
|
|
433
|
+
content: null,
|
|
434
|
+
message: { role: 'assistant', content: null },
|
|
435
|
+
tool_calls: [{ id: 'wrong-delegate', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"je veux configurer google"}' } }],
|
|
436
|
+
};
|
|
437
|
+
if (turn === 2) return {
|
|
438
|
+
content: null,
|
|
439
|
+
message: { role: 'assistant', content: null },
|
|
440
|
+
tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'google-workspace__start_google_auth', arguments: '{}' } }],
|
|
441
|
+
};
|
|
442
|
+
return {
|
|
443
|
+
content: 'J’ai lancé l’authentification. Ouvre le lien fourni.',
|
|
444
|
+
message: { role: 'assistant', content: 'J’ai lancé l’authentification. Ouvre le lien fourni.' },
|
|
445
|
+
tool_calls: null,
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const result = await createAgentGraph().invoke({ input: 'je veux configurer google', session });
|
|
453
|
+
assert.match(result.response, /J’ai lancé l’authentification/);
|
|
454
|
+
assert.match(result.response, /https:\/\/accounts\.google\.com\/o\/oauth2\/auth\?client_id=test&state=abc/);
|
|
455
|
+
assert.equal(fetchedUrls.some((url) => url.includes('runtime.test')), false);
|
|
456
|
+
assert.equal(fetchedUrls.some((url) => url.includes('google.test')), true);
|
|
457
|
+
} finally {
|
|
458
|
+
globalThis.fetch = originalFetch;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('Donna does not invent a setup tool when a connector advertises data tools only', async () => {
|
|
463
|
+
const originalFetch = globalThis.fetch;
|
|
464
|
+
globalThis.fetch = async () => assert.fail('neither runtime delegation nor an unrelated data tool should be called');
|
|
465
|
+
let turn = 0;
|
|
466
|
+
const session = sessionBase({
|
|
467
|
+
runtime: { url: 'http://runtime.test' },
|
|
468
|
+
mcp: {
|
|
469
|
+
acme: {
|
|
470
|
+
status: 'connected',
|
|
471
|
+
url: 'http://acme.test/mcp',
|
|
472
|
+
tools: [{ name: 'list_records', description: 'List records.', inputSchema: { type: 'object' } }],
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
llm: {
|
|
476
|
+
async completeWithTools({ messages }) {
|
|
477
|
+
turn += 1;
|
|
478
|
+
if (turn === 1) return {
|
|
479
|
+
content: null,
|
|
480
|
+
message: { role: 'assistant', content: null },
|
|
481
|
+
tool_calls: [{ id: 'wrong-delegate', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"configure acme"}' } }],
|
|
482
|
+
};
|
|
483
|
+
const refusal = (messages ?? []).filter((message) => message.role === 'tool').at(-1)?.content;
|
|
484
|
+
assert.match(String(refusal), /advertises no setup or authentication tool/);
|
|
485
|
+
return {
|
|
486
|
+
content: 'ACME doit être authentifié hors de cette interface.',
|
|
487
|
+
message: { role: 'assistant', content: 'ACME doit être authentifié hors de cette interface.' },
|
|
488
|
+
tool_calls: null,
|
|
489
|
+
};
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const result = await createAgentGraph().invoke({ input: 'configure acme', session });
|
|
496
|
+
assert.equal(result.response, 'ACME doit être authentifié hors de cette interface.');
|
|
386
497
|
} finally {
|
|
387
498
|
globalThis.fetch = originalFetch;
|
|
388
499
|
}
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -6,14 +6,14 @@ import { ensureManagerScaffold, loadManagerEnv } from '../core/env.js';
|
|
|
6
6
|
loadManagerEnv();
|
|
7
7
|
import { createAgentGraph } from '../agent/graph.js';
|
|
8
8
|
import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
|
|
9
|
-
import { runShell } from '../shell/repl.js';
|
|
9
|
+
import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
|
|
10
10
|
import { runChecks } from '../core/startupCheck.js';
|
|
11
11
|
import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
12
12
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
13
|
-
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
13
|
+
import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
|
|
14
14
|
import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
15
15
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
16
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
16
|
+
import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
17
17
|
import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
|
|
18
18
|
import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
|
|
19
19
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
@@ -57,6 +57,40 @@ function createSession() {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export function createInteractiveSession(context, { runtimeUrl, turnId, signal = null } = {}) {
|
|
61
|
+
const source = context.session;
|
|
62
|
+
const session = createSession();
|
|
63
|
+
for (const key of [
|
|
64
|
+
'workspace', 'workspacePath', 'workspaceEnvFile', 'workspaceEnv',
|
|
65
|
+
'wikirc', 'wikircConfig', 'language', 'llm', 'mcp', 'commands',
|
|
66
|
+
'packageJson', 'queueStore', 'systemPrompt',
|
|
67
|
+
]) {
|
|
68
|
+
if (source[key] !== undefined) session[key] = source[key];
|
|
69
|
+
}
|
|
70
|
+
session.runtime = runtimeUrl ? { url: runtimeUrl } : null;
|
|
71
|
+
session.headless = true;
|
|
72
|
+
session.chatMode = false;
|
|
73
|
+
session.chatAccess = null;
|
|
74
|
+
session.conversations = { [session.workspace || '__global__']: [] };
|
|
75
|
+
session.agentEvents = [];
|
|
76
|
+
session.activities = {};
|
|
77
|
+
session.productionActivity = null;
|
|
78
|
+
session.jobQueue = [];
|
|
79
|
+
session.headlessPlan = null;
|
|
80
|
+
session.turnId = turnId ?? null;
|
|
81
|
+
session._abortSignal = signal;
|
|
82
|
+
return session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function ensureInteractiveAssistantMessage(session, response, { turnId, workspace } = {}) {
|
|
86
|
+
const content = String(response ?? '').trim();
|
|
87
|
+
if (!content || session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
|
|
88
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
89
|
+
origin: 'runtime_turn', turnId, workspace, payload: { content: String(response) },
|
|
90
|
+
}));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
export async function forwardRuntimeApproval(getWorkspaceContext, request = {}) {
|
|
61
95
|
const context = await getWorkspaceContext(request.workspace ?? null);
|
|
62
96
|
return context.approvalManager?.approve(request) ?? { approved: false };
|
|
@@ -455,7 +489,7 @@ async function runRuntime(argv, agent) {
|
|
|
455
489
|
const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
|
|
456
490
|
const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
|
|
457
491
|
const { createApprovalManager } = await import('../runtime/approvals.js');
|
|
458
|
-
const { runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
|
|
492
|
+
const { conversationSeed, runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
|
|
459
493
|
|
|
460
494
|
const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1';
|
|
461
495
|
const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
|
|
@@ -465,6 +499,8 @@ async function runRuntime(argv, agent) {
|
|
|
465
499
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
466
500
|
throw new Error(`Invalid runtime port: ${port}`);
|
|
467
501
|
}
|
|
502
|
+
const selfRuntimeUrl = process.env.WIKI_MANAGER_RUNTIME_URL
|
|
503
|
+
?? `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
468
504
|
|
|
469
505
|
const store = openRuntimeStore({ stateDir });
|
|
470
506
|
let serverHandle = null;
|
|
@@ -480,6 +516,7 @@ async function runRuntime(argv, agent) {
|
|
|
480
516
|
session.headless = true;
|
|
481
517
|
session.chatMode = false;
|
|
482
518
|
session.packageJson = packageJson;
|
|
519
|
+
session.runtime = { url: selfRuntimeUrl };
|
|
483
520
|
|
|
484
521
|
if (requestedWorkspace) {
|
|
485
522
|
const result = await handleSlashCommand(`/use ${requestedWorkspace}`, { packageJson, session });
|
|
@@ -968,6 +1005,66 @@ async function runRuntime(argv, agent) {
|
|
|
968
1005
|
}
|
|
969
1006
|
}
|
|
970
1007
|
|
|
1008
|
+
async function executeInteractiveTurn(context, body, { signal, turnId } = {}) {
|
|
1009
|
+
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
1010
|
+
if (!input) throw new Error('Missing input.');
|
|
1011
|
+
const ephemeral = createInteractiveSession(context, { runtimeUrl: selfRuntimeUrl, turnId, signal });
|
|
1012
|
+
// Seed from a freshly reduced COPY of persisted events. Interactive turn
|
|
1013
|
+
// events deliberately do not mutate the canonical run projection, so the
|
|
1014
|
+
// canonical session alone is not a reliable conversation-history source.
|
|
1015
|
+
const persistedProjection = reduceAgentEvents(store.listEvents({
|
|
1016
|
+
workspace: context.workspace ?? ephemeral.workspace ?? null,
|
|
1017
|
+
}));
|
|
1018
|
+
const messages = conversationSeed({ agentProjection: persistedProjection }, input);
|
|
1019
|
+
ephemeral._onAgentEvent = (event) => {
|
|
1020
|
+
const interactiveEvent = {
|
|
1021
|
+
...event,
|
|
1022
|
+
origin: 'runtime_turn',
|
|
1023
|
+
turnId,
|
|
1024
|
+
runId: null,
|
|
1025
|
+
workspace: context.workspace ?? ephemeral.workspace ?? null,
|
|
1026
|
+
};
|
|
1027
|
+
store.persistEvent(interactiveEvent);
|
|
1028
|
+
serverHandle?.publish(interactiveEvent);
|
|
1029
|
+
};
|
|
1030
|
+
ephemeral._onStep = (message) => dispatchAgentEvent(ephemeral, createAgentEvent('runtime_log', {
|
|
1031
|
+
origin: 'runtime_turn',
|
|
1032
|
+
turnId,
|
|
1033
|
+
workspace: context.workspace ?? null,
|
|
1034
|
+
payload: { message },
|
|
1035
|
+
}));
|
|
1036
|
+
dispatchAgentEvent(ephemeral, createAgentEvent('user_message', {
|
|
1037
|
+
origin: 'runtime_turn',
|
|
1038
|
+
turnId,
|
|
1039
|
+
workspace: context.workspace ?? null,
|
|
1040
|
+
payload: { content: input },
|
|
1041
|
+
}));
|
|
1042
|
+
// Read-only chat turn: same chatAccess policy as the Shell UI's /chat, now
|
|
1043
|
+
// reachable over HTTP so `wiki serve` chat mode gets read tools without
|
|
1044
|
+
// duplicating the loop. Anything other than mode === 'chat' stays the full
|
|
1045
|
+
// unrestricted agent turn.
|
|
1046
|
+
const chatMode = String(body.mode ?? '').toLowerCase() === 'chat';
|
|
1047
|
+
let response;
|
|
1048
|
+
if (chatMode) {
|
|
1049
|
+
ephemeral.chatMode = true;
|
|
1050
|
+
ephemeral.chatAccess = readChatAccessConfig();
|
|
1051
|
+
const history = messages.length && messages[messages.length - 1]?.role === 'user'
|
|
1052
|
+
? messages.slice(0, -1)
|
|
1053
|
+
: messages;
|
|
1054
|
+
response = await runHeadlessChatTurn(ephemeral, input, {
|
|
1055
|
+
history,
|
|
1056
|
+
onStep: ephemeral._onStep,
|
|
1057
|
+
});
|
|
1058
|
+
} else {
|
|
1059
|
+
response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
|
|
1060
|
+
}
|
|
1061
|
+
ensureInteractiveAssistantMessage(ephemeral, response, {
|
|
1062
|
+
turnId,
|
|
1063
|
+
workspace: context.workspace ?? null,
|
|
1064
|
+
});
|
|
1065
|
+
return response;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
971
1068
|
serverHandle = await startRuntimeServer({
|
|
972
1069
|
host,
|
|
973
1070
|
port,
|
|
@@ -977,6 +1074,7 @@ async function runRuntime(argv, agent) {
|
|
|
977
1074
|
.filter((context) => context?.running)
|
|
978
1075
|
.map((context) => ({ workspace: context.workspace ?? null, runId: context.currentRunId ?? null })),
|
|
979
1076
|
run: executeRun,
|
|
1077
|
+
turn: executeInteractiveTurn,
|
|
980
1078
|
delegate: prepareDelegation,
|
|
981
1079
|
cancel: (context) => emitRuntimeLog(context.session, 'runtime: cancel requested'),
|
|
982
1080
|
resume: ({ workspace }) => recoverRuntime({ workspace, manual: true }),
|
package/src/commands/slash.js
CHANGED
|
@@ -232,8 +232,8 @@ function statLine(label, stat) {
|
|
|
232
232
|
return `${label}: ${stat.count} (${formatBytes(stat.totalBytes)})`;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
function
|
|
236
|
-
if (!stats) return 'No workspace loaded.';
|
|
235
|
+
function workspaceStatsColumns(stats) {
|
|
236
|
+
if (!stats) return { left: 'No workspace loaded.', right: '' };
|
|
237
237
|
const hints = [];
|
|
238
238
|
if (stats.untracked.count > 0) {
|
|
239
239
|
hints.push(`${stats.untracked.count} raw/untracked document(s) are waiting for ingest.`);
|
|
@@ -280,11 +280,10 @@ function workspaceStatsText(stats) {
|
|
|
280
280
|
]);
|
|
281
281
|
const hintsColumn = sectionBlock('Hints', hints.length > 0 ? hints : ['No immediate content action detected.']);
|
|
282
282
|
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
'',
|
|
286
|
-
|
|
287
|
-
].join('\n');
|
|
283
|
+
return {
|
|
284
|
+
left: [wikiColumn, deliveryColumn].join('\n\n'),
|
|
285
|
+
right: [rawColumn, internalColumn, hintsColumn].join('\n\n'),
|
|
286
|
+
};
|
|
288
287
|
}
|
|
289
288
|
|
|
290
289
|
function workspaceLoadedText(workspace, summary, session) {
|
|
@@ -530,16 +529,18 @@ async function statusText(session) {
|
|
|
530
529
|
const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
|
|
531
530
|
const mcpColumn = sectionBlock('MCP', formatMcpStatus(session.mcp).split('\n'));
|
|
532
531
|
const mcpToolsColumn = sectionBlock('MCP tool summary', formatMcpToolSummary(session.mcp).split('\n'));
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
532
|
+
const stats = workspaceStatsColumns(workspaceStats);
|
|
533
|
+
|
|
534
|
+
const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
|
|
535
|
+
const rightColumn = [configColumn, stats.right, mcpToolsColumn].filter(Boolean).join('\n\n');
|
|
536
|
+
|
|
537
|
+
// Leading/trailing padding row on *both* columns so the boxed pair doesn't
|
|
538
|
+
// butt directly against the pane border when the view is scrolled to show
|
|
539
|
+
// the tail. A single space on each side (not '') keeps the row tab-joined,
|
|
540
|
+
// so both the left and right box render — an empty string on either side
|
|
541
|
+
// of the tab makes twoColumns drop the pairing and only the left box shows.
|
|
542
|
+
const pad = ' \t ';
|
|
543
|
+
return [pad, twoColumns(leftColumn, rightColumn), pad].join('\n');
|
|
543
544
|
}
|
|
544
545
|
|
|
545
546
|
function loadWorkspaceSystemPrompt(workspacePath) {
|
package/src/core/buildInfo.json
CHANGED
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.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.8';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -29,6 +29,27 @@ function normalizeHeaders(headers) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// An endpoint's url/headers may reference `${VAR}` placeholders with no
|
|
33
|
+
// `:-default`. If that env var is unset, the placeholder interpolates to ''
|
|
34
|
+
// (see interpolateEnv) and the endpoint would otherwise look "configured"
|
|
35
|
+
// with a blank credential — then discoverMcpTools happily probes the live
|
|
36
|
+
// endpoint and can report it "connected" even though it has no real auth.
|
|
37
|
+
function hasMissingRequiredEnv(value) {
|
|
38
|
+
if (typeof value !== 'string') return false;
|
|
39
|
+
let missing = false;
|
|
40
|
+
value.replace(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
41
|
+
const sep = expr.indexOf(':-');
|
|
42
|
+
if (sep === -1 && !envValue(expr)) missing = true;
|
|
43
|
+
return '';
|
|
44
|
+
});
|
|
45
|
+
return missing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function endpointHasMissingCredentials(endpoint) {
|
|
49
|
+
const headerValues = Object.values(endpoint?.headers ?? {}).filter((value) => typeof value === 'string');
|
|
50
|
+
return [String(endpoint?.url ?? ''), ...headerValues].some(hasMissingRequiredEnv);
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
function normalizeExternalUrlForRuntime(url) {
|
|
33
54
|
if (process.env.WIKI_MANAGER_KEEP_DOCKER_HOST === '1') return url;
|
|
34
55
|
try {
|
|
@@ -57,8 +78,14 @@ export function readChatAccessConfig() {
|
|
|
57
78
|
if (!chatAccess || typeof chatAccess !== 'object' || Array.isArray(chatAccess)) return null;
|
|
58
79
|
const servers = {};
|
|
59
80
|
for (const [name, entry] of Object.entries(chatAccess.servers ?? {})) {
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
// "*" is also commonly written as a one-element array (["*"]) since every
|
|
82
|
+
// other "allow" example in this config is an array of tool names — treat
|
|
83
|
+
// both forms as the same wildcard rather than silently allowing nothing.
|
|
84
|
+
if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
|
|
85
|
+
servers[name] = { allow: '*' };
|
|
86
|
+
} else if (Array.isArray(entry?.allow)) {
|
|
87
|
+
servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
|
|
88
|
+
}
|
|
62
89
|
}
|
|
63
90
|
const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
|
|
64
91
|
? Math.floor(Number(chatAccess.maxToolIterations))
|
|
@@ -75,24 +102,27 @@ function readExternalMcpEndpoints() {
|
|
|
75
102
|
return Object.fromEntries(
|
|
76
103
|
Object.entries(servers)
|
|
77
104
|
.filter(([, endpoint]) => endpoint?.url)
|
|
78
|
-
.map(([name, endpoint]) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
.map(([name, endpoint]) => {
|
|
106
|
+
const missingCredentials = endpointHasMissingCredentials(endpoint);
|
|
107
|
+
return [
|
|
108
|
+
name,
|
|
109
|
+
{
|
|
110
|
+
...endpointStatus(!missingCredentials, missingCredentials ? 'credential not set' : ''),
|
|
111
|
+
url: normalizeExternalUrlForRuntime(interpolateEnv(String(endpoint.url))),
|
|
112
|
+
configuredUrl: interpolateEnv(String(endpoint.url)),
|
|
113
|
+
headers: normalizeHeaders(endpoint.headers),
|
|
114
|
+
// Tools the endpoint marks approval-gated: Donna may still call them
|
|
115
|
+
// directly (they are single-step tools), but toolRequiresApproval
|
|
116
|
+
// makes the call wait for the user's confirmation first (e.g. a
|
|
117
|
+
// destructive cme_export_run). Agent/operator owned — no hard-coded
|
|
118
|
+
// business name in the manager.
|
|
119
|
+
requireApproval: Array.isArray(endpoint.requireApproval)
|
|
120
|
+
? endpoint.requireApproval.map(String).filter(Boolean)
|
|
121
|
+
: undefined,
|
|
122
|
+
external: true,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}),
|
|
96
126
|
);
|
|
97
127
|
}
|
|
98
128
|
|
|
@@ -125,6 +155,14 @@ const DEFAULT_MCP_RETRY_POLICY = {
|
|
|
125
155
|
backoffMs: 500,
|
|
126
156
|
};
|
|
127
157
|
|
|
158
|
+
// MCP control traffic has its own budget. It must never consume or reduce the
|
|
159
|
+
// provider RPM configured in .wikirc, which is reserved for LLM/vector calls.
|
|
160
|
+
// Keep a little headroom below the commonly deployed 50 RPM MCP limit for
|
|
161
|
+
// initialize/list-tools and other non-tool-call requests.
|
|
162
|
+
const DEFAULT_MCP_REQUESTS_PER_MINUTE = 45;
|
|
163
|
+
const mcpThrottleQueues = new Map();
|
|
164
|
+
const mcpThrottleStarts = new Map();
|
|
165
|
+
|
|
128
166
|
export function buildMcpStatus(session) {
|
|
129
167
|
// Attach the /chat read-tool policy to the session alongside MCP status.
|
|
130
168
|
// Only /chat (repl.js) reads session.chatAccess; /agent ignores it.
|
|
@@ -304,6 +342,7 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
|
|
|
304
342
|
const timeoutMs = serverName === 'documents' && toolName === 'documents_convert_to_markdown' ? 600_000 : 8000;
|
|
305
343
|
const retry = resolveRetryPolicy(endpoint, toolName, options.retry);
|
|
306
344
|
return withRetry(async () => {
|
|
345
|
+
await throttleMcpRequestStart(endpoint, signal);
|
|
307
346
|
const payload = await mcpRequest(endpoint, 'tools/call', {
|
|
308
347
|
name: toolName,
|
|
309
348
|
arguments: toolArgs,
|
|
@@ -315,6 +354,47 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
|
|
|
315
354
|
}, retry, { signal, onRetry: options.onRetry });
|
|
316
355
|
}
|
|
317
356
|
|
|
357
|
+
async function throttleMcpRequestStart(endpoint, signal) {
|
|
358
|
+
const configured = Number(
|
|
359
|
+
endpoint.requestsPerMinute
|
|
360
|
+
?? endpoint.rateLimit?.requestsPerMinute
|
|
361
|
+
?? envValue('WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE'),
|
|
362
|
+
);
|
|
363
|
+
const requestsPerMinute = Number.isFinite(configured) && configured > 0
|
|
364
|
+
? Math.floor(configured)
|
|
365
|
+
: DEFAULT_MCP_REQUESTS_PER_MINUTE;
|
|
366
|
+
const configuredWindowMs = Number(envValue('WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS'));
|
|
367
|
+
const windowMs = Number.isFinite(configuredWindowMs) && configuredWindowMs > 0
|
|
368
|
+
? configuredWindowMs
|
|
369
|
+
: 60_000;
|
|
370
|
+
const key = String(endpoint.url ?? endpoint.name ?? 'mcp');
|
|
371
|
+
const previous = mcpThrottleQueues.get(key) ?? Promise.resolve();
|
|
372
|
+
const next = previous.catch(() => {}).then(async () => {
|
|
373
|
+
while (true) {
|
|
374
|
+
if (signal?.aborted) throw signal.reason ?? new Error('MCP request aborted.');
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
const starts = (mcpThrottleStarts.get(key) ?? []).filter((at) => now - at < windowMs);
|
|
377
|
+
if (starts.length < requestsPerMinute) {
|
|
378
|
+
starts.push(now);
|
|
379
|
+
mcpThrottleStarts.set(key, starts);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await retryDelay(Math.max(1, windowMs - (now - starts[0])), signal);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
mcpThrottleQueues.set(key, next);
|
|
386
|
+
try {
|
|
387
|
+
await next;
|
|
388
|
+
} finally {
|
|
389
|
+
if (mcpThrottleQueues.get(key) === next) mcpThrottleQueues.delete(key);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function resetMcpThrottleForTests() {
|
|
394
|
+
mcpThrottleQueues.clear();
|
|
395
|
+
mcpThrottleStarts.clear();
|
|
396
|
+
}
|
|
397
|
+
|
|
318
398
|
export function formatMcpToolResult(result) {
|
|
319
399
|
if (!result) return 'No result.';
|
|
320
400
|
const content = result.content;
|
package/src/core/mcp.test.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
callMcpTool,
|
|
9
9
|
discoverMcpTools,
|
|
10
10
|
formatMcpToolsForAgent,
|
|
11
|
+
resetMcpThrottleForTests,
|
|
11
12
|
resolveRetryPolicy,
|
|
12
13
|
resolveToolCallName,
|
|
13
14
|
truncateToolResult,
|
|
@@ -361,6 +362,44 @@ test('callMcpTool sends configured endpoint headers', async () => {
|
|
|
361
362
|
}
|
|
362
363
|
});
|
|
363
364
|
|
|
365
|
+
test('callMcpTool throttles MCP traffic independently per endpoint', async () => {
|
|
366
|
+
const originalFetch = globalThis.fetch;
|
|
367
|
+
const originalWindow = process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS;
|
|
368
|
+
const starts = [];
|
|
369
|
+
globalThis.fetch = async () => {
|
|
370
|
+
starts.push(Date.now());
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
status: 200,
|
|
374
|
+
headers: { get: () => null },
|
|
375
|
+
text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS = '30';
|
|
379
|
+
resetMcpThrottleForTests();
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const status = {
|
|
383
|
+
production: {
|
|
384
|
+
status: 'connected',
|
|
385
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
386
|
+
requestsPerMinute: 1,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
await Promise.all([
|
|
390
|
+
callMcpTool(status, 'production', 'agent_status', { jobId: 'a' }),
|
|
391
|
+
callMcpTool(status, 'production', 'agent_status', { jobId: 'b' }),
|
|
392
|
+
]);
|
|
393
|
+
assert.equal(starts.length, 2);
|
|
394
|
+
assert.ok(starts[1] - starts[0] >= 20, `expected throttling delay, got ${starts[1] - starts[0]}ms`);
|
|
395
|
+
} finally {
|
|
396
|
+
globalThis.fetch = originalFetch;
|
|
397
|
+
if (originalWindow == null) delete process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS;
|
|
398
|
+
else process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS = originalWindow;
|
|
399
|
+
resetMcpThrottleForTests();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
364
403
|
test('callMcpTool retries transient MCP failures', async () => {
|
|
365
404
|
const originalFetch = globalThis.fetch;
|
|
366
405
|
let attempts = 0;
|
|
@@ -528,4 +567,3 @@ test('truncateToolResult keeps short results intact and bounds long ones head+ta
|
|
|
528
567
|
assert.match(bounded, /-END$/);
|
|
529
568
|
assert.match(bounded, /caractères tronqués/);
|
|
530
569
|
});
|
|
531
|
-
|
package/src/runtime/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { createAgentEvent, dispatchAgentEvent, resetSessionProjection } from '../core/agentEvents.js';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
|
|
4
4
|
import { activeCacertPath } from '../core/cacert.js';
|
|
5
5
|
import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
|
|
6
6
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
@@ -15,6 +15,7 @@ export function startRuntimeServer({
|
|
|
15
15
|
session = null,
|
|
16
16
|
getContext,
|
|
17
17
|
run,
|
|
18
|
+
turn,
|
|
18
19
|
delegate,
|
|
19
20
|
cancel,
|
|
20
21
|
resume,
|
|
@@ -276,6 +277,77 @@ export function startRuntimeServer({
|
|
|
276
277
|
}
|
|
277
278
|
return;
|
|
278
279
|
}
|
|
280
|
+
if (request.method === 'POST' && url.pathname === '/turn') {
|
|
281
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
282
|
+
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
283
|
+
if (!input) {
|
|
284
|
+
sendJson(response, 400, { error: 'Missing input.' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// Read-only chat turns intentionally remain available while an agent
|
|
288
|
+
// run is active. Other interactive turns still become control
|
|
289
|
+
// messages so they cannot start a competing agent decision.
|
|
290
|
+
const readOnlyChat = String(body.mode ?? '').toLowerCase() === 'chat';
|
|
291
|
+
if (context.running && !readOnlyChat) {
|
|
292
|
+
const result = await handleControlMessage(context, store, input, {
|
|
293
|
+
intent: body.intent,
|
|
294
|
+
startNextControlRequest,
|
|
295
|
+
cancel,
|
|
296
|
+
approve,
|
|
297
|
+
});
|
|
298
|
+
sendJson(response, result.statusCode, result.body);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (typeof turn !== 'function') {
|
|
302
|
+
sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const turnId = `turn-${randomUUID()}`;
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
const previous = context.interactiveTurn ?? Promise.resolve();
|
|
308
|
+
const current = previous.catch(() => {}).then(async () => {
|
|
309
|
+
// A preceding serialized turn may have delegated and started a run
|
|
310
|
+
// after this request was accepted. Reclassify against the fresh
|
|
311
|
+
// state instead of starting another interactive decision in parallel.
|
|
312
|
+
if (context.running && !readOnlyChat) {
|
|
313
|
+
const result = await handleControlMessage(context, store, input, {
|
|
314
|
+
intent: body.intent,
|
|
315
|
+
startNextControlRequest,
|
|
316
|
+
cancel,
|
|
317
|
+
approve,
|
|
318
|
+
});
|
|
319
|
+
publish(createAgentEvent('assistant_message', {
|
|
320
|
+
origin: 'runtime_turn',
|
|
321
|
+
turnId,
|
|
322
|
+
workspace: context.workspace ?? null,
|
|
323
|
+
payload: { content: result.body?.explanation ?? 'Runtime control request processed.' },
|
|
324
|
+
}));
|
|
325
|
+
return result.body;
|
|
326
|
+
}
|
|
327
|
+
return turn(context, { ...body, input }, {
|
|
328
|
+
signal: controller.signal,
|
|
329
|
+
turnId,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
context.interactiveTurn = current;
|
|
333
|
+
void current.catch((err) => {
|
|
334
|
+
publish(createAgentEvent('assistant_message', {
|
|
335
|
+
origin: 'runtime_turn',
|
|
336
|
+
turnId,
|
|
337
|
+
workspace: context.workspace ?? null,
|
|
338
|
+
payload: { content: `Runtime turn failed: ${err instanceof Error ? err.message : String(err)}` },
|
|
339
|
+
}));
|
|
340
|
+
}).finally(() => {
|
|
341
|
+
if (context.interactiveTurn === current) context.interactiveTurn = null;
|
|
342
|
+
});
|
|
343
|
+
sendJson(response, 202, {
|
|
344
|
+
accepted: true,
|
|
345
|
+
kind: 'turn',
|
|
346
|
+
turnId,
|
|
347
|
+
workspace: context.workspace ?? null,
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
279
351
|
if (request.method === 'POST' && url.pathname === '/delegate') {
|
|
280
352
|
const { body, context } = await resolveBodyContext(request, url);
|
|
281
353
|
const objective = String(body.objective ?? '').trim();
|
|
@@ -527,10 +599,18 @@ function controlStatus(context, store) {
|
|
|
527
599
|
};
|
|
528
600
|
}
|
|
529
601
|
|
|
530
|
-
function runtimeState(context, store, { workspace = null, session = null } = {}) {
|
|
602
|
+
export function runtimeState(context, store, { workspace = null, session = null } = {}) {
|
|
531
603
|
const state = store.getState(context?.session ?? session ?? null, { workspace });
|
|
532
604
|
return {
|
|
533
605
|
...state,
|
|
606
|
+
// Interactive (runtime_turn) replies are persisted as events but never
|
|
607
|
+
// merged into the canonical in-memory projection, so a state built from
|
|
608
|
+
// that projection omits them — chat mode and conversational agent turns
|
|
609
|
+
// then show no reply at all. Rebuild the conversation from the full
|
|
610
|
+
// persisted event log (the same source interactive turns use to seed their
|
|
611
|
+
// own history) so those replies surface. The log is a superset of the
|
|
612
|
+
// canonical run conversation, so run rendering is unaffected.
|
|
613
|
+
conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
|
|
534
614
|
status: context?.running ? 'running' : state.status ?? 'idle',
|
|
535
615
|
running: Boolean(context?.running),
|
|
536
616
|
runId: context?.currentRunId ?? state.runId ?? null,
|
|
@@ -1,7 +1,80 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
-
import {
|
|
4
|
+
import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
|
|
5
|
+
import { runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
|
|
6
|
+
|
|
7
|
+
// Most server tests exercise endpoint behavior rather than authentication. Keep
|
|
8
|
+
// them independent from a developer's WIKI_MANAGER_RUNTIME_TOKEN environment;
|
|
9
|
+
// auth-specific tests can still override this default explicitly.
|
|
10
|
+
function startRuntimeServer(options) {
|
|
11
|
+
return startRuntimeServerImpl({ token: '', ...options });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('interactive runtime sessions isolate canonical run state', () => {
|
|
15
|
+
const mcp = { wiki: { status: 'connected' } };
|
|
16
|
+
const session = createInteractiveSession({ session: {
|
|
17
|
+
workspace: 'demo', workspacePath: '/workspace/demo', mcp,
|
|
18
|
+
llm: { invoke() {} }, commands: ['status'], packageJson: {}, queueStore: {},
|
|
19
|
+
_currentRunIdentity: { runId: 'run-1' }, headlessPlan: [{ id: 'task-1' }],
|
|
20
|
+
agentProjection: { status: 'running' }, _agentProjectionState: {},
|
|
21
|
+
controlQueue: [{}], planPatches: [{}], _requestApproval() {}, agents: [{}], agentRegistry: {},
|
|
22
|
+
} }, { runtimeUrl: 'http://127.0.0.1:7788', turnId: 'turn-1' });
|
|
23
|
+
|
|
24
|
+
assert.equal(session.mcp, mcp);
|
|
25
|
+
assert.deepEqual(session.runtime, { url: 'http://127.0.0.1:7788' });
|
|
26
|
+
assert.equal(session.headlessPlan, null);
|
|
27
|
+
assert.deepEqual(session.activities, {});
|
|
28
|
+
assert.deepEqual(session.jobQueue, []);
|
|
29
|
+
for (const key of [
|
|
30
|
+
'_currentRunIdentity', 'agentProjection', '_agentProjectionState', 'controlQueue',
|
|
31
|
+
'planPatches', '_requestApproval', 'agents', 'agentRegistry',
|
|
32
|
+
]) assert.equal(Object.hasOwn(session, key), false, `${key} must not leak`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('interactive turns publish a fallback assistant message exactly once', () => {
|
|
36
|
+
const published = [];
|
|
37
|
+
const session = { agentEvents: [], _onAgentEvent: (event) => published.push(event) };
|
|
38
|
+
assert.equal(ensureInteractiveAssistantMessage(session, 'Réponse concise.', {
|
|
39
|
+
turnId: 'turn-1', workspace: 'demo',
|
|
40
|
+
}), true);
|
|
41
|
+
assert.equal(ensureInteractiveAssistantMessage(session, 'Réponse dupliquée.', {
|
|
42
|
+
turnId: 'turn-1', workspace: 'demo',
|
|
43
|
+
}), false);
|
|
44
|
+
assert.equal(published.length, 1);
|
|
45
|
+
assert.equal(published[0].type, 'assistant_message');
|
|
46
|
+
assert.equal(published[0].origin, 'runtime_turn');
|
|
47
|
+
assert.equal(published[0].payload.content, 'Réponse concise.');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('runtime state rebuilds interactive conversation from persisted events', () => {
|
|
51
|
+
const user = {
|
|
52
|
+
...createAgentEvent('user_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Bonjour' } }),
|
|
53
|
+
origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo',
|
|
54
|
+
};
|
|
55
|
+
const assistant = {
|
|
56
|
+
...createAgentEvent('assistant_message', { origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo', payload: { content: 'Salut !' } }),
|
|
57
|
+
origin: 'runtime_turn', turnId: 'turn-1', workspace: 'demo',
|
|
58
|
+
};
|
|
59
|
+
const session = { agentProjection: { conversation: [] } };
|
|
60
|
+
const store = {
|
|
61
|
+
getState: (receivedSession) => {
|
|
62
|
+
assert.equal(receivedSession, session);
|
|
63
|
+
return { status: 'idle', conversation: [] };
|
|
64
|
+
},
|
|
65
|
+
listEvents: ({ workspace }) => {
|
|
66
|
+
assert.equal(workspace, 'demo');
|
|
67
|
+
return [user, assistant];
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const state = runtimeState({ workspace: 'demo', session, running: false }, store, { workspace: 'demo' });
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(state.conversation.map(({ role, content }) => ({ role, content })), [
|
|
74
|
+
{ role: 'user', content: 'Bonjour' },
|
|
75
|
+
{ role: 'assistant', content: 'Salut !' },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
5
78
|
|
|
6
79
|
test('runtime server checks bearer and x-runtime-token credentials', async (t) => {
|
|
7
80
|
let handle;
|
|
@@ -1397,6 +1470,94 @@ test('runtime server answers control messages posted to /run during an active ru
|
|
|
1397
1470
|
}
|
|
1398
1471
|
});
|
|
1399
1472
|
|
|
1473
|
+
test('runtime server accepts an interactive turn without starting a run', async (t) => {
|
|
1474
|
+
const context = { workspace: 'demo', session: {}, running: false };
|
|
1475
|
+
let received = null;
|
|
1476
|
+
let handle;
|
|
1477
|
+
try {
|
|
1478
|
+
handle = await startRuntimeServer({
|
|
1479
|
+
host: '127.0.0.1',
|
|
1480
|
+
port: 0,
|
|
1481
|
+
token: 'runtime-secret',
|
|
1482
|
+
store: {
|
|
1483
|
+
dbPath: ':memory:',
|
|
1484
|
+
getState: () => ({ status: 'idle' }),
|
|
1485
|
+
listEvents: () => [],
|
|
1486
|
+
},
|
|
1487
|
+
getContext: async () => context,
|
|
1488
|
+
run: async () => assert.fail('/turn must not start a runtime run'),
|
|
1489
|
+
turn: async (_context, body, meta) => { received = { body, meta }; },
|
|
1490
|
+
});
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
if (err?.code === 'EPERM') {
|
|
1493
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
throw err;
|
|
1497
|
+
}
|
|
1498
|
+
try {
|
|
1499
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn`, {
|
|
1500
|
+
method: 'POST',
|
|
1501
|
+
headers: { authorization: 'Bearer runtime-secret', 'content-type': 'application/json' },
|
|
1502
|
+
body: JSON.stringify({ input: 'Quels documents sont en attente ?', workspace: 'demo' }),
|
|
1503
|
+
});
|
|
1504
|
+
assert.equal(response.status, 202);
|
|
1505
|
+
const body = await response.json();
|
|
1506
|
+
assert.equal(body.kind, 'turn');
|
|
1507
|
+
assert.match(body.turnId, /^turn-/);
|
|
1508
|
+
await context.interactiveTurn;
|
|
1509
|
+
assert.equal(received.body.input, 'Quels documents sont en attente ?');
|
|
1510
|
+
assert.equal(received.meta.turnId, body.turnId);
|
|
1511
|
+
assert.equal(context.running, false);
|
|
1512
|
+
} finally {
|
|
1513
|
+
await handle.close();
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
test('runtime server keeps read-only chat turns available during an active run', async (t) => {
|
|
1518
|
+
const context = { workspace: 'demo', session: {}, running: true };
|
|
1519
|
+
let received = null;
|
|
1520
|
+
let handle;
|
|
1521
|
+
try {
|
|
1522
|
+
handle = await startRuntimeServer({
|
|
1523
|
+
host: '127.0.0.1',
|
|
1524
|
+
port: 0,
|
|
1525
|
+
token: 'runtime-secret',
|
|
1526
|
+
store: {
|
|
1527
|
+
dbPath: ':memory:',
|
|
1528
|
+
getState: () => ({ status: 'running' }),
|
|
1529
|
+
listEvents: () => [],
|
|
1530
|
+
},
|
|
1531
|
+
getContext: async () => context,
|
|
1532
|
+
run: async () => assert.fail('/turn must not start another runtime run'),
|
|
1533
|
+
turn: async (_context, body, meta) => { received = { body, meta }; },
|
|
1534
|
+
});
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
if (err?.code === 'EPERM') {
|
|
1537
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
throw err;
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn`, {
|
|
1544
|
+
method: 'POST',
|
|
1545
|
+
headers: { authorization: 'Bearer runtime-secret', 'content-type': 'application/json' },
|
|
1546
|
+
body: JSON.stringify({ input: 'Quel est le statut CME ?', mode: 'chat', workspace: 'demo' }),
|
|
1547
|
+
});
|
|
1548
|
+
assert.equal(response.status, 202);
|
|
1549
|
+
const body = await response.json();
|
|
1550
|
+
assert.equal(body.kind, 'turn');
|
|
1551
|
+
await context.interactiveTurn;
|
|
1552
|
+
assert.equal(received.body.mode, 'chat');
|
|
1553
|
+
assert.equal(received.body.input, 'Quel est le statut CME ?');
|
|
1554
|
+
assert.equal(received.meta.turnId, body.turnId);
|
|
1555
|
+
assert.equal(context.running, true);
|
|
1556
|
+
} finally {
|
|
1557
|
+
await handle.close();
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1400
1561
|
test('runtime health reports active runs across workspaces', async (t) => {
|
|
1401
1562
|
let handle;
|
|
1402
1563
|
try {
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -312,7 +312,7 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
|
|
|
312
312
|
function isStatusOutput(message: { role: string; content: string }) {
|
|
313
313
|
const content = String(message.content ?? '');
|
|
314
314
|
return message.role === 'command'
|
|
315
|
-
&& content.startsWith('Workspace')
|
|
315
|
+
&& content.trimStart().startsWith('Workspace')
|
|
316
316
|
&& content.includes('Config')
|
|
317
317
|
&& content.includes('MCP');
|
|
318
318
|
}
|
package/src/shell/repl.js
CHANGED
|
@@ -1279,6 +1279,35 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
|
|
|
1279
1279
|
return { exit: false };
|
|
1280
1280
|
}
|
|
1281
1281
|
|
|
1282
|
+
// Headless equivalent of runDirectChatTurn for HTTP callers (the runtime /turn
|
|
1283
|
+
// in chat mode). Reuses the exact same read-only policy — chatReadTools +
|
|
1284
|
+
// runChatReadToolLoop + buildDirectChatSystemPrompt — so there is no second
|
|
1285
|
+
// implementation of chat access; it just returns the final text instead of
|
|
1286
|
+
// driving a live repl bubble. The caller must have seeded session.chatAccess
|
|
1287
|
+
// (and session.mcp) so chatReadTools can resolve the allow-listed read tools.
|
|
1288
|
+
export async function runHeadlessChatTurn(session, input, { history = [], onStep } = {}) {
|
|
1289
|
+
const donnaMessage = { role: 'donna', content: '' };
|
|
1290
|
+
const readTools = chatReadTools(session);
|
|
1291
|
+
const canUseReadTools = readTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
|
|
1292
|
+
if (canUseReadTools) {
|
|
1293
|
+
await runChatReadToolLoop({ input, session, history, donnaMessage, onStep, readTools });
|
|
1294
|
+
return donnaMessage.content;
|
|
1295
|
+
}
|
|
1296
|
+
if (typeof session.llm?.stream === 'function') {
|
|
1297
|
+
let content = '';
|
|
1298
|
+
for await (const delta of session.llm.stream({
|
|
1299
|
+
system: buildDirectChatSystemPrompt(session),
|
|
1300
|
+
messages: [...history, { role: 'user', content: input }],
|
|
1301
|
+
signal: session._abortSignal,
|
|
1302
|
+
})) {
|
|
1303
|
+
const clean = stripDsmlArtifacts(delta);
|
|
1304
|
+
if (clean) content += clean;
|
|
1305
|
+
}
|
|
1306
|
+
return stripDsmlArtifacts(content).trimEnd() || formatLlmUnavailableMessage('flux vide');
|
|
1307
|
+
}
|
|
1308
|
+
return directChatUnavailableText(session);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1282
1311
|
function directChatUnavailableText(session) {
|
|
1283
1312
|
if (!session.workspacePath) {
|
|
1284
1313
|
return 'Direct chat unavailable: no workspace loaded. Use /use <workspace>.';
|
package/src/shell/repl.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
applyRuntimeStateToShellSession,
|
|
8
8
|
chatReadTools,
|
|
9
9
|
createSession,
|
|
10
|
+
runHeadlessChatTurn,
|
|
10
11
|
conversationMessages,
|
|
11
12
|
recordRuntimeUnavailableAgentInput,
|
|
12
13
|
runLine,
|
|
@@ -443,3 +444,36 @@ test('/chat falls back to the plain stream when no read tools are declared', asy
|
|
|
443
444
|
assert.match(last.content, /PLAIN_STREAM/);
|
|
444
445
|
assert.doesNotMatch(last.content, /SHOULD_NOT_APPEAR/);
|
|
445
446
|
});
|
|
447
|
+
|
|
448
|
+
test('runHeadlessChatTurn (HTTP /chat) uses the read-tool path and returns text', async () => {
|
|
449
|
+
const session = createSession();
|
|
450
|
+
session.chatMode = true;
|
|
451
|
+
session.chatAccess = { maxToolIterations: 4, servers: { cme: { allow: ['cme_status'] } } };
|
|
452
|
+
session.mcp = { cme: { status: 'connected', tools: [{ name: 'cme_status', inputSchema: { type: 'object', properties: {} } }] } };
|
|
453
|
+
let usedComplete = false;
|
|
454
|
+
session.llm = {
|
|
455
|
+
async *stream() { yield 'STREAM_FALLBACK'; },
|
|
456
|
+
async completeWithTools() {
|
|
457
|
+
usedComplete = true;
|
|
458
|
+
return { tool_calls: [], content: 'CME est configuré.', message: { role: 'assistant', content: 'CME est configuré.' } };
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
const reply = await runHeadlessChatTurn(session, 'le cme est-il configuré', { history: [] });
|
|
462
|
+
assert.ok(usedComplete, 'completeWithTools path was taken');
|
|
463
|
+
assert.match(reply, /CME est configuré/);
|
|
464
|
+
assert.doesNotMatch(reply, /STREAM_FALLBACK/);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('runHeadlessChatTurn falls back to the plain stream without read tools', async () => {
|
|
468
|
+
const session = createSession();
|
|
469
|
+
session.chatMode = true;
|
|
470
|
+
session.chatAccess = null;
|
|
471
|
+
session.mcp = {};
|
|
472
|
+
session.llm = {
|
|
473
|
+
async *stream() { yield 'PLAIN_STREAM'; },
|
|
474
|
+
async completeWithTools() { return { tool_calls: [], content: 'SHOULD_NOT_APPEAR' }; },
|
|
475
|
+
};
|
|
476
|
+
const reply = await runHeadlessChatTurn(session, 'bonjour', { history: [] });
|
|
477
|
+
assert.match(reply, /PLAIN_STREAM/);
|
|
478
|
+
assert.doesNotMatch(reply, /SHOULD_NOT_APPEAR/);
|
|
479
|
+
});
|