@dotdrelle/wiki-manager 0.12.0 → 0.12.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -5,7 +5,7 @@ import {
5
5
  callMcpTool,
6
6
  formatMcpToolResult,
7
7
  formatMcpToolsForAgent,
8
- parseToolCallName,
8
+ resolveToolCallName,
9
9
  } from '../core/mcp.js';
10
10
  import { formatSkillsForAgent, readOptionalText } from '../core/skills.js';
11
11
  import { handleSlashCommand } from '../commands/slash.js';
@@ -18,6 +18,14 @@ const MAX_TOOL_ITERATIONS = 80;
18
18
  const MAX_SPINNER_ARG_LENGTH = 96;
19
19
  const MAX_PROFILE_CHARS = 4000;
20
20
 
21
+ // Pseudo-servers handled directly by the tool executor (not present in
22
+ // session.mcp). Listed so unqualified names like "plan_set" resolve the same
23
+ // way as MCP tools in resolveToolCallName.
24
+ const INTERNAL_TOOL_SERVERS = {
25
+ wiki: ['plan_set', 'plan_done'],
26
+ shell: ['run_command', 'read_command', 'profile_update'],
27
+ };
28
+
21
29
  const AGENT_SLASH_COMMANDS = new Set([
22
30
  'help',
23
31
  'version',
@@ -562,11 +570,11 @@ export function buildAgentSystemPrompt(state) {
562
570
  ].filter(Boolean).join('\n'),
563
571
  'For service actions, recommend only available service primitives from Available primitives, with the exact service name when the primitive supports one.',
564
572
  'Disambiguate export requests carefully.',
565
- 'Confluence/CME/source export means exporting external Confluence sources into raw/untracked: use cme MCP tools (`cme_export_run`, then `cme_export_status`). Never use production `type=export` for Confluence source export.',
566
- 'Wiki/deliverable/publication export means exporting generated deliverables from the wiki: use production MCP tools (`production_start_job` with `type:"export"` or pipeline steps). Require the deliverable path when exporting deliverables.',
567
- 'For ingest/build/export/polish/pipeline workflows, use production MCP tools. Do not route these through direct /wiki shortcuts. To chain multiple sequential steps (e.g. build then polish), always use a single production_start_job call with type="pipeline" and steps=["build","polish"] — never start them as separate jobs: the first job is asynchronous and the second would run before it completes. For existing deliverables where content stability matters, pass stabilize:true so the build step preserves unchanged sections; keep polish in the pipeline when publication output is requested. Do not ask the user to confirm between steps; start the pipeline call directly.',
573
+ 'Confluence/CME/source export means exporting external Confluence sources into raw/untracked: use cme MCP tools (`cme__cme_export_run`, then `cme__cme_export_status`). Never use production `type=export` for Confluence source export.',
574
+ 'Wiki/deliverable/publication export means exporting generated deliverables from the wiki: use production MCP tools (`production__production_start_job` with `type:"export"` or pipeline steps). Require the deliverable path when exporting deliverables.',
575
+ 'For ingest/build/export/polish/pipeline workflows, use production MCP tools. Do not route these through direct /wiki shortcuts. To chain multiple sequential steps (e.g. build then polish), always use a single production__production_start_job call with type="pipeline" and steps=["build","polish"] — never start them as separate jobs: the first job is asynchronous and the second would run before it completes. For existing deliverables where content stability matters, pass stabilize:true so the build step preserves unchanged sections; keep polish in the pipeline when publication output is requested. Do not ask the user to confirm between steps; start the pipeline call directly.',
568
576
  'Long-running MCP jobs: do not call the same status tool more than once consecutively. When chaining jobs sequentially: (1) start the job, report job/activity id and status; (2) check status once — if done, proceed to the next step immediately; (3) if still running, report status, list the remaining steps, and return control; (4) when re-invoked, check status first, then proceed. Do not spin-poll (status → status → status with no new action between). The shell activity panel monitors non-terminal jobs automatically.',
569
- 'If production_start_job is returned as queued/waiting by the manager, report that it is waiting in the local queue and return control. Do not continue as if the production job has started.',
577
+ 'If production__production_start_job is returned as queued/waiting by the manager, report that it is waiting in the local queue and return control. Do not continue as if the production job has started.',
570
578
  'For diagnostics, use /wiki run doctor when the user asks for doctor. Use /workspace init <name> [path] for low-level non-interactive workspace creation. In the interactive TUI, /new <name> opens the setup wizard. Use /wiki for index, or /wiki run index through the explicit backup hatch. Use /wiki run init only for explicit current-workspace llm-wiki init.',
571
579
  'If an action requires tools or skills not available yet, explain the limitation and name the expected primitive.',
572
580
  workspaceProfile
@@ -771,11 +779,19 @@ export function createAgentGraph(options = {}) {
771
779
  const toolResultMessages = [];
772
780
 
773
781
  for (const call of toolCalls) {
774
- const { server, tool } = parseToolCallName(call.function.name);
782
+ const resolved = resolveToolCallName(state.session.mcp, call.function.name, INTERNAL_TOOL_SERVERS);
783
+ const { server, tool } = resolved;
775
784
  const argsSummary = summarizeToolArguments(call.function.arguments);
776
785
  const isInternalWikiTool = server === 'wiki' && (tool === 'plan_set' || tool === 'plan_done');
777
786
  const serverLabel = server === 'shell' ? 'Shell' : isInternalWikiTool ? 'Plan' : 'MCP';
778
- const toolName = `${server}.${tool}`;
787
+ const toolName = server ? `${server}.${tool}` : call.function.name;
788
+ if (resolved.normalized) {
789
+ // Keep normalizations visible: the defensive routing must not hide
790
+ // prompt/skill regressions that reintroduce unqualified names.
791
+ state.session._onStep?.(
792
+ `tool name normalized: ${call.function.name} -> ${server}__${tool}`,
793
+ );
794
+ }
779
795
  state.session._onStep?.(
780
796
  `[${state.toolIterations}/${MAX_TOOL_ITERATIONS}] ${serverLabel} ${toolName}${argsSummary ? ` (${argsSummary})` : ''}`,
781
797
  );
@@ -796,6 +812,18 @@ export function createAgentGraph(options = {}) {
796
812
  let resultText;
797
813
  let ok = true;
798
814
  try {
815
+ if (!server) {
816
+ if (resolved.candidates.length > 1) {
817
+ throw new Error(
818
+ `Ambiguous unqualified tool name "${call.function.name}": several connected servers expose it. `
819
+ + `Use the <server>__<tool> form: ${resolved.candidates.map((s) => `${s}__${tool}`).join(', ')}.`,
820
+ );
821
+ }
822
+ throw new Error(
823
+ `Unqualified tool call name "${call.function.name}". Tool calls must use the <server>__<tool> `
824
+ + `naming convention (e.g. production__production_start_job); no connected server exposes a tool named "${tool}".`,
825
+ );
826
+ }
799
827
  let args = JSON.parse(call.function.arguments ?? '{}');
800
828
  if (server === 'production' && tool === 'production_start_job' && state.session.workspace && !args.callerLabel) {
801
829
  args = { ...args, callerLabel: `${state.session.workspace}/wiki-manager` };
@@ -869,7 +897,7 @@ export function createAgentGraph(options = {}) {
869
897
  err.name === 'ApprovalError'
870
898
  ) throw err;
871
899
  ok = false;
872
- resultText = `Error [${server}.${tool}]: ${err instanceof Error ? err.message : String(err)}`;
900
+ resultText = `Error [${toolName}]: ${err instanceof Error ? err.message : String(err)}`;
873
901
  if (minimalPlanActive && state.session.headlessPlan?.[0]?._activityKey === null) {
874
902
  emitAgentEvent(state.session, 'plan_step_updated', 'tool', { step: 1, status: 'failed' });
875
903
  }
@@ -387,3 +387,50 @@ test('buildAgentSystemPrompt forbids inventing slash commands or arguments', ()
387
387
  assert.doesNotMatch(prompt, /executorQuery/);
388
388
  assert.doesNotMatch(prompt, /executor:"/);
389
389
  });
390
+
391
+ // Guard: the system prompt must never show a connected tool's bare name
392
+ // outside its qualified server__tool form. Bare mentions are what teach the
393
+ // model to emit unqualified tool calls (the cme_status incident). The bare
394
+ // name list comes from the session's declared servers, never from a manual
395
+ // list (amendment A6). New prompt text or injected skill descriptions that
396
+ // reintroduce a bare name must fail here.
397
+ test('buildAgentSystemPrompt contains no unqualified tool names for connected servers', () => {
398
+ const session = sessionBase({
399
+ mcp: {
400
+ production: {
401
+ status: 'connected',
402
+ tools: [
403
+ { name: 'production_start_job' }, { name: 'production_job_status' },
404
+ { name: 'production_job_logs' }, { name: 'production_cancel_job' },
405
+ { name: 'production_list_jobs' }, { name: 'production_list_templates' },
406
+ { name: 'production_status' }, { name: 'agent_describe' },
407
+ { name: 'agent_plan' }, { name: 'agent_execute' },
408
+ { name: 'agent_status' }, { name: 'agent_cancel' },
409
+ ],
410
+ },
411
+ cme: {
412
+ status: 'connected',
413
+ tools: [
414
+ { name: 'cme_status' }, { name: 'cme_setup' },
415
+ { name: 'cme_sources_list' }, { name: 'cme_source_add' },
416
+ { name: 'cme_source_remove' }, { name: 'cme_export_run' },
417
+ { name: 'cme_export_status' }, { name: 'cme_export_cancel' },
418
+ { name: 'agent_describe' }, { name: 'agent_execute' },
419
+ { name: 'agent_status' }, { name: 'agent_cancel' },
420
+ ],
421
+ },
422
+ },
423
+ });
424
+ const prompt = buildAgentSystemPrompt({ session });
425
+ const offenders = [];
426
+ for (const [serverName, value] of Object.entries(session.mcp)) {
427
+ for (const tool of value.tools) {
428
+ // A bare occurrence is the tool name not embedded in a wider
429
+ // identifier: `production__production_start_job` does not match
430
+ // because the inner occurrence is preceded by `_`.
431
+ const bare = new RegExp(`(?<![\\w])${tool.name}(?![\\w])`);
432
+ if (bare.test(prompt)) offenders.push(`${serverName}:${tool.name}`);
433
+ }
434
+ }
435
+ assert.deepEqual(offenders, [], `Unqualified tool names found in system prompt: ${offenders.join(', ')}`);
436
+ });
@@ -50,7 +50,7 @@ test('agent event contract carries the unified audit identity fields', () => {
50
50
  runId: 'run-1',
51
51
  turnId: 'turn-1',
52
52
  taskId: 'task-1',
53
- workspace: 'juno',
53
+ workspace: 'acme',
54
54
  payload: { toolCallId: 'call-1', ok: true },
55
55
  };
56
56
 
@@ -58,7 +58,7 @@ test('agent event contract carries the unified audit identity fields', () => {
58
58
  });
59
59
 
60
60
  test('run and control request contracts reject empty run input but accept explicit controls', () => {
61
- assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: 'juno' }).ok, true);
61
+ assert.equal(validateContract('runRequest', { input: 'Build docs', workspace: 'acme' }).ok, true);
62
62
  assert.equal(validateContract('runRequest', { input: '' }).ok, false);
63
63
  assert.equal(validateContract('controlMessage', { action: 'message', input: 'Ou en est le build ?', intent: 'observe' }).ok, true);
64
64
  });
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.12.0';
4
+ const WIKI_MANAGER_VERSION = '0.12.1';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -174,7 +174,7 @@ function clarifyToolDescription(serverName, toolName, description) {
174
174
  if (serverName === 'production' && toolName === 'production_start_job') {
175
175
  return compactDescription([
176
176
  base,
177
- 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme_export_run instead.',
177
+ 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme__cme_export_run instead.',
178
178
  ].filter(Boolean).join(' '));
179
179
  }
180
180
  return base;
@@ -469,7 +469,9 @@ export function formatMcpToolsForAgent(mcpStatus) {
469
469
  sections.push(`${name}: connected, tools not discovered yet`);
470
470
  continue;
471
471
  }
472
- sections.push(`${name}: ${tools.map((tool) => tool.name).join(', ')}`);
472
+ // Always advertise the qualified call name (server__tool): showing bare
473
+ // tool names here is what teaches the model to emit unqualified calls.
474
+ sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
473
475
  }
474
476
  return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
475
477
  }
@@ -498,6 +500,31 @@ export function parseToolCallName(name) {
498
500
  return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
499
501
  }
500
502
 
503
+ // Deterministic recovery for unqualified tool-call names emitted by the LLM
504
+ // (e.g. "cme_status" instead of "cme__cme_status"). Exact-name match only:
505
+ // if exactly one connected server (or extra pseudo-server) exposes the bare
506
+ // tool name, route to it and report `normalized: true`; otherwise return
507
+ // `server: null` with the list of candidate servers so the caller can raise
508
+ // an explicit error. This is name normalization, never fuzzy matching — do
509
+ // not extend it to description/similarity-based selection (plan directeur
510
+ // §20 forbids that).
511
+ export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
512
+ const parsed = parseToolCallName(name);
513
+ if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
514
+ const candidates = [];
515
+ for (const [serverName, toolNames] of Object.entries(extraServers)) {
516
+ if (toolNames.includes(parsed.tool)) candidates.push(serverName);
517
+ }
518
+ for (const [serverName, value] of Object.entries(mcpStatus ?? {})) {
519
+ if (value.status !== 'connected') continue;
520
+ if ((value.tools ?? []).some((tool) => tool.name === parsed.tool)) candidates.push(serverName);
521
+ }
522
+ if (candidates.length === 1) {
523
+ return { server: candidates[0], tool: parsed.tool, normalized: true, candidates };
524
+ }
525
+ return { server: null, tool: parsed.tool, normalized: false, candidates };
526
+ }
527
+
501
528
  export function mcpStatusMarker(status) {
502
529
  if (status === 'connected') return '●';
503
530
  if (status === 'configured') return '◐';
@@ -3,7 +3,75 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { buildMcpStatus, callMcpTool, discoverMcpTools, resolveRetryPolicy } from './mcp.js';
6
+ import {
7
+ buildMcpStatus,
8
+ callMcpTool,
9
+ discoverMcpTools,
10
+ formatMcpToolsForAgent,
11
+ resolveRetryPolicy,
12
+ resolveToolCallName,
13
+ } from './mcp.js';
14
+
15
+ const resolveFixtureStatus = {
16
+ production: {
17
+ status: 'connected',
18
+ tools: [{ name: 'production_start_job' }, { name: 'agent_status' }],
19
+ },
20
+ cme: {
21
+ status: 'connected',
22
+ tools: [{ name: 'cme_status' }, { name: 'agent_status' }],
23
+ },
24
+ documents: {
25
+ status: 'configured', // not connected: must never be a candidate
26
+ tools: [{ name: 'cme_status' }],
27
+ },
28
+ };
29
+
30
+ test('resolveToolCallName passes qualified names through untouched', () => {
31
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'cme__cme_status');
32
+ assert.deepEqual(
33
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
34
+ { server: 'cme', tool: 'cme_status', normalized: false },
35
+ );
36
+ });
37
+
38
+ test('resolveToolCallName normalizes a bare name with exactly one connected match', () => {
39
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'cme_status');
40
+ assert.deepEqual(
41
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
42
+ { server: 'cme', tool: 'cme_status', normalized: true },
43
+ );
44
+ });
45
+
46
+ test('resolveToolCallName refuses ambiguous bare names and reports candidates', () => {
47
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'agent_status');
48
+ assert.equal(resolved.server, null);
49
+ assert.equal(resolved.normalized, false);
50
+ assert.deepEqual([...resolved.candidates].sort(), ['cme', 'production']);
51
+ });
52
+
53
+ test('resolveToolCallName returns no server for unknown bare names', () => {
54
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'does_not_exist');
55
+ assert.equal(resolved.server, null);
56
+ assert.deepEqual(resolved.candidates, []);
57
+ });
58
+
59
+ test('resolveToolCallName resolves internal pseudo-server tools via extraServers', () => {
60
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'plan_set', { wiki: ['plan_set', 'plan_done'] });
61
+ assert.deepEqual(
62
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
63
+ { server: 'wiki', tool: 'plan_set', normalized: true },
64
+ );
65
+ });
66
+
67
+ test('formatMcpToolsForAgent advertises qualified server__tool names only', () => {
68
+ const listing = formatMcpToolsForAgent(resolveFixtureStatus);
69
+ assert.match(listing, /cme__cme_status/);
70
+ assert.match(listing, /production__production_start_job/);
71
+ // No bare tool name outside a qualified form.
72
+ assert.doesNotMatch(listing, /(?<![\w])cme_status(?![\w])/);
73
+ assert.doesNotMatch(listing, /(?<![\w])production_start_job(?![\w])/);
74
+ });
7
75
 
