@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 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.6",
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",
@@ -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
- ? `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.`
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 to you right now — it is a tool you call directly, not a slash command or a missing primitive. It is the ONLY way to execute an action (ingest, build, export, configure, send…). Never tell the user that delegation or the runtime is unavailable while it is connected; call runtime__delegate instead.'
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 any requested action, call runtime__delegate with the user objective only. Never choose a capability, operation, agent, plan, or implementation yourself. The runtime resolves the registry and validates the provider plan before accepting. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
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
- return tool === 'wiki_workspace_status'
967
- || tool === 'agent_describe'
968
- || tool === 'agent_status'
969
- || /(?:^|_)(?:status|list|search|read|get)$/.test(tool);
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,
@@ -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
  }
@@ -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 }),
@@ -232,8 +232,8 @@ function statLine(label, stat) {
232
232
  return `${label}: ${stat.count} (${formatBytes(stat.totalBytes)})`;
233
233
  }
234
234
 
235
- function workspaceStatsText(stats) {
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
- twoColumns(wikiColumn, rawColumn),
285
- '',
286
- twoColumns(deliveryColumn, `${internalColumn}\n\n${hintsColumn}`),
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
- return [
535
- twoColumns(workspaceColumn, configColumn),
536
- '',
537
- workspaceStatsText(workspaceStats),
538
- '',
539
- runtimeColumn,
540
- '',
541
- twoColumns(mcpColumn, mcpToolsColumn),
542
- ].join('\n');
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) {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.6",
3
- "commit": "d1163a1"
2
+ "version": "0.14.8",
3
+ "commit": "d3995e0"
4
4
  }
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.6';
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
- if (entry?.allow === '*') servers[name] = { allow: '*' };
61
- else if (Array.isArray(entry?.allow)) servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
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
- name,
80
- {
81
- ...endpointStatus(true),
82
- url: normalizeExternalUrlForRuntime(interpolateEnv(String(endpoint.url))),
83
- configuredUrl: interpolateEnv(String(endpoint.url)),
84
- headers: normalizeHeaders(endpoint.headers),
85
- // Tools the endpoint marks approval-gated: Donna may still call them
86
- // directly (they are single-step tools), but toolRequiresApproval
87
- // makes the call wait for the user's confirmation first (e.g. a
88
- // destructive cme_export_run). Agent/operator owned — no hard-coded
89
- // business name in the manager.
90
- requireApproval: Array.isArray(endpoint.requireApproval)
91
- ? endpoint.requireApproval.map(String).filter(Boolean)
92
- : undefined,
93
- external: true,
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;
@@ -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
-
@@ -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 { startRuntimeServer } from './server.js';
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 {
@@ -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>.';
@@ -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
+ });