@dotdrelle/wiki-manager 0.14.5 → 0.14.7

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`.
@@ -29,6 +29,7 @@
29
29
  "chatAccess": {
30
30
  "maxToolIterations": 6,
31
31
  "servers": {
32
+ "wiki": { "allow": ["help_list", "help_read"] },
32
33
  "production": { "allow": ["production_job_status", "production_jobs_list"] },
33
34
  "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
34
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -711,7 +711,12 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
711
711
  if (!objective) return 'Delegation rejected: missing objective.';
712
712
  const result = await postRuntimeDelegate(objective, { url, workspace });
713
713
  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.`
714
+ ? JSON.stringify({
715
+ delegated: true,
716
+ runId: result.runId,
717
+ summary: result.delegation ?? null,
718
+ 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.`,
719
+ })
715
720
  : `Délégation refusée : ${result?.error ?? JSON.stringify(result)}`;
716
721
  }
717
722
  if (tool === 'enqueue') {
@@ -372,17 +372,24 @@ 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/);
386
393
  } finally {
387
394
  globalThis.fetch = originalFetch;
388
395
  }
@@ -13,7 +13,7 @@ import { listWikircProfiles } from '../core/wikirc.js';
13
13
  import { callMcpTool, formatMcpToolResult } 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,48 @@ 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
+ const response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1043
+ ensureInteractiveAssistantMessage(ephemeral, response, {
1044
+ turnId,
1045
+ workspace: context.workspace ?? null,
1046
+ });
1047
+ return response;
1048
+ }
1049
+
971
1050
  serverHandle = await startRuntimeServer({
972
1051
  host,
973
1052
  port,
@@ -977,6 +1056,7 @@ async function runRuntime(argv, agent) {
977
1056
  .filter((context) => context?.running)
978
1057
  .map((context) => ({ workspace: context.workspace ?? null, runId: context.currentRunId ?? null })),
979
1058
  run: executeRun,
1059
+ turn: executeInteractiveTurn,
980
1060
  delegate: prepareDelegation,
981
1061
  cancel: (context) => emitRuntimeLog(context.session, 'runtime: cancel requested'),
982
1062
  resume: ({ workspace }) => recoverRuntime({ workspace, manual: true }),
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.5",
3
- "commit": "cc90bd3"
2
+ "version": "0.14.7",
3
+ "commit": "f17765a"
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.5';
4
+ const WIKI_MANAGER_VERSION = '0.14.7';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -125,6 +125,14 @@ const DEFAULT_MCP_RETRY_POLICY = {
125
125
  backoffMs: 500,
126
126
  };
127
127
 
128
+ // MCP control traffic has its own budget. It must never consume or reduce the
129
+ // provider RPM configured in .wikirc, which is reserved for LLM/vector calls.
130
+ // Keep a little headroom below the commonly deployed 50 RPM MCP limit for
131
+ // initialize/list-tools and other non-tool-call requests.
132
+ const DEFAULT_MCP_REQUESTS_PER_MINUTE = 45;
133
+ const mcpThrottleQueues = new Map();
134
+ const mcpThrottleStarts = new Map();
135
+
128
136
  export function buildMcpStatus(session) {
129
137
  // Attach the /chat read-tool policy to the session alongside MCP status.
130
138
  // Only /chat (repl.js) reads session.chatAccess; /agent ignores it.
@@ -304,6 +312,7 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
304
312
  const timeoutMs = serverName === 'documents' && toolName === 'documents_convert_to_markdown' ? 600_000 : 8000;
305
313
  const retry = resolveRetryPolicy(endpoint, toolName, options.retry);
306
314
  return withRetry(async () => {
315
+ await throttleMcpRequestStart(endpoint, signal);
307
316
  const payload = await mcpRequest(endpoint, 'tools/call', {
308
317
  name: toolName,
309
318
  arguments: toolArgs,
@@ -315,6 +324,47 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
315
324
  }, retry, { signal, onRetry: options.onRetry });
316
325
  }
317
326
 
327
+ async function throttleMcpRequestStart(endpoint, signal) {
328
+ const configured = Number(
329
+ endpoint.requestsPerMinute
330
+ ?? endpoint.rateLimit?.requestsPerMinute
331
+ ?? envValue('WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE'),
332
+ );
333
+ const requestsPerMinute = Number.isFinite(configured) && configured > 0
334
+ ? Math.floor(configured)
335
+ : DEFAULT_MCP_REQUESTS_PER_MINUTE;
336
+ const configuredWindowMs = Number(envValue('WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS'));
337
+ const windowMs = Number.isFinite(configuredWindowMs) && configuredWindowMs > 0
338
+ ? configuredWindowMs
339
+ : 60_000;
340
+ const key = String(endpoint.url ?? endpoint.name ?? 'mcp');
341
+ const previous = mcpThrottleQueues.get(key) ?? Promise.resolve();
342
+ const next = previous.catch(() => {}).then(async () => {
343
+ while (true) {
344
+ if (signal?.aborted) throw signal.reason ?? new Error('MCP request aborted.');
345
+ const now = Date.now();
346
+ const starts = (mcpThrottleStarts.get(key) ?? []).filter((at) => now - at < windowMs);
347
+ if (starts.length < requestsPerMinute) {
348
+ starts.push(now);
349
+ mcpThrottleStarts.set(key, starts);
350
+ return;
351
+ }
352
+ await retryDelay(Math.max(1, windowMs - (now - starts[0])), signal);
353
+ }
354
+ });
355
+ mcpThrottleQueues.set(key, next);
356
+ try {
357
+ await next;
358
+ } finally {
359
+ if (mcpThrottleQueues.get(key) === next) mcpThrottleQueues.delete(key);
360
+ }
361
+ }
362
+
363
+ export function resetMcpThrottleForTests() {
364
+ mcpThrottleQueues.clear();
365
+ mcpThrottleStarts.clear();
366
+ }
367
+
318
368
  export function formatMcpToolResult(result) {
319
369
  if (!result) return 'No result.';
320
370
  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
-
@@ -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,73 @@ 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
+ if (context.running) {
288
+ const result = await handleControlMessage(context, store, input, {
289
+ intent: body.intent,
290
+ startNextControlRequest,
291
+ cancel,
292
+ approve,
293
+ });
294
+ sendJson(response, result.statusCode, result.body);
295
+ return;
296
+ }
297
+ if (typeof turn !== 'function') {
298
+ sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
299
+ return;
300
+ }
301
+ const turnId = `turn-${randomUUID()}`;
302
+ const controller = new AbortController();
303
+ const previous = context.interactiveTurn ?? Promise.resolve();
304
+ const current = previous.catch(() => {}).then(async () => {
305
+ // A preceding serialized turn may have delegated and started a run
306
+ // after this request was accepted. Reclassify against the fresh
307
+ // state instead of starting another interactive decision in parallel.
308
+ if (context.running) {
309
+ const result = await handleControlMessage(context, store, input, {
310
+ intent: body.intent,
311
+ startNextControlRequest,
312
+ cancel,
313
+ approve,
314
+ });
315
+ publish(createAgentEvent('assistant_message', {
316
+ origin: 'runtime_turn',
317
+ turnId,
318
+ workspace: context.workspace ?? null,
319
+ payload: { content: result.body?.explanation ?? 'Runtime control request processed.' },
320
+ }));
321
+ return result.body;
322
+ }
323
+ return turn(context, { ...body, input }, {
324
+ signal: controller.signal,
325
+ turnId,
326
+ });
327
+ });
328
+ context.interactiveTurn = current;
329
+ void current.catch((err) => {
330
+ publish(createAgentEvent('assistant_message', {
331
+ origin: 'runtime_turn',
332
+ turnId,
333
+ workspace: context.workspace ?? null,
334
+ payload: { content: `Runtime turn failed: ${err instanceof Error ? err.message : String(err)}` },
335
+ }));
336
+ }).finally(() => {
337
+ if (context.interactiveTurn === current) context.interactiveTurn = null;
338
+ });
339
+ sendJson(response, 202, {
340
+ accepted: true,
341
+ kind: 'turn',
342
+ turnId,
343
+ workspace: context.workspace ?? null,
344
+ });
345
+ return;
346
+ }
279
347
  if (request.method === 'POST' && url.pathname === '/delegate') {
280
348
  const { body, context } = await resolveBodyContext(request, url);
281
349
  const objective = String(body.objective ?? '').trim();
@@ -1,8 +1,45 @@
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 { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
4
5
  import { startRuntimeServer } from './server.js';
5
6
 
7
+ test('interactive runtime sessions isolate canonical run state', () => {
8
+ const mcp = { wiki: { status: 'connected' } };
9
+ const session = createInteractiveSession({ session: {
10
+ workspace: 'demo', workspacePath: '/workspace/demo', mcp,
11
+ llm: { invoke() {} }, commands: ['status'], packageJson: {}, queueStore: {},
12
+ _currentRunIdentity: { runId: 'run-1' }, headlessPlan: [{ id: 'task-1' }],
13
+ agentProjection: { status: 'running' }, _agentProjectionState: {},
14
+ controlQueue: [{}], planPatches: [{}], _requestApproval() {}, agents: [{}], agentRegistry: {},
15
+ } }, { runtimeUrl: 'http://127.0.0.1:7788', turnId: 'turn-1' });
16
+
17
+ assert.equal(session.mcp, mcp);
18
+ assert.deepEqual(session.runtime, { url: 'http://127.0.0.1:7788' });
19
+ assert.equal(session.headlessPlan, null);
20
+ assert.deepEqual(session.activities, {});
21
+ assert.deepEqual(session.jobQueue, []);
22
+ for (const key of [
23
+ '_currentRunIdentity', 'agentProjection', '_agentProjectionState', 'controlQueue',
24
+ 'planPatches', '_requestApproval', 'agents', 'agentRegistry',
25
+ ]) assert.equal(Object.hasOwn(session, key), false, `${key} must not leak`);
26
+ });
27
+
28
+ test('interactive turns publish a fallback assistant message exactly once', () => {
29
+ const published = [];
30
+ const session = { agentEvents: [], _onAgentEvent: (event) => published.push(event) };
31
+ assert.equal(ensureInteractiveAssistantMessage(session, 'Réponse concise.', {
32
+ turnId: 'turn-1', workspace: 'demo',
33
+ }), true);
34
+ assert.equal(ensureInteractiveAssistantMessage(session, 'Réponse dupliquée.', {
35
+ turnId: 'turn-1', workspace: 'demo',
36
+ }), false);
37
+ assert.equal(published.length, 1);
38
+ assert.equal(published[0].type, 'assistant_message');
39
+ assert.equal(published[0].origin, 'runtime_turn');
40
+ assert.equal(published[0].payload.content, 'Réponse concise.');
41
+ });
42
+
6
43
  test('runtime server checks bearer and x-runtime-token credentials', async (t) => {
7
44
  let handle;
8
45
  try {
@@ -1397,6 +1434,50 @@ test('runtime server answers control messages posted to /run during an active ru
1397
1434
  }
1398
1435
  });
1399
1436
 
1437
+ test('runtime server accepts an interactive turn without starting a run', async (t) => {
1438
+ const context = { workspace: 'demo', session: {}, running: false };
1439
+ let received = null;
1440
+ let handle;
1441
+ try {
1442
+ handle = await startRuntimeServer({
1443
+ host: '127.0.0.1',
1444
+ port: 0,
1445
+ token: 'runtime-secret',
1446
+ store: {
1447
+ dbPath: ':memory:',
1448
+ getState: () => ({ status: 'idle' }),
1449
+ listEvents: () => [],
1450
+ },
1451
+ getContext: async () => context,
1452
+ run: async () => assert.fail('/turn must not start a runtime run'),
1453
+ turn: async (_context, body, meta) => { received = { body, meta }; },
1454
+ });
1455
+ } catch (err) {
1456
+ if (err?.code === 'EPERM') {
1457
+ t.skip('network listen is not permitted in this sandbox');
1458
+ return;
1459
+ }
1460
+ throw err;
1461
+ }
1462
+ try {
1463
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn`, {
1464
+ method: 'POST',
1465
+ headers: { authorization: 'Bearer runtime-secret', 'content-type': 'application/json' },
1466
+ body: JSON.stringify({ input: 'Quels documents sont en attente ?', workspace: 'demo' }),
1467
+ });
1468
+ assert.equal(response.status, 202);
1469
+ const body = await response.json();
1470
+ assert.equal(body.kind, 'turn');
1471
+ assert.match(body.turnId, /^turn-/);
1472
+ await context.interactiveTurn;
1473
+ assert.equal(received.body.input, 'Quels documents sont en attente ?');
1474
+ assert.equal(received.meta.turnId, body.turnId);
1475
+ assert.equal(context.running, false);
1476
+ } finally {
1477
+ await handle.close();
1478
+ }
1479
+ });
1480
+
1400
1481
  test('runtime health reports active runs across workspaces', async (t) => {
1401
1482
  let handle;
1402
1483
  try {