8
76
  test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
9
77
  const originalCwd = process.cwd();
@@ -0,0 +1,43 @@
1
+ // Deterministic, localized messages for the runtime control lane.
2
+ //
3
+ // Control-lane acknowledgements (run queued, ambiguous input, conversation
4
+ // fallback…) are intentionally NOT generated by Donna: spending an LLM turn
5
+ // to say "your request is queued" would reintroduce exactly the per-message
6
+ // cost the orchestration refactor removed. But they are user-facing, so they
7
+ // must follow the session's configured reply language. This catalog is the
8
+ // single source for those strings — never hardcode a control-lane message in
9
+ // the shell or the server directly.
10
+
11
+ const CONTROL_MESSAGES = {
12
+ queued_for_future_run: {
13
+ en: 'Request added to the queue — it will start automatically after the current run.',
14
+ fr: 'Demande ajoutée à la file — elle démarrera automatiquement à la fin du run en cours.',
15
+ },
16
+ plan_patch_proposed: {
17
+ en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
18
+ fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
19
+ },
20
+ ambiguous_control: {
21
+ en: 'The runtime cannot safely classify this message.',
22
+ fr: 'Le runtime ne peut pas classer ce message de façon sûre.',
23
+ },
24
+ converse_while_running: {
25
+ en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
26
+ fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
27
+ },
28
+ converse_while_idle: {
29
+ en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
30
+ fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
31
+ },
32
+ };
33
+
34
+ export function controlLanguage(session) {
35
+ const raw = String(session?.language ?? 'en').toLowerCase();
36
+ return raw.startsWith('fr') ? 'fr' : 'en';
37
+ }
38
+
39
+ export function controlMessage(session, key) {
40
+ const entry = CONTROL_MESSAGES[key];
41
+ if (!entry) throw new Error(`Unknown control message key: ${key}`);
42
+ return entry[controlLanguage(session)] ?? entry.en;
43
+ }
@@ -0,0 +1,21 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { controlLanguage, controlMessage } from './controlMessages.js';
4
+
5
+ test('controlLanguage maps fr locales to fr and everything else to en', () => {
6
+ assert.equal(controlLanguage({ language: 'fr-FR' }), 'fr');
7
+ assert.equal(controlLanguage({ language: 'fr' }), 'fr');
8
+ assert.equal(controlLanguage({ language: 'en-US' }), 'en');
9
+ assert.equal(controlLanguage({ language: null }), 'en');
10
+ assert.equal(controlLanguage(null), 'en');
11
+ });
12
+
13
+ test('controlMessage returns the localized queued acknowledgement', () => {
14
+ assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /ajoutée à la file/);
15
+ assert.match(controlMessage({ language: 'en-US' }, 'queued_for_future_run'), /added to the queue/);
16
+ });
17
+
18
+ test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
19
+ assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
20
+ assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
21
+ });
@@ -531,7 +531,7 @@ function toolResult(payload) {
531
531
  }
532
532
 
533
533
  test('Recipe #7 — "où en es-tu ?" during an active run: status in conversation, run continues, no new run', async (t) => {
534
- const session = { workspace: 'juno', controlQueue: [] };
534
+ const session = { workspace: 'acme', controlQueue: [] };
535
535
  let runCount = 0;
536
536
  let handle;
537
537
  try {
@@ -550,7 +550,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
550
550
  listEvents: () => [],
551
551
  },
552
552
  getContext: async () => ({
553
- workspace: 'juno',
553
+ workspace: 'acme',
554
554
  session,
555
555
  running: true,
556
556
  currentAbortController: new AbortController(),
@@ -566,7 +566,7 @@ test('Recipe #7 — "où en es-tu ?" during an active run: status in conversatio
566
566
  }
567
567
 
568
568
  try {
569
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
569
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
570
570
  method: 'POST',
571
571
  headers: { 'Content-Type': 'application/json' },
572
572
  body: JSON.stringify({ action: 'message', input: 'où en es-tu ?' }),
@@ -5,6 +5,7 @@ import { activeCacertPath } from '../core/cacert.js';
5
5
  import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
7
7
  import { runtimeTokenFromEnv } from './auth.js';
8
+ import { controlMessage } from './controlMessages.js';
8
9
 
9
10
  export function startRuntimeServer({
10
11
  host = '127.0.0.1',
@@ -248,7 +249,7 @@ export function startRuntimeServer({
248
249
  kind: 'enqueue_run',
249
250
  item,
250
251
  ...controlStatus(context, store),
251
- explanation: 'Request queued for a future run.',
252
+ explanation: controlMessage(context?.session, 'queued_for_future_run'),
252
253
  });
253
254
  return;
254
255
  }
@@ -522,7 +523,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
522
523
  classification,
523
524
  proposal,
524
525
  ...controlStatus(context, store),
525
- explanation: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
526
+ explanation: controlMessage(context?.session, 'plan_patch_proposed'),
526
527
  },
527
528
  };
528
529
  }
@@ -540,12 +541,12 @@ async function handleControlMessage(context, store, input, { intent = null, star
540
541
  classification,
541
542
  item,
542
543
  ...controlStatus(context, store),
543
- explanation: 'Request queued for a future run.',
544
+ explanation: controlMessage(context?.session, 'queued_for_future_run'),
544
545
  },
545
546
  };
546
547
  }
547
548
  if (classification.kind === 'ambiguous') {
548
- return readOnlyControlResponse('ambiguous', classification, status, 'The runtime cannot safely classify this message.', {
549
+ return readOnlyControlResponse('ambiguous', classification, status, controlMessage(context?.session, 'ambiguous_control'), {
549
550
  accepted: false,
550
551
  extra: {
551
552
  choices: [
@@ -557,8 +558,8 @@ async function handleControlMessage(context, store, input, { intent = null, star
557
558
  });
558
559
  }
559
560
  return readOnlyControlResponse('converse', classification, status, status.running
560
- ? 'Runtime run is still active. This message was treated as conversation and did not create a queued run.'
561
- : 'Runtime is idle. This message was treated as conversation and did not create a run.');
561
+ ? controlMessage(context?.session, 'converse_while_running')
562
+ : controlMessage(context?.session, 'converse_while_idle'));
562
563
  }
563
564
 
564
565
  function enqueueControlRequest(context, input) {
@@ -271,7 +271,7 @@ test('runtime server returns the accepted run id and passes it to the runner', a
271
271
  const response = await fetch(`http://127.0.0.1:${handle.port}/run`, {
272
272
  method: 'POST',
273
273
  headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({ input: 'build', workspace: 'juno', evaluate: false, replans: 1 }),
274
+ body: JSON.stringify({ input: 'build', workspace: 'acme', evaluate: false, replans: 1 }),
275
275
  });
276
276
  assert.equal(response.status, 202);
277
277
  const body = await response.json();
@@ -279,7 +279,7 @@ test('runtime server returns the accepted run id and passes it to the runner', a
279
279
  assert.match(body.runId, /^[0-9a-f-]{36}$/);
280
280
  await new Promise((resolve) => setImmediate(resolve));
281
281
  assert.equal(receivedBody.runId, body.runId);
282
- assert.equal(receivedBody.workspace, 'juno');
282
+ assert.equal(receivedBody.workspace, 'acme');
283
283
  assert.equal(receivedBody.evaluate, false);
284
284
  assert.equal(receivedBody.replans, 1);
285
285
  } finally {
@@ -290,8 +290,8 @@ test('runtime server returns the accepted run id and passes it to the runner', a
290
290
  test('runtime server state exposes active run identity while running', async (t) => {
291
291
  let releaseRun;
292
292
  const context = {
293
- workspace: 'juno',
294
- session: { workspace: 'juno' },
293
+ workspace: 'acme',
294
+ session: { workspace: 'acme' },
295
295
  running: false,
296
296
  currentAbortController: null,
297
297
  currentRunId: null,
@@ -321,18 +321,18 @@ test('runtime server state exposes active run identity while running', async (t)
321
321
  }
322
322
 
323
323
  try {
324
- const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=juno`, {
324
+ const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, {
325
325
  method: 'POST',
326
326
  headers: { 'Content-Type': 'application/json' },
327
327
  body: JSON.stringify({ input: 'build' }),
328
328
  });
329
329
  acceptedRun = await runResponse.json();
330
- const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
330
+ const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=acme`);
331
331
  const state = await stateResponse.json();
332
332
  assert.equal(state.status, 'running');
333
333
  assert.equal(state.running, true);
334
334
  assert.equal(state.runId, acceptedRun.runId);
335
- assert.equal(state.workspace, 'juno');
335
+ assert.equal(state.workspace, 'acme');
336
336
  } finally {
337
337
  releaseRun?.();
338
338
  await handle.close();
@@ -381,8 +381,8 @@ test('runtime server isolates active runs by workspace', async (t) => {
381
381
 
382
382
  try {
383
383
  const url = `http://127.0.0.1:${handle.port}/run`;
384
- const [juno, docs] = await Promise.all([
385
- fetch(`${url}?workspace=juno`, {
384
+ const [acme, docs] = await Promise.all([
385
+ fetch(`${url}?workspace=acme`, {
386
386
  method: 'POST',
387
387
  headers: { 'Content-Type': 'application/json' },
388
388
  body: JSON.stringify({ input: 'first' }),
@@ -393,18 +393,18 @@ test('runtime server isolates active runs by workspace', async (t) => {
393
393
  body: JSON.stringify({ input: 'second' }),
394
394
  }),
395
395
  ]);
396
- assert.deepEqual([juno.status, docs.status], [202, 202]);
396
+ assert.deepEqual([acme.status, docs.status], [202, 202]);
397
397
 
398
- const queued = await fetch(`${url}?workspace=juno`, {
398
+ const queued = await fetch(`${url}?workspace=acme`, {
399
399
  method: 'POST',
400
400
  headers: { 'Content-Type': 'application/json' },
401
401
  body: JSON.stringify({ input: 'third' }),
402
402
  });
403
403
  assert.equal(queued.status, 202);
404
404
  assert.equal((await queued.json()).queued, true);
405
- assert.deepEqual(runWorkspaces.sort(), ['docs', 'juno']);
405
+ assert.deepEqual(runWorkspaces.sort(), ['acme', 'docs']);
406
406
  } finally {
407
- releases.get('juno')?.();
407
+ releases.get('acme')?.();
408
408
  releases.get('docs')?.();
409
409
  await handle.close();
410
410
  }
@@ -446,10 +446,10 @@ test('runtime server filters state and events by workspace', async (t) => {
446
446
  }
447
447
 
448
448
  try {
449
- const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
449
+ const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=acme`);
450
450
  assert.equal(state.status, 200);
451
- assert.equal((await state.json()).workspace, 'juno');
452
- assert.equal(stateWorkspace, 'juno');
451
+ assert.equal((await state.json()).workspace, 'acme');
452
+ assert.equal(stateWorkspace, 'acme');
453
453
 
454
454
  const events = await fetch(`http://127.0.0.1:${handle.port}/events?workspace=docs`);
455
455
  assert.equal(events.status, 200);
@@ -533,15 +533,15 @@ test('runtime server exposes manual resume endpoint', async (t) => {
533
533
  }
534
534
 
535
535
  try {
536
- const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=juno`, {
536
+ const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=acme`, {
537
537
  method: 'POST',
538
538
  });
539
539
  assert.equal(response.status, 202);
540
- assert.equal(resumedWorkspace, 'juno');
540
+ assert.equal(resumedWorkspace, 'acme');
541
541
  assert.deepEqual(await response.json(), {
542
542
  resumed: 1,
543
543
  interrupted: 0,
544
- workspaces: [{ workspace: 'juno', resumed: true }],
544
+ workspaces: [{ workspace: 'acme', resumed: true }],
545
545
  });
546
546
  } finally {
547
547
  await handle.close();
@@ -575,13 +575,13 @@ test('runtime server exposes approval endpoint', async (t) => {
575
575
  }
576
576
 
577
577
  try {
578
- const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=juno&runId=run-1&itemId=item-1`, {
578
+ const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=acme&runId=run-1&itemId=item-1`, {
579
579
  method: 'POST',
580
580
  });
581
581
  assert.equal(response.status, 202);
582
582
  assert.deepEqual(approved, {
583
- workspace: 'juno',
584
- workspaceId: 'juno',
583
+ workspace: 'acme',
584
+ workspaceId: 'acme',
585
585
  runId: 'run-1',
586
586
  itemId: 'item-1',
587
587
  approvalId: null,
@@ -599,8 +599,8 @@ test('runtime server exposes approval endpoint', async (t) => {
599
599
 
600
600
  test('runtime server exposes control status and explanation', async (t) => {
601
601
  const session = {
602
- workspace: 'juno',
603
- controlQueue: [{ id: 'control-1', workspace: 'juno', status: 'queued', input: 'later' }],
602
+ workspace: 'acme',
603
+ controlQueue: [{ id: 'control-1', workspace: 'acme', status: 'queued', input: 'later' }],
604
604
  };
605
605
  let handle;
606
606
  try {
@@ -620,7 +620,7 @@ test('runtime server exposes control status and explanation', async (t) => {
620
620
  listEvents: () => [],
621
621
  },
622
622
  getContext: async () => ({
623
- workspace: 'juno',
623
+ workspace: 'acme',
624
624
  session,
625
625
  running: false,
626
626
  currentAbortController: null,
@@ -636,14 +636,14 @@ test('runtime server exposes control status and explanation', async (t) => {
636
636
  }
637
637
 
638
638
  try {
639
- const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`);
639
+ const status = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`);
640
640
  assert.equal(status.status, 200);
641
641
  const statusBody = await status.json();
642
642
  assert.equal(statusBody.status, 'idle');
643
643
  assert.equal(statusBody.running, false);
644
644
  assert.equal(statusBody.controlQueue[0].id, 'control-1');
645
645
 
646
- const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
646
+ const explain = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
647
647
  method: 'POST',
648
648
  headers: { 'Content-Type': 'application/json' },
649
649
  body: JSON.stringify({ action: 'explain' }),
@@ -657,7 +657,7 @@ test('runtime server exposes control status and explanation', async (t) => {
657
657
 
658
658
  test('runtime server control enqueue emits events but does not patch an active plan or start a run', async (t) => {
659
659
  const session = {
660
- workspace: 'juno',
660
+ workspace: 'acme',
661
661
  controlQueue: [],
662
662
  };
663
663
  const events = [];
@@ -681,7 +681,7 @@ test('runtime server control enqueue emits events but does not patch an active p
681
681
  listEvents: () => [],
682
682
  },
683
683
  getContext: async () => ({
684
- workspace: 'juno',
684
+ workspace: 'acme',
685
685
  session,
686
686
  running: true,
687
687
  currentAbortController: new AbortController(),
@@ -697,7 +697,7 @@ test('runtime server control enqueue emits events but does not patch an active p
697
697
  }
698
698
 
699
699
  try {
700
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
700
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
701
701
  method: 'POST',
702
702
  headers: { 'Content-Type': 'application/json' },
703
703
  body: JSON.stringify({ action: 'enqueue', input: 'run this after current work' }),
@@ -718,7 +718,7 @@ test('runtime server control enqueue emits events but does not patch an active p
718
718
 
719
719
  test('runtime server control message observes an active run without enqueueing', async (t) => {
720
720
  const session = {
721
- workspace: 'juno',
721
+ workspace: 'acme',
722
722
  controlQueue: [],
723
723
  };
724
724
  let runCount = 0;
@@ -739,7 +739,7 @@ test('runtime server control message observes an active run without enqueueing',
739
739
  listEvents: () => [],
740
740
  },
741
741
  getContext: async () => ({
742
- workspace: 'juno',
742
+ workspace: 'acme',
743
743
  session,
744
744
  running: true,
745
745
  currentAbortController: new AbortController(),
@@ -755,7 +755,7 @@ test('runtime server control message observes an active run without enqueueing',
755
755
  }
756
756
 
757
757
  try {
758
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
758
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
759
759
  method: 'POST',
760
760
  headers: { 'Content-Type': 'application/json' },
761
761
  body: JSON.stringify({ action: 'message', input: 'Où en est le build ?' }),
@@ -773,7 +773,7 @@ test('runtime server control message observes an active run without enqueueing',
773
773
 
774
774
  test('runtime server control message handles approve and cancel intents during an active run', async (t) => {
775
775
  const session = {
776
- workspace: 'juno',
776
+ workspace: 'acme',
777
777
  controlQueue: [],
778
778
  };
779
779
  const abortController = new AbortController();
@@ -795,7 +795,7 @@ test('runtime server control message handles approve and cancel intents during a
795
795
  listEvents: () => [],
796
796
  },
797
797
  getContext: async () => ({
798
- workspace: 'juno',
798
+ workspace: 'acme',
799
799
  session,
800
800
  running: true,
801
801
  currentAbortController: abortController,
@@ -812,7 +812,7 @@ test('runtime server control message handles approve and cancel intents during a
812
812
  }
813
813
 
814
814
  try {
815
- const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
815
+ const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
816
816
  method: 'POST',
817
817
  headers: { 'Content-Type': 'application/json' },
818
818
  body: JSON.stringify({ action: 'message', input: 'valide tout' }),
@@ -820,7 +820,7 @@ test('runtime server control message handles approve and cancel intents during a
820
820
  assert.equal(approveResponse.status, 200);
821
821
  assert.equal((await approveResponse.json()).kind, 'approve');
822
822
 
823
- const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
823
+ const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
824
824
  method: 'POST',
825
825
  headers: { 'Content-Type': 'application/json' },
826
826
  body: JSON.stringify({ action: 'message', input: 'annule le run' }),
@@ -837,18 +837,18 @@ test('runtime server control message handles approve and cancel intents during a
837
837
 
838
838
  test('runtime server control message records active plan mutation as a proposal', async (t) => {
839
839
  const session = {
840
- workspace: 'juno',
840
+ workspace: 'acme',
841
841
  controlQueue: [],
842
842
  };
843
843
  dispatchAgentEvent(session, createAgentEvent('run_started', {
844
844
  origin: 'runtime',
845
845
  runId: 'run-mutate',
846
- workspace: 'juno',
846
+ workspace: 'acme',
847
847
  }));
848
848
  dispatchAgentEvent(session, createAgentEvent('plan_set', {
849
849
  origin: 'tool',
850
850
  runId: 'run-mutate',
851
- workspace: 'juno',
851
+ workspace: 'acme',
852
852
  payload: {
853
853
  steps: [{ step: 1, id: 'generate', description: 'Generate', status: 'running' }],
854
854
  },
@@ -865,13 +865,13 @@ test('runtime server control message records active plan mutation as a proposal'
865
865
  ...session.agentProjection,
866
866
  status: session.agentProjection?.status ?? 'running',
867
867
  queue: [],
868
- runs: [{ id: 'run-mutate', workspace: 'juno', status: 'running' }],
868
+ runs: [{ id: 'run-mutate', workspace: 'acme', status: 'running' }],
869
869
  runId: 'run-mutate',
870
870
  }),
871
871
  listEvents: () => [],
872
872
  },
873
873
  getContext: async () => ({
874
- workspace: 'juno',
874
+ workspace: 'acme',
875
875
  session,
876
876
  running: true,
877
877
  currentAbortController: new AbortController(),
@@ -888,7 +888,7 @@ test('runtime server control message records active plan mutation as a proposal'
888
888
  }
889
889
 
890
890
  try {
891
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
891
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
892
892
  method: 'POST',
893
893
  headers: { 'Content-Type': 'application/json' },
894
894
  body: JSON.stringify({ action: 'message', input: 'Ajoute un envoi après chaque génération' }),
@@ -901,7 +901,7 @@ test('runtime server control message records active plan mutation as a proposal'
901
901
  assert.equal(session.agentProjection.planPatches[0].status, 'proposed');
902
902
  assert.ok(session.agentEvents.some((event) => event.type === 'control_message_received'));
903
903
  assert.ok(session.agentEvents.some((event) => event.type === 'plan_patch_proposed'));
904
- const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
904
+ const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
905
905
  method: 'POST',
906
906
  headers: { 'Content-Type': 'application/json' },
907
907
  body: JSON.stringify({ action: 'approve_patch', patchId: body.proposal.id }),
@@ -924,7 +924,7 @@ test('runtime server control message records active plan mutation as a proposal'
924
924
 
925
925
  test('runtime server control message reports ambiguity without starting a run', async (t) => {
926
926
  const session = {
927
- workspace: 'juno',
927
+ workspace: 'acme',
928
928
  controlQueue: [],
929
929
  };
930
930
  let runCount = 0;
@@ -945,7 +945,7 @@ test('runtime server control message reports ambiguity without starting a run',
945
945
  listEvents: () => [],
946
946
  },
947
947
  getContext: async () => ({
948
- workspace: 'juno',
948
+ workspace: 'acme',
949
949
  session,
950
950
  running: true,
951
951
  currentAbortController: new AbortController(),
@@ -961,7 +961,7 @@ test('runtime server control message reports ambiguity without starting a run',
961
961
  }
962
962
 
963
963
  try {
964
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
964
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
965
965
  method: 'POST',
966
966
  headers: { 'Content-Type': 'application/json' },
967
967
  body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
@@ -979,7 +979,7 @@ test('runtime server control message reports ambiguity without starting a run',
979
979
 
980
980
  test('runtime server drains queued control requests when idle', async (t) => {
981
981
  const session = {
982
- workspace: 'juno',
982
+ workspace: 'acme',
983
983
  controlQueue: [],
984
984
  };
985
985
  const events = [];
@@ -1003,7 +1003,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1003
1003
  listEvents: () => [],
1004
1004
  },
1005
1005
  getContext: async () => ({
1006
- workspace: 'juno',
1006
+ workspace: 'acme',
1007
1007
  session,
1008
1008
  running: false,
1009
1009
  currentAbortController: null,
@@ -1021,7 +1021,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1021
1021
  }
1022
1022
 
1023
1023
  try {
1024
- const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
1024
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1025
1025
  method: 'POST',
1026
1026
  headers: { 'Content-Type': 'application/json' },
1027
1027
  body: JSON.stringify({ action: 'enqueue', input: 'run from control queue' }),
@@ -1029,7 +1029,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1029
1029
  assert.equal(response.status, 202);
1030
1030
  await new Promise((resolve) => setImmediate(resolve));
1031
1031
  assert.equal(receivedBody.input, 'run from control queue');
1032
- assert.equal(receivedBody.workspace, 'juno');
1032
+ assert.equal(receivedBody.workspace, 'acme');
1033
1033
  assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
1034
1034
  assert.equal(session.controlQueue[0].status, 'running');
1035
1035
  assert.equal(session.controlQueue[0].runId, receivedBody.runId);
@@ -1041,13 +1041,13 @@ test('runtime server drains queued control requests when idle', async (t) => {
1041
1041
 
1042
1042
  test('runtime server handle drains a pre-existing hydrated control request', async (t) => {
1043
1043
  const session = {
1044
- workspace: 'juno',
1045
- controlQueue: [{ id: 'control-existing', workspace: 'juno', status: 'queued', input: 'resume queued control' }],
1044
+ workspace: 'acme',
1045
+ controlQueue: [{ id: 'control-existing', workspace: 'acme', status: 'queued', input: 'resume queued control' }],
1046
1046
  };
1047
1047
  const events = [];
1048
1048
  session._onAgentEvent = (event) => events.push(event);
1049
1049
  const context = {
1050
- workspace: 'juno',
1050
+ workspace: 'acme',
1051
1051
  session,
1052
1052
  running: false,
1053
1053
  currentAbortController: null,
@@ -1089,8 +1089,8 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
1089
1089
 
1090
1090
  test('runtime server exposes config profile list and switch endpoints', async (t) => {
1091
1091
  const context = {
1092
- workspace: 'juno',
1093
- session: { workspace: 'juno', wikirc: { profile: 'default' } },
1092
+ workspace: 'acme',
1093
+ session: { workspace: 'acme', wikirc: { profile: 'default' } },
1094
1094
  running: false,
1095
1095
  currentAbortController: null,
1096
1096
  };
@@ -1123,11 +1123,11 @@ test('runtime server exposes config profile list and switch endpoints', async (t
1123
1123
  }
1124
1124
 
1125
1125
  try {
1126
- const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=juno`);
1126
+ const profiles = await fetch(`http://127.0.0.1:${handle.port}/config/profiles?workspace=acme`);
1127
1127
  assert.equal(profiles.status, 200);
1128
1128
  assert.deepEqual(await profiles.json(), { profiles: ['default', 'vpn'], active: 'default' });
1129
1129
 
1130
- const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
1130
+ const use = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=acme`, {
1131
1131
  method: 'POST',
1132
1132
  headers: { 'Content-Type': 'application/json' },
1133
1133
  body: JSON.stringify({ profile: 'vpn' }),
@@ -1152,8 +1152,8 @@ test('runtime server rejects config switching while a run is active', async (t)
1152
1152
  listEvents: () => [],
1153
1153
  },
1154
1154
  getContext: async () => ({
1155
- workspace: 'juno',
1156
- session: { workspace: 'juno' },
1155
+ workspace: 'acme',
1156
+ session: { workspace: 'acme' },
1157
1157
  running: true,
1158
1158
  currentAbortController: null,
1159
1159
  }),
@@ -1169,7 +1169,7 @@ test('runtime server rejects config switching while a run is active', async (t)
1169
1169
  }
1170
1170
 
1171
1171
  try {
1172
- const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=juno`, {
1172
+ const response = await fetch(`http://127.0.0.1:${handle.port}/config/use?workspace=acme`, {
1173
1173
  method: 'POST',
1174
1174
  headers: { 'Content-Type': 'application/json' },
1175
1175
  body: JSON.stringify({ profile: 'vpn' }),
@@ -281,8 +281,8 @@ test('runtime store persists duplicate events idempotently', () => {
281
281
  origin: 'test',
282
282
  runId: 'run-1',
283
283
  turnId: 'run-1:turn-0',
284
- workspace: 'juno',
285
- payload: { input: 'build wiki', workspace: 'juno' },
284
+ workspace: 'acme',
285
+ payload: { input: 'build wiki', workspace: 'acme' },
286
286
  });
287
287
 
288
288
  store.persistEvent(event);
@@ -292,7 +292,7 @@ test('runtime store persists duplicate events idempotently', () => {
292
292
  assert.equal(store.listEvents()[0].sequence, 1);
293
293
  assert.equal(store.getState().eventsCursor, 1);
294
294
  assert.equal(store.listRuns()[0].id, 'run-1');
295
- assert.equal(store.listRuns()[0].workspace, 'juno');
295
+ assert.equal(store.listRuns()[0].workspace, 'acme');
296
296
  assert.equal(store.listRuns()[0].status, 'running');
297
297
  store.close();
298
298
  });
@@ -590,19 +590,19 @@ test('runtime store persists cancelled run status', () => {
590
590
  origin: 'test',
591
591
  runId: 'run-1',
592
592
  turnId: 'run-1:turn-0',
593
- workspace: 'juno',
594
- payload: { input: 'build wiki', workspace: 'juno' },
593
+ workspace: 'acme',
594
+ payload: { input: 'build wiki', workspace: 'acme' },
595
595
  }));
596
596
  store.persistEvent(createAgentEvent('run_cancelled', {
597
597
  origin: 'test',
598
598
  runId: 'run-1',
599
599
  turnId: 'run-1:turn-1',
600
- workspace: 'juno',
601
- payload: { runId: 'run-1', workspace: 'juno' },
600
+ workspace: 'acme',
601
+ payload: { runId: 'run-1', workspace: 'acme' },
602
602
  }));
603
603
 
604
604
  assert.equal(store.listRuns()[0].status, 'cancelled');
605
- assert.equal(store.listRuns()[0].workspace, 'juno');
605
+ assert.equal(store.listRuns()[0].workspace, 'acme');
606
606
  store.close();
607
607
  });
608
608
 
@@ -724,10 +724,10 @@ test('runtime store filters events runs and queue by workspace', () => {
724
724
  const store = openRuntimeStore({ stateDir });
725
725
  store.persistEvent(createAgentEvent('run_started', {
726
726
  origin: 'test',
727
- runId: 'run-juno',
728
- turnId: 'run-juno:turn-0',
729
- workspace: 'juno',
730
- payload: { input: 'juno', workspace: 'juno' },
727
+ runId: 'run-acme',
728
+ turnId: 'run-acme:turn-0',
729
+ workspace: 'acme',
730
+ payload: { input: 'acme', workspace: 'acme' },
731
731
  }));
732
732
  store.persistEvent(createAgentEvent('run_started', {
733
733
  origin: 'test',
@@ -736,12 +736,12 @@ test('runtime store filters events runs and queue by workspace', () => {
736
736
  workspace: 'docs',
737
737
  payload: { input: 'docs', workspace: 'docs' },
738
738
  }));
739
- store.saveQueue([{ id: 'q-juno', workspace: 'juno', status: 'waiting' }], { workspace: 'juno' });
739
+ store.saveQueue([{ id: 'q-acme', workspace: 'acme', status: 'waiting' }], { workspace: 'acme' });
740
740
  store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
741
741
 
742
- assert.deepEqual(store.listEvents({ workspace: 'juno' }).map((event) => event.runId), ['run-juno']);
742
+ assert.deepEqual(store.listEvents({ workspace: 'acme' }).map((event) => event.runId), ['run-acme']);
743
743
  assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-docs']);
744
- assert.deepEqual(store.listQueue({ workspace: 'juno' }).map((item) => item.id), ['q-juno']);
744
+ assert.deepEqual(store.listQueue({ workspace: 'acme' }).map((item) => item.id), ['q-acme']);
745
745
 
746
746
  const session = { activities: {}, headlessPlan: null };
747
747
  store.hydrateSession(session, { workspace: 'docs' });
@@ -794,8 +794,8 @@ test('runtime store persists and replays control queue events without mixing MCP
794
794
  }));
795
795
  store.persistEvent(createAgentEvent('control_enqueued', {
796
796
  origin: 'runtime',
797
- workspace: 'juno',
798
- payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
797
+ workspace: 'acme',
798
+ payload: { id: 'control-acme', workspace: 'acme', input: 'other' },
799
799
  }));
800
800
  store.close();
801
801
 
@@ -925,10 +925,10 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
925
925
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
926
926
  const store = openRuntimeStore({ stateDir });
927
927
  store.persistRun({
928
- id: 'run-juno',
929
- workspace: 'juno',
928
+ id: 'run-acme',
929
+ workspace: 'acme',
930
930
  status: 'running',
931
- input: 'build juno',
931
+ input: 'build acme',
932
932
  });
933
933
  store.persistRun({
934
934
  id: 'run-done',
@@ -938,9 +938,9 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
938
938
  });
939
939
  store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
940
940
 
941
- assert.deepEqual(store.listRecoverableWorkspaces(), ['docs', 'juno']);
942
- assert.deepEqual(store.listRecoverableRuns({ workspace: 'juno' }).map((run) => run.id), ['run-juno']);
943
- assert.equal(store.interruptRuns({ workspace: 'juno' }), 1);
944
- assert.equal(store.listRuns({ workspace: 'juno' })[0].status, 'interrupted');
941
+ assert.deepEqual(store.listRecoverableWorkspaces(), ['acme', 'docs']);
942
+ assert.deepEqual(store.listRecoverableRuns({ workspace: 'acme' }).map((run) => run.id), ['run-acme']);
943
+ assert.equal(store.interruptRuns({ workspace: 'acme' }), 1);
944
+ assert.equal(store.listRuns({ workspace: 'acme' })[0].status, 'interrupted');
945
945
  store.close();
946
946
  });
package/src/shell/repl.js CHANGED
@@ -1580,14 +1580,15 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1580
1580
  if (outcome.kind === 'accepted') {
1581
1581
  activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1582
1582
  } else if (outcome.kind === 'queued') {
1583
- conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
1583
+ // Server-localized acknowledgement (src/runtime/controlMessages.js).
1584
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
1584
1585
  activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
1585
1586
  } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1586
1587
  const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
1587
1588
  conversationMessages(session).push({ role: 'command', content: explanation });
1588
1589
  activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1589
1590
  } else if (outcome.kind === 'ambiguous') {
1590
- conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
1591
+ conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.') });
1591
1592
  activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
1592
1593
  } else {
1593
1594
  conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
@@ -183,7 +183,7 @@ test('agent mode without runtime records a visible error instead of falling back
183
183
 
184
184
  test('runtime status exposes the disconnected reason', () => {
185
185
  assert.equal(
186
- runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'juno' }),
186
+ runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'acme' }),
187
187
  'runtime: disconnected: token mismatch',
188
188
  );
189
189
  assert.equal(
@@ -31,7 +31,11 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
31
31
  props.onRuntimeAccepted?.();
32
32
  props.addLog('runtime: run accepted');
33
33
  } else if (outcome.kind === 'queued') {
34
- conversationMessages(props.session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
34
+ // The server localizes control-lane acknowledgements from the
35
+ // session language (src/runtime/controlMessages.js) — always prefer
36
+ // its explanation over a local hardcoded string.
37
+ const explanation = (outcome as any).result?.explanation ?? 'Request added to the queue.';
38
+ conversationMessages(props.session).push({ role: 'command', content: String(explanation) });
35
39
  props.addLog('runtime: control queued');
36
40
  } else {
37
41
  conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
@@ -224,36 +224,64 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
224
224
  ? lastVisibleActivities.map((activity) => ({ ...activity }))
225
225
  : current;
226
226
  });
227
+ // Control-lane run requests (user messages queued while the runtime is
228
+ // busy) live in state.controlQueue, a separate lane from the MCP job
229
+ // queue. They must appear in the Queue panel too — otherwise a queued run
230
+ // request is invisible (Queue (0)) even though it is registered and will
231
+ // start automatically.
232
+ const controlQueueItems = createMemo(() => {
233
+ version();
234
+ const controlQueue = runtimeState()?.controlQueue;
235
+ if (!Array.isArray(controlQueue)) return [] as any[];
236
+ return controlQueue
237
+ .filter((item: any) => ['queued', 'running', 'starting'].includes(String(item.status ?? '').toLowerCase()))
238
+ .map((item: any) => ({
239
+ ...item,
240
+ id: item.id,
241
+ label: `run: ${String(item.input ?? '').slice(0, 64)}`,
242
+ status: item.status,
243
+ _runtime: true,
244
+ _control: true,
245
+ }));
246
+ });
227
247
  const queueItems = createMemo(() => {
228
248
  version();
249
+ const control = controlQueueItems();
229
250
  const workflowQueue = nonEmptyRuntimeArray(runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue'));
230
251
  if (workflowQueue) {
231
- return workflowQueue.map((node: any) => ({ ...(node.raw ?? {}), id: node.itemId ?? node.id, label: node.label, status: node.status, _runtime: true, _workflow: true }));
252
+ return [...control, ...workflowQueue.map((node: any) => ({ ...(node.raw ?? {}), id: node.itemId ?? node.id, label: node.label, status: node.status, _runtime: true, _workflow: true }))];
232
253
  }
233
254
  const runtimeQueue = nonEmptyRuntimeArray(runtimeState()?.queue);
234
- if (runtimeQueue) return runtimeQueue.map((item: any) => ({ ...item, _runtime: true }));
235
- return projectQueue((session as any).headlessPlan, (session as any).jobQueue ?? [], { workspace: (session as any).workspace ?? null })
236
- .map((item: any) => ({ ...item }));
255
+ if (runtimeQueue) return [...control, ...runtimeQueue.map((item: any) => ({ ...item, _runtime: true }))];
256
+ return [
257
+ ...control,
258
+ ...projectQueue((session as any).headlessPlan, (session as any).jobQueue ?? [], { workspace: (session as any).workspace ?? null })
259
+ .map((item: any) => ({ ...item })),
260
+ ];
237
261
  });
238
262
  const queueInfo = createMemo(() => {
239
263
  version();
264
+ const control = controlQueueItems();
265
+ const controlActive = control.length;
266
+ const controlCurrent = control.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length;
240
267
  const workflowQueue = runtimeState()?.workflow?.nodes?.filter((node: any) => node.type === 'queue');
241
268
  if (Array.isArray(workflowQueue)) {
242
269
  return {
243
- active: workflowQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
244
- current: workflowQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
270
+ active: controlActive + workflowQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
271
+ current: controlCurrent + workflowQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
245
272
  frozen: 0,
246
273
  };
247
274
  }
248
275
  const runtimeQueue = runtimeState()?.queue;
249
276
  if (Array.isArray(runtimeQueue)) {
250
277
  return {
251
- active: runtimeQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
252
- current: runtimeQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
278
+ active: controlActive + runtimeQueue.filter((item: any) => ['waiting', 'starting', 'running', 'queued', 'pending', 'pending_approval'].includes(String(item.status ?? '').toLowerCase())).length,
279
+ current: controlCurrent + runtimeQueue.filter((item: any) => ['starting', 'running'].includes(String(item.status ?? '').toLowerCase())).length,
253
280
  frozen: 0,
254
281
  };
255
282
  }
256
- return queueCounts(session);
283
+ const base = queueCounts(session);
284
+ return { ...base, active: (base.active ?? 0) + controlActive, current: (base.current ?? 0) + controlCurrent };
257
285
  });
258
286
  const plan = createMemo(() => {
259
287
  version();