@dotdrelle/wiki-manager 0.15.59 → 0.15.62

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.
@@ -130,7 +130,7 @@ services:
130
130
  # error. Every compose-deployed ingest then ran without the Lot 4 barrier
131
131
  # and left the published map stale — the very defect that work fixed.
132
132
  # `copy` stays out on purpose: it is the legacy step, opt-in only.
133
- - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,taxonomy,build,export,polish,restore,pipeline}
133
+ - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,concepts,reclassify-concepts,taxonomy,build,export,polish,restore,pipeline}
134
134
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
135
135
  # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
136
136
  # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.59",
3
+ "version": "0.15.62",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,6 @@ import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/
28
28
  import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, rememberArtifact } from '../core/currentArtifact.js';
29
29
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
30
30
  import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
31
- import { controlLanguage } from '../runtime/controlMessages.js';
32
31
 
33
32
  const MAX_TOOL_ITERATIONS = 80;
34
33
  /**
@@ -433,10 +432,6 @@ export function bareToolCallJson(content, tools = []) {
433
432
  return hasArguments ? name : null;
434
433
  }
435
434
 
436
- function localizedFailure(session, english, french) {
437
- return controlLanguage(session) === 'fr' ? french : english;
438
- }
439
-
440
435
  function parseActionJson(text) {
441
436
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
442
437
  if (!cleaned) return null;
@@ -1548,11 +1543,7 @@ export function createAgentGraph(options = {}) {
1548
1543
  invalidToolCallRetries: retries + 1,
1549
1544
  };
1550
1545
  }
1551
- const failure = localizedFailure(
1552
- state.session,
1553
- 'Action not executed: the model generated an incomplete tool call.',
1554
- 'Action non exécutée : l’appel d’outil généré par le modèle était incomplet.',
1555
- );
1546
+ const failure = 'Action not executed: the model generated an incomplete tool call.';
1556
1547
  emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
1557
1548
  return { response: failure, pendingToolCalls: null, readyToStream: false };
1558
1549
  }
@@ -1616,11 +1607,7 @@ export function createAgentGraph(options = {}) {
1616
1607
  };
1617
1608
  }
1618
1609
  state.session._onStreamReset?.();
1619
- const failure = localizedFailure(
1620
- state.session,
1621
- 'Action not executed: Donna repeatedly printed an internal tool request instead of calling it. No result was created.',
1622
- 'Action non exécutée : Donna a affiché à plusieurs reprises une requête interne au lieu d’appeler l’outil. Aucun résultat n’a été créé.',
1623
- );
1610
+ const failure = 'Action not executed: Donna repeatedly printed an internal tool request instead of calling it. No result was created.';
1624
1611
  emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
1625
1612
  return { response: failure, pendingToolCalls: null, readyToStream: false };
1626
1613
  }
@@ -1689,11 +1676,7 @@ export function createAgentGraph(options = {}) {
1689
1676
 
1690
1677
  if (runtimeExecution && state.retryWithoutTool) {
1691
1678
  state.session._onStreamReset?.();
1692
- const failure = localizedFailure(
1693
- state.session,
1694
- 'Action not executed: Donna did not call any available tool. No job or result was created.',
1695
- 'Action non exécutée : Donna n’a appelé aucun outil disponible. Aucun job ni résultat n’a été créé.',
1696
- );
1679
+ const failure = 'Action not executed: Donna did not call any available tool. No job or result was created.';
1697
1680
  emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
1698
1681
  return {
1699
1682
  response: failure,
@@ -2281,8 +2281,8 @@ test('LOT F: repeated bare tool-call JSON never reaches the user', async () => {
2281
2281
  assert.ok(offered.includes('production__production_start_job'), 'the turn must really offer the tool');
2282
2282
  assert.equal(calls, 3, 'two retries, then the guard');
2283
2283
  assert.doesNotMatch(result.response, /production__production_start_job/);
2284
- assert.match(result.response, /a affiché à plusieurs reprises une requête interne/);
2285
- assert.match(result.response, /Aucun .*résultat n’a été créé/);
2284
+ assert.match(result.response, /repeatedly printed an internal tool request/);
2285
+ assert.match(result.response, /No .*result was created/);
2286
2286
  });
2287
2287
 
2288
2288
  /*
@@ -2326,7 +2326,7 @@ test('LOT F: a skill re-invoking itself as bare JSON is neither executed nor sho
2326
2326
  assert.deepEqual(ran, [], 'a call written as text must never reach the skill runner');
2327
2327
  assert.doesNotMatch(result.response, /runtime__run_skill/);
2328
2328
  assert.doesNotMatch(result.response, /[{}]/, 'no JSON fragment may survive in the user-facing answer');
2329
- assert.match(result.response, /a affiché à plusieurs reprises une requête interne/);
2329
+ assert.match(result.response, /repeatedly printed an internal tool request/);
2330
2330
  assert.ok(streamResets >= 1, 'the partially streamed payload must be wiped before the guard message');
2331
2331
  assert.ok(calls >= 2, 'the first occurrence is retried, not surfaced');
2332
2332
 
@@ -2338,7 +2338,7 @@ test('LOT F: a skill re-invoking itself as bare JSON is neither executed nor sho
2338
2338
  assert.equal(guard[0].payload.content, result.response);
2339
2339
  });
2340
2340
 
2341
- test('LOT F: the guard message follows the session language', async () => {
2341
+ test('LOT F: the guard message stays English-only for every session language', async () => {
2342
2342
  const raw = '{"name":"production__production_start_job","arguments":{"type":"build"}}';
2343
2343
  const answer = async (language) => {
2344
2344
  const session = sessionBase({
@@ -2353,10 +2353,10 @@ test('LOT F: the guard message follows the session language', async () => {
2353
2353
  return result.response;
2354
2354
  };
2355
2355
 
2356
- // Le message de garde est produit par le code, pas par le modèle : il doit
2357
- // suivre la langue configurée au lieu d'imposer l'anglais comme le faisait
2358
- // l'ancien texte injecté par l'UI.
2356
+ // The guard message is produced by deterministic code, not by the model, and
2357
+ // this lane deliberately does not run an LLM turn — so it is English-only
2358
+ // rather than a hardcoded catalog that would leave most languages unanswered.
2359
2359
  assert.match(await answer('en-US'), /repeatedly printed an internal tool request/);
2360
2360
  assert.match(await answer(undefined), /repeatedly printed an internal tool request/);
2361
- assert.match(await answer('fr'), /a affiché à plusieurs reprises une requête interne/);
2361
+ assert.match(await answer('fr'), /repeatedly printed an internal tool request/);
2362
2362
  });
package/src/agent/llm.js CHANGED
@@ -26,9 +26,10 @@ export function createLlmClientFromWikiConfig(config) {
26
26
  }
27
27
 
28
28
  return {
29
- async complete({ system, input }) {
29
+ async complete({ system, input, signal }) {
30
30
  const response = await fetch(`${baseUrl}/chat/completions`, {
31
31
  method: 'POST',
32
+ signal,
32
33
  headers: {
33
34
  Authorization: `Bearer ${apiKey}`,
34
35
  'Content-Type': 'application/json',
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.59",
3
- "commit": "2899ec5"
2
+ "version": "0.15.62",
3
+ "commit": "399432f"
4
4
  }
@@ -45,6 +45,36 @@ test('every shipped default allows the taxonomy step', async () => {
45
45
  assert.match(String(allowed), /(?:^|,)taxonomy(?:,|})/);
46
46
  });
47
47
 
48
+ test('every shipped default allows the concepts step', async () => {
49
+ /*
50
+ Same silent-omission risk as `taxonomy` above, one lot earlier: without
51
+ `concepts`, `wiki concepts --apply` (which writes wiki/concepts-grid.md) is
52
+ never reachable through /wiki-sync, /pipeline, or any orchestrated flow.
53
+ Every ingest then files every concept page under the reserved
54
+ `unclassified` class forever, with nothing surfacing why.
55
+ */
56
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
57
+ const compose = YAML.parse(raw);
58
+ const allowed = compose.services['production-mcp'].environment
59
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
60
+ assert.match(String(allowed), /(?:^|,)concepts(?:,|})/);
61
+ });
62
+
63
+ test('every shipped default allows the reclassify-concepts step', async () => {
64
+ /*
65
+ One step further than `concepts`: without `reclassify-concepts`, a page
66
+ already stuck under wiki/concepts/unclassified stays there even after a
67
+ grid exists — re-ingesting its source is not a reliable fix, since the
68
+ ingest prompt updates an existing leaf at its existing path instead of
69
+ moving it.
70
+ */
71
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
72
+ const compose = YAML.parse(raw);
73
+ const allowed = compose.services['production-mcp'].environment
74
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
75
+ assert.match(String(allowed), /(?:^|,)reclassify-concepts(?:,|})/);
76
+ });
77
+
48
78
  test('shipped compose files never carry a build context', async () => {
49
79
  // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
50
80
  // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.59';
4
+ const WIKI_MANAGER_VERSION = '0.15.62';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -76,6 +76,17 @@ function normalizeExternalUrlForRuntime(url) {
76
76
  // server's entry shaped differently from every other. It is folded into
77
77
  // `allow` on read so existing installs keep working without regenerating
78
78
  // their endpoints file, but nothing writes it any more.
79
+ // The packaged example and every scaffolded mcp.endpoints.json declare the
80
+ // two built-in workspace servers' chatAccess under their public/documented
81
+ // names ("llm-wiki", "wiki-production" — see PROTECTED_SERVERS in
82
+ // mcpEndpoints.js and the root CLAUDE.md). Those servers are actually
83
+ // discovered into session.mcp under different internal keys ("wiki",
84
+ // "production" — MCP_SERVICE_MAP below). Without this alias, chatAllowedTools'
85
+ // intersection of session.mcp against chatAccess.servers never matches the
86
+ // built-in servers, so /chat silently gets zero wiki/production tools no
87
+ // matter what is configured — alias both spellings onto the internal key.
88
+ const BUILTIN_CHAT_ACCESS_ALIASES = { 'llm-wiki': 'wiki', 'wiki-production': 'production' };
89
+
79
90
  export function readChatAccessConfig() {
80
91
  const filePath = managerMcpEndpointsFile();
81
92
  if (!existsSync(filePath)) return null;
@@ -84,19 +95,21 @@ export function readChatAccessConfig() {
84
95
  const chatAccess = raw?.chatAccess;
85
96
  if (!chatAccess || typeof chatAccess !== 'object' || Array.isArray(chatAccess)) return null;
86
97
  const servers = {};
87
- for (const [name, entry] of Object.entries(chatAccess.servers ?? {})) {
98
+ for (const [rawName, entry] of Object.entries(chatAccess.servers ?? {})) {
99
+ const name = BUILTIN_CHAT_ACCESS_ALIASES[rawName] ?? rawName;
88
100
  // "*" is also commonly written as a one-element array (["*"]) since every
89
101
  // other "allow" example in this config is an array of tool names — treat
90
102
  // both forms as the same wildcard rather than silently allowing nothing.
91
103
  const legacyActions = Array.isArray(entry?.allowActions)
92
104
  ? entry.allowActions.map(String).filter(Boolean)
93
105
  : [];
94
- if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
106
+ const isWildcard = entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*');
107
+ const priorAllow = servers[name]?.allow;
108
+ if (isWildcard || priorAllow === '*') {
95
109
  servers[name] = { allow: '*' };
96
- } else if (Array.isArray(entry?.allow)) {
97
- servers[name] = { allow: [...new Set([...entry.allow.map(String).filter(Boolean), ...legacyActions])] };
98
- } else if (legacyActions.length > 0) {
99
- servers[name] = { allow: legacyActions };
110
+ } else if (Array.isArray(entry?.allow) || legacyActions.length > 0) {
111
+ const merged = [...(Array.isArray(priorAllow) ? priorAllow : []), ...(Array.isArray(entry?.allow) ? entry.allow.map(String).filter(Boolean) : []), ...legacyActions];
112
+ servers[name] = { allow: [...new Set(merged)] };
100
113
  }
101
114
  }
102
115
  const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
@@ -9,6 +9,7 @@ import {
9
9
  callMcpTool,
10
10
  discoverMcpTools,
11
11
  formatMcpToolsForAgent,
12
+ readChatAccessConfig,
12
13
  resetMcpSessionsForTests,
13
14
  resetMcpThrottleForTests,
14
15
  resolveRetryPolicy,
@@ -159,6 +160,71 @@ test('buildMcpStatus interpolates external endpoints from manager .env', async (
159
160
  }
160
161
  });
161
162
 
163
+ test('readChatAccessConfig aliases the built-in servers\' public names onto their internal session.mcp keys', async () => {
164
+ // Every shipped example and scaffolded mcp.endpoints.json declares
165
+ // chatAccess for the built-in workspace servers under their public names
166
+ // ("llm-wiki", "wiki-production"), but session.mcp discovers them under
167
+ // different internal keys ("wiki", "production" — MCP_SERVICE_MAP).
168
+ // Without aliasing, chatAllowedTools' intersection against session.mcp
169
+ // never matches these entries and /chat silently gets zero wiki/production
170
+ // tools no matter what is configured.
171
+ const originalCwd = process.cwd();
172
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-chat-access-'));
173
+ await writeFile(
174
+ path.join(root, 'mcp.endpoints.json'),
175
+ JSON.stringify({
176
+ mcpServers: {},
177
+ chatAccess: {
178
+ maxToolIterations: 8,
179
+ servers: {
180
+ 'llm-wiki': { allow: ['wiki_search_context', 'wiki_read_page'] },
181
+ 'wiki-production': { allow: ['production_job_status'] },
182
+ cme: { allow: ['cme_status'] },
183
+ },
184
+ },
185
+ }),
186
+ 'utf8',
187
+ );
188
+
189
+ try {
190
+ process.chdir(root);
191
+ const config = readChatAccessConfig();
192
+ assert.deepEqual(config.servers.wiki, { allow: ['wiki_search_context', 'wiki_read_page'] });
193
+ assert.deepEqual(config.servers.production, { allow: ['production_job_status'] });
194
+ assert.deepEqual(config.servers.cme, { allow: ['cme_status'] });
195
+ assert.equal(config.servers['llm-wiki'], undefined);
196
+ assert.equal(config.servers['wiki-production'], undefined);
197
+ } finally {
198
+ process.chdir(originalCwd);
199
+ }
200
+ });
201
+
202
+ test('readChatAccessConfig merges a wildcard from either the public or internal built-in name', async () => {
203
+ const originalCwd = process.cwd();
204
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-chat-access-wildcard-'));
205
+ await writeFile(
206
+ path.join(root, 'mcp.endpoints.json'),
207
+ JSON.stringify({
208
+ mcpServers: {},
209
+ chatAccess: {
210
+ servers: {
211
+ wiki: { allow: ['wiki_read_page'] },
212
+ 'llm-wiki': { allow: '*' },
213
+ },
214
+ },
215
+ }),
216
+ 'utf8',
217
+ );
218
+
219
+ try {
220
+ process.chdir(root);
221
+ const config = readChatAccessConfig();
222
+ assert.deepEqual(config.servers.wiki, { allow: '*' });
223
+ } finally {
224
+ process.chdir(originalCwd);
225
+ }
226
+ });
227
+
162
228
  test('buildMcpStatus reloads external endpoint keys changed in manager .env', async () => {
163
229
  const originalCwd = process.cwd();
164
230
  const originalToken = process.env.TEST_EXTERNAL_TOKEN;
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
31
31
  });
32
32
 
33
33
  test('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
34
- const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
34
+ const expected = { pipeline: 1, 'wiki-ingest': 2, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
35
35
  for (const [name, count] of Object.entries(expected)) {
36
36
  const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
37
37
  const { meta, body } = parseFrontmatter(raw);
@@ -106,9 +106,22 @@ function resolveMentionedRegistryOperation(objective, candidates) {
106
106
  const text = normalizeText(objective);
107
107
 
108
108
  const aliasHits = candidates
109
- .filter((candidate) => (candidate.aliases ?? []).some((alias) =>
110
- phraseIn(normalizePhrase(alias), words, text)))
111
- .map((candidate) => ({ capability: candidate.id, operation: candidate.operations[0] }));
109
+ .map((candidate) => {
110
+ const matchedAlias = (candidate.aliases ?? []).find((alias) =>
111
+ phraseIn(normalizePhrase(alias), words, text));
112
+ if (matchedAlias === undefined) return null;
113
+ // A capability with more than one operation may declare
114
+ // aliasOperations, mapping the specific alias phrase that matched to
115
+ // the operation it actually names. Without it, operations[0]
116
+ // (alphabetical) is a silent guess: for knowledge.concepts this always
117
+ // picked the destructive grid-rebuild "concepts" operation, even when
118
+ // the matched alias ("reclassify concepts", "file unclassified
119
+ // concepts") named the safe, mechanical "reclassify-concepts" one —
120
+ // making that operation structurally unreachable from natural language.
121
+ const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
122
+ return { capability: candidate.id, operation };
123
+ })
124
+ .filter(Boolean);
112
125
  if (aliasHits.length === 1) return aliasHits[0];
113
126
  if (aliasHits.length > 1) return null;
114
127
 
@@ -142,8 +155,12 @@ export function capabilityCandidates(session) {
142
155
  const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
143
156
  const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
144
157
  const aliases = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.aliases ?? []))].sort();
158
+ const aliasOperations = Object.assign(
159
+ {},
160
+ ...(providers ?? []).map((provider) => provider?.capability?.aliasOperations ?? {}),
161
+ );
145
162
  const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
146
- byId.set(id, { id, description, operations, aliases });
163
+ byId.set(id, { id, description, operations, aliases, aliasOperations });
147
164
  }
148
165
  return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
149
166
  }
@@ -7,8 +7,8 @@ import {
7
7
  ObjectiveNotOrchestrableError,
8
8
  } from './objectiveResolver.js';
9
9
 
10
- function makeCapability(id, { operations = [], aliases = [], description = '' } = {}) {
11
- return { id, version: '1', description, supportedOperations: operations, aliases };
10
+ function makeCapability(id, { operations = [], aliases = [], aliasOperations = {}, description = '' } = {}) {
11
+ return { id, version: '1', description, supportedOperations: operations, aliases, aliasOperations };
12
12
  }
13
13
 
14
14
  function provider(agentInstanceId, capability) {
@@ -63,6 +63,7 @@ test('capabilityCandidates exposes aliases from the closed live registry', () =>
63
63
  description: 'Update knowledge from pending sources.',
64
64
  operations: ['ingest', 'ingest_apply', 'ingest_plan'],
65
65
  aliases: ['ingest', 'ingestion'],
66
+ aliasOperations: {},
66
67
  }]);
67
68
  });
68
69
 
@@ -121,6 +122,67 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
121
122
  assert.equal(result.operation, 'doctor');
122
123
  });
123
124
 
125
+ const concepts = makeCapability('knowledge.concepts', {
126
+ operations: ['concepts', 'reclassify-concepts'],
127
+ aliases: ['concept grid', 'reclassify concepts', 'file unclassified concepts'],
128
+ aliasOperations: {
129
+ 'concept grid': 'concepts',
130
+ 'reclassify concepts': 'reclassify-concepts',
131
+ 'file unclassified concepts': 'reclassify-concepts',
132
+ },
133
+ description: 'Synthesize the concept grid or file unclassified pages into it.',
134
+ });
135
+
136
+ /*
137
+ Regression: with two operations, [...new Set(supportedOperations)].sort()
138
+ alphabetizes to ["concepts", "reclassify-concepts"], so a naive alias hit
139
+ defaulting to operations[0] would ALWAYS resolve to "concepts" — the
140
+ destructive grid rebuild — even for aliases explicitly authored to reach the
141
+ safe "reclassify-concepts" operation. aliasOperations must be consulted
142
+ first.
143
+ */
144
+ test('resolveObjective routes "reclassify concepts" to reclassify-concepts, not operations[0]', async () => {
145
+ const session = sessionWith([provider('production-1', concepts)]);
146
+ session.llm.completeWithTools = async () => {
147
+ throw new Error('the aliased operation must not depend on LLM selection');
148
+ };
149
+ const result = await resolveObjective('Please reclassify concepts in the workspace', session);
150
+ assert.equal(result.capability, 'knowledge.concepts');
151
+ assert.equal(result.operation, 'reclassify-concepts');
152
+ });
153
+
154
+ test('resolveObjective routes "file unclassified concepts" to reclassify-concepts', async () => {
155
+ const session = sessionWith([provider('production-1', concepts)]);
156
+ session.llm.completeWithTools = async () => {
157
+ throw new Error('the aliased operation must not depend on LLM selection');
158
+ };
159
+ const result = await resolveObjective('File unclassified concepts into the grid', session);
160
+ assert.equal(result.capability, 'knowledge.concepts');
161
+ assert.equal(result.operation, 'reclassify-concepts');
162
+ });
163
+
164
+ test('resolveObjective routes "concept grid" to the concepts operation', async () => {
165
+ const session = sessionWith([provider('production-1', concepts)]);
166
+ session.llm.completeWithTools = async () => {
167
+ throw new Error('the aliased operation must not depend on LLM selection');
168
+ };
169
+ const result = await resolveObjective('Rebuild the concept grid', session);
170
+ assert.equal(result.capability, 'knowledge.concepts');
171
+ assert.equal(result.operation, 'concepts');
172
+ });
173
+
174
+ test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {
175
+ // A capability that never declares aliasOperations (every existing
176
+ // single-operation capability) must keep working exactly as before.
177
+ const session = sessionWith(
178
+ [provider('production-1', diagnose)],
179
+ { capability: 'workspace.diagnose', operation: 'doctor' },
180
+ );
181
+ const result = await resolveObjective('diagnose the workspace', session);
182
+ assert.equal(result.capability, 'workspace.diagnose');
183
+ assert.equal(result.operation, 'doctor');
184
+ });
185
+
124
186
  test('objectiveForResolution strips notification and negative guardrails', () => {
125
187
  const clean = objectiveForResolution(
126
188
  'Ingest files. Do not build or publish deliverables. If a messaging connector is available, send a summary; otherwise skip notification silently.',
@@ -1,43 +1,27 @@
1
- // Deterministic, localized messages for the runtime control lane.
1
+ // Deterministic, English-only messages for the runtime control lane.
2
2
  //
3
3
  // Control-lane acknowledgements (run queued, ambiguous input, conversation
4
4
  // fallback…) are intentionally NOT generated by Donna: spending an LLM turn
5
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.
6
+ // cost the orchestration refactor removed. They are therefore English-only —
7
+ // the one language this deterministic lane can guarantee — and are never
8
+ // localized by a hardcoded fr/en catalog (a French-only fallback would still
9
+ // leave every other language unanswered). Localized, personalised replies are
10
+ // Donna's job on the conversational paths (see generateSkillAcknowledgment).
11
+ // This catalog is the single source for these strings — never hardcode a
12
+ // control-lane message in the shell or the server directly.
10
13
 
11
14
  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: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
22
- fr: 'Un run est déjà actif et ta demande ressemble à une nouvelle action. Dis « mets en file » pour l\'exécuter après le run en cours, « modifie le run » pour changer le plan actif, « annule » pour arrêter le run actuel — ou attends la fin.',
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
- },
15
+ queued_for_future_run: 'Request added to the queue — it will start automatically after the current run.',
16
+ control_run_started: 'A queued request is now starting.',
17
+ plan_patch_proposed: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
18
+ ambiguous_control: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
19
+ converse_while_running: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
20
+ converse_while_idle: 'Runtime is idle. This message was treated as conversation and did not create a run.',
32
21
  };
33
22
 
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) {
23
+ export function controlMessage(_session, key) {
40
24
  const entry = CONTROL_MESSAGES[key];
41
25
  if (!entry) throw new Error(`Unknown control message key: ${key}`);
42
- return entry[controlLanguage(session)] ?? entry.en;
26
+ return entry;
43
27
  }
@@ -1,21 +1,15 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { controlLanguage, controlMessage } from './controlMessages.js';
3
+ import { controlMessage } from './controlMessages.js';
4
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');
5
+ test('controlMessage returns the deterministic English acknowledgement regardless of locale', () => {
6
+ assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /added to the queue/);
7
+ assert.match(controlMessage({ language: 'es' }, 'queued_for_future_run'), /added to the queue/);
8
+ assert.match(controlMessage(null, 'queued_for_future_run'), /added to the queue/);
11
9
  });
12
10
 
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/);
11
+ test('controlMessage keeps every key in English and throws on unknown keys', () => {
12
+ assert.match(controlMessage({ language: 'fr' }, 'ambiguous_control'), /queue it/);
13
+ assert.match(controlMessage({ language: 'fr' }, 'converse_while_idle'), /treated as conversation/);
20
14
  assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
21
15
  });
@@ -11,7 +11,8 @@ import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { matchSkillInvocation } from '../core/skillInvocation.js';
12
12
  import { reconcileControlQueue } from './controlDrain.js';
13
13
  import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
14
- import { runSkillChain } from './skillRun.js';
14
+ import { generateSkillAcknowledgment, runSkillChain } from './skillRun.js';
15
+ import { emitRuntimeLog } from './supervisor.js';
15
16
  import { findSkill, listSkills } from '../core/skills.js';
16
17
 
17
18
  const PRIVATE_CONTROL_INPUTS = new WeakMap();
@@ -347,7 +348,8 @@ export function startRuntimeServer({
347
348
  if (skillMatch) {
348
349
  try {
349
350
  const result = await enqueueSkillInvocation(context, skillMatch);
350
- sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
351
+ const explanation = await generateSkillAcknowledgment(context.session, result);
352
+ sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
351
353
  } catch (err) {
352
354
  const error = skillInvocationErrorMessage(err);
353
355
  publishSkillInvocationFailure(context, input, error);
@@ -421,7 +423,8 @@ export function startRuntimeServer({
421
423
  if (skillMatch) {
422
424
  try {
423
425
  const result = await enqueueSkillInvocation(context, skillMatch);
424
- sendJson(response, 202, { accepted: true, kind: 'skill_chain', ...result, ...controlStatus(context, store) });
426
+ const explanation = await generateSkillAcknowledgment(context.session, result);
427
+ sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
425
428
  } catch (err) {
426
429
  const error = skillInvocationErrorMessage(err);
427
430
  publishSkillInvocationFailure(context, input, error);
@@ -778,7 +781,7 @@ export function startRuntimeServer({
778
781
  return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued, ...(purged !== null ? { purged } : {}) };
779
782
  }
780
783
 
781
- function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false } = {}) {
784
+ function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false, announceLaunch = false } = {}) {
782
785
  const runId = randomUUID();
783
786
  const runWorkspace = context.workspace ?? body.workspace ?? null;
784
787
  context.running = true;
@@ -801,6 +804,14 @@ export function startRuntimeServer({
801
804
  workspace: runWorkspace,
802
805
  payload: { id: controlItemId, runId },
803
806
  }));
807
+ // A control item is now a live run: announce it in the conversation so the
808
+ // "queued" acknowledgement is closed out by a "starting" one. A task is a
809
+ // task whether it waited or not — it is about to go through approval — so
810
+ // this is not gated on having waited. Skill-chain steps are skipped: they
811
+ // already announced the whole skill at invocation.
812
+ if (announceLaunch) {
813
+ announceControlLaunch(context.session, body.publicInput ?? body.input, runWorkspace);
814
+ }
804
815
  }
805
816
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
806
817
  runPromise
@@ -858,7 +869,7 @@ export function startRuntimeServer({
858
869
  },
859
870
  }
860
871
  : {}),
861
- }, { controlItemId: item.id }),
872
+ }, { controlItemId: item.id, announceLaunch: !item.chainId }),
862
873
  skipItem: (item, reason) => {
863
874
  privateControlInputsFor(context.session).delete(item.id);
864
875
  emitControlSkipped(context, item, reason);
@@ -1089,7 +1100,11 @@ export function approvalRequestFromStatus(status) {
1089
1100
 
1090
1101
  async function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false, cancel = null, approve = null } = {}) {
1091
1102
  const status = controlStatus(context, store);
1092
- const classification = classifyControlMessage(input, status, intent);
1103
+ const classification = await classifyControlMessage(input, status, {
1104
+ forcedIntent: intent,
1105
+ llm: context?.session?.llm,
1106
+ session: context?.session,
1107
+ });
1093
1108
  if (classification.kind === 'observe') {
1094
1109
  return readOnlyControlResponse('observe', classification, status, explainControlState(status));
1095
1110
  }
@@ -1127,6 +1142,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
1127
1142
  // startNextControlRequest), which can change running/plan/status — a full
1128
1143
  // controlStatus() recompute is required here, not just controlQueue.
1129
1144
  void startNextControlRequest(context);
1145
+ const explanation = await generateControlAcknowledgment(context?.session, { kind: 'queued', input });
1130
1146
  return {
1131
1147
  statusCode: 202,
1132
1148
  body: {
@@ -1135,7 +1151,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
1135
1151
  classification,
1136
1152
  item,
1137
1153
  ...controlStatus(context, store),
1138
- explanation: controlMessage(context?.session, 'queued_for_future_run'),
1154
+ explanation,
1139
1155
  },
1140
1156
  };
1141
1157
  }
@@ -1156,6 +1172,61 @@ async function handleControlMessage(context, store, input, { intent = null, star
1156
1172
  : controlMessage(context?.session, 'converse_while_idle'));
1157
1173
  }
1158
1174
 
1175
+ /*
1176
+ Control-lane acknowledgements are Donna's to localize.
1177
+
1178
+ The control lane stays deterministic in its CLASSIFICATION and its actions,
1179
+ but the acknowledgement the user reads ("queued, will run after this one" /
1180
+ "the queued task is starting") is a conversational reply: it goes through a
1181
+ single bounded LLM completion, like the skill-launch acknowledgement, and
1182
+ falls back to the deterministic English catalog when no LLM is configured or
1183
+ the call fails. The fallback is what keeps the lane deterministic-under-failure.
1184
+ */
1185
+ async function generateControlAcknowledgment(session, { kind, input }) {
1186
+ const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
1187
+ const llm = session?.llm;
1188
+ const fallback = kind === 'queued'
1189
+ ? controlMessage(session, 'queued_for_future_run')
1190
+ : controlMessage(session, 'control_run_started');
1191
+ if (llm && typeof llm.complete === 'function') {
1192
+ try {
1193
+ const scenario = kind === 'queued'
1194
+ ? 'The user requested a new task while a run is active. It was queued and will start automatically after the current run finishes.'
1195
+ : 'A task the user queued earlier is now starting.';
1196
+ const instruction = kind === 'queued'
1197
+ ? 'their request is queued and will run after the current run finishes'
1198
+ : 'the queued task is now starting';
1199
+ const reply = await llm.complete({
1200
+ system: 'You are Donna, the workspace assistant. You acknowledge a runtime queue event in the user\'s language. Be concise: exactly one short sentence.',
1201
+ input: `${scenario}\n\nThe task is: ${input}\n\nWrite ONE short sentence in ${language} that tells the user ${instruction}. Return only that sentence, nothing else.`,
1202
+ signal: AbortSignal.timeout(8_000),
1203
+ });
1204
+ const text = String(reply ?? '').trim();
1205
+ if (text) return text;
1206
+ emitRuntimeLog(session, 'control-acknowledgment: LLM returned an empty reply, using the deterministic fallback');
1207
+ } catch (err) {
1208
+ // A degradation must announce itself: silently falling through here
1209
+ // hides the difference between "no LLM configured" (expected) and "the
1210
+ // configured LLM is failing every call" (a real problem) — both would
1211
+ // otherwise look identical from the Shell or serve UI.
1212
+ emitRuntimeLog(session, `control-acknowledgment: LLM call failed, using the deterministic fallback — ${err instanceof Error ? err.message : String(err)}`);
1213
+ }
1214
+ }
1215
+ return fallback;
1216
+ }
1217
+
1218
+ function announceControlLaunch(session, input, workspace) {
1219
+ void generateControlAcknowledgment(session, { kind: 'started', input })
1220
+ .then((content) => {
1221
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
1222
+ origin: 'runtime',
1223
+ workspace,
1224
+ payload: { content, independent: true },
1225
+ }));
1226
+ })
1227
+ .catch(() => {});
1228
+ }
1229
+
1159
1230
  /*
1160
1231
  `skillStack` accompagne l'élément, il ne vit pas sur la session.
1161
1232
 
@@ -1351,13 +1422,15 @@ function rejectPlanPatch(context, store, patchId, reason) {
1351
1422
  };
1352
1423
  }
1353
1424
 
1354
- // Interim classifier for control §4.2 of the plan directeur: the plan expects
1355
- // an LLM-backed classification eventually ("la classification LLM se
1356
- // trompera" — the plan's own fallback-UX rule presupposes an LLM). This is a
1357
- // synchronous keyword/regex stand-in with the same {kind, confidence, reason}
1358
- // contract, so swapping in an LLM call later shouldn't require touching
1359
- // handleControlMessage.
1360
- function classifyControlMessage(input, status, forcedIntent = null) {
1425
+ // Classifier for control §4.2 of the plan directeur. The plan expects an
1426
+ // LLM-backed classification — "the classification LLM se trompera" — and this
1427
+ // is that, now: the only deterministic matches left are the runtime's own
1428
+ // control verbs (cancel, an explicit "later/queue", status and plan-change
1429
+ // wording). Deciding "is this a NEW task to queue vs plain conversation" is a
1430
+ // semantic judgement about the workspace's domain, so it is never a keyword
1431
+ // list here — it goes to the model, bounded, and falls back to the choice menu
1432
+ // (`ambiguous`) rather than guessing when no model is available.
1433
+ async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1361
1434
  // Caller (the /control message route) already trims and rejects empty input.
1362
1435
  const lower = String(input ?? '').toLowerCase();
1363
1436
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
@@ -1375,22 +1448,53 @@ function classifyControlMessage(input, status, forcedIntent = null) {
1375
1448
  if (explicit) {
1376
1449
  return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
1377
1450
  }
1451
+ // Cancel stays a keyword: it is a runtime control verb, and an abort must not
1452
+ // wait on a model round-trip.
1378
1453
  if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
1379
1454
  return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
1380
1455
  }
1381
1456
  if (/\b(plus tard|later|ensuite|apr[eè]s ce run|enqueue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
1382
1457
  return { kind: 'enqueue_run', confidence: 0.8, reason: 'future_run_request' };
1383
1458
  }
1384
- if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
1459
+ if (/\b(o[uù] en es[t-]|status|statut|progress|progression|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
1385
1460
  return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
1386
1461
  }
1387
1462
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
1388
1463
  return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
1389
1464
  }
1390
- if (status.running && /\b(lance|run|g[eé]n[eè]re|build|export|cr[eé]e|create|send|envoie|ingest|convert|importe|import)\b/i.test(lower)) {
1391
- return { kind: 'ambiguous', confidence: 0.45, reason: 'active_run_action_is_ambiguous' };
1465
+ if (!status.running) return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
1466
+ // A run is active and none of the runtime control verbs matched. The message
1467
+ // is either a request to perform a NEW mutating task (→ queue it to run
1468
+ // after the current one) or ordinary conversation — that is a judgement about
1469
+ // the workspace's domain, so the model decides it, never a keyword list.
1470
+ if (llm && typeof llm.complete === 'function') {
1471
+ try {
1472
+ const reply = await llm.complete({
1473
+ system: 'You classify one user message typed while a run is already active. Return exactly one word, nothing else.',
1474
+ input: [
1475
+ `The user typed this while a run is active: "${input}"`,
1476
+ '',
1477
+ 'Choose ONE of:',
1478
+ '- "action" — a request to perform a NEW task (generate, create, ingest, build, export, convert, send, publish, produce…), which must run after the current run.',
1479
+ '- "conversation" — ordinary conversation, a question, or an unrelated remark.',
1480
+ '',
1481
+ 'Return only that one word.',
1482
+ ].join('\n'),
1483
+ signal: AbortSignal.timeout(8_000),
1484
+ });
1485
+ const kind = String(reply ?? '').trim().toLowerCase();
1486
+ if (kind.startsWith('action')) return { kind: 'enqueue_run', confidence: 0.85, reason: 'llm_classified_action' };
1487
+ if (kind.startsWith('conversation')) return { kind: 'converse', confidence: 0.85, reason: 'llm_classified_conversation' };
1488
+ emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, falling back to the choice menu — ${JSON.stringify(kind).slice(0, 200)}`);
1489
+ } catch (err) {
1490
+ // A degradation must announce itself: silently falling through here
1491
+ // hides the difference between "no LLM configured" (expected) and "the
1492
+ // configured LLM is failing every call" (a real problem) — both would
1493
+ // otherwise look identical from the Shell or serve UI.
1494
+ emitRuntimeLog(session, `control-classify: LLM call failed, falling back to the choice menu — ${err instanceof Error ? err.message : String(err)}`);
1495
+ }
1392
1496
  }
1393
- return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
1497
+ return { kind: 'ambiguous', confidence: 0.45, reason: 'action_vs_conversation_unclear' };
1394
1498
  }
1395
1499
 
1396
1500
  function isAuthorized(request, token) {
@@ -1434,10 +1434,14 @@ test('runtime server control message records active plan mutation as a proposal'
1434
1434
  }
1435
1435
  });
1436
1436
 
1437
- test('runtime server control message reports ambiguity without starting a run', async (t) => {
1437
+ test('runtime server auto-queues a clear new action while a run is active', async (t) => {
1438
1438
  const session = {
1439
1439
  workspace: 'acme',
1440
1440
  controlQueue: [],
1441
+ _onAgentEvent: () => {},
1442
+ // The classifier asks the model whether the message is a new action; a
1443
+ // keyword list is deliberately not part of the code path.
1444
+ llm: { complete: async () => 'action' },
1441
1445
  };
1442
1446
  let runCount = 0;
1443
1447
  let handle;
@@ -1451,6 +1455,7 @@ test('runtime server control message reports ambiguity without starting a run',
1451
1455
  status: 'running',
1452
1456
  plan: [{ step: 1, description: 'Generate', status: 'running' }],
1453
1457
  queue: [],
1458
+ controlQueue: session.controlQueue,
1454
1459
  approvals: [],
1455
1460
  summary: null,
1456
1461
  }),
@@ -1478,12 +1483,11 @@ test('runtime server control message reports ambiguity without starting a run',
1478
1483
  headers: { 'Content-Type': 'application/json' },
1479
1484
  body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
1480
1485
  });
1481
- assert.equal(response.status, 200);
1486
+ assert.equal(response.status, 202);
1482
1487
  const body = await response.json();
1483
- assert.equal(body.kind, 'ambiguous');
1484
- assert.equal(body.choices.length, 3);
1485
- assert.equal(session.controlQueue.length, 0);
1486
- assert.equal(runCount, 0);
1488
+ assert.equal(body.kind, 'enqueue_run');
1489
+ assert.equal(body.item.status, 'queued');
1490
+ assert.equal(runCount, 0, 'the queued task must not start while the current run is active');
1487
1491
  } finally {
1488
1492
  await handle.close();
1489
1493
  }
@@ -1545,7 +1549,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
1545
1549
  assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
1546
1550
  assert.equal(session.controlQueue[0].status, 'running');
1547
1551
  assert.equal(session.controlQueue[0].runId, receivedBody.runId);
1548
- assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started']);
1552
+ assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started', 'assistant_message']);
1549
1553
  } finally {
1550
1554
  await handle.close();
1551
1555
  }
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
142
142
  assert.equal(body.objectives, 2);
143
143
  assert.equal(env.runs.length, 2, 'the second objective must run after the first');
144
144
  assert.match(env.runs[0].input, /^Export the requested Confluence source/);
145
- assert.match(env.runs[1].input, /^Ingest the newly exported Markdown/);
145
+ assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
146
146
  // CME first, Production second — and the parameter reaches the step that
147
147
  // consumes it, not only the last objective.
148
148
  for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
202
202
  // that silently fragments would show up as extra runs, not as extra objectives.
203
203
  const PERFORMANCE_TABLE = {
204
204
  pipeline: 1,
205
- 'wiki-ingest': 1,
205
+ 'wiki-ingest': 2,
206
206
  'wiki-build': 1,
207
207
  deliver: 1,
208
208
  diagnose: 1,
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { applyLegacySkillPlaceholders, parseSkillArguments } from '../core/skillInvocation.js';
3
3
  import { compileSkillObjectives, createSkillCompilerFallback } from '../core/skillCompiler.js';
4
+ import { emitRuntimeLog } from './supervisor.js';
4
5
 
5
6
  const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
6
7
  const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
@@ -86,6 +87,7 @@ export async function runSkillChain(context, skill, {
86
87
  skill: skill.name,
87
88
  objectives: objectives.length,
88
89
  items,
90
+ publicInput,
89
91
  deprecatedPlaceholders: legacy.deprecatedPlaceholders,
90
92
  };
91
93
  }
@@ -97,6 +99,48 @@ export function formatPublicSkillInvocation(name, args = {}) {
97
99
  return `/${String(name ?? '').trim()}${values.length ? ` ${values.join(' ')}` : ''}`;
98
100
  }
99
101
 
102
+ /**
103
+ * Donna's launch acknowledgement for an executable skill.
104
+ *
105
+ * The reply is user-facing and echoes the caller's own arguments (a source, a
106
+ * file, a deliverable), so it must be generated by Donna in the session's
107
+ * configured language — a hardcoded fr/en catalog cannot cover every language
108
+ * and would drop the very parameters the user asked for. This is the single
109
+ * place that turns a compiled skill chain into a conversational reply; the
110
+ * browser and the shell both read the resulting `explanation`, never a
111
+ * language-specific string of their own.
112
+ *
113
+ * Degrades honestly: without an LLM client (no `.wikirc` LLM configured) there
114
+ * is nothing to localize with, so it falls back to a neutral, language-free
115
+ * acknowledgement instead of guessing a language.
116
+ */
117
+ export async function generateSkillAcknowledgment(session, { publicInput, objectives }) {
118
+ const count = Number(objectives) || 1;
119
+ const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
120
+ const llm = session?.llm;
121
+ if (llm && typeof llm.complete === 'function') {
122
+ try {
123
+ // Same bound as compileSkillObjectives' llmFallback above: a hung or
124
+ // slow provider must not block the skill-launch HTTP response forever.
125
+ const reply = await llm.complete({
126
+ system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
127
+ input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Return only that sentence, nothing else.`,
128
+ signal: AbortSignal.timeout(8_000),
129
+ });
130
+ const text = String(reply ?? '').trim();
131
+ if (text) return text;
132
+ emitRuntimeLog(session, 'skill-acknowledgment: LLM returned an empty reply, using the neutral fallback');
133
+ } catch (err) {
134
+ // A degradation must announce itself: silently falling through here
135
+ // hides the difference between "no LLM configured" (expected) and "the
136
+ // configured LLM is failing every call" (a real problem) — both would
137
+ // otherwise look identical from the Shell or serve UI.
138
+ emitRuntimeLog(session, `skill-acknowledgment: LLM call failed, using the neutral fallback — ${err instanceof Error ? err.message : String(err)}`);
139
+ }
140
+ }
141
+ return `Started ${publicInput} — ${count} step(s) in progress.`;
142
+ }
143
+
100
144
  function argumentError(message) {
101
145
  const error = new Error(message);
102
146
  error.code = 'skill_arguments_invalid';
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { formatPublicSkillInvocation, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
3
+ import { formatPublicSkillInvocation, generateSkillAcknowledgment, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
4
4
 
5
5
  const skill = { name: 'deliver', params: ['deliverable', 'polish'], body: 'Deliver the requested output.' };
6
6
 
@@ -82,3 +82,38 @@ test('runSkillChain starts a fresh stack for a top-level invocation', async () =
82
82
 
83
83
  assert.deepEqual(queued[0].skillStack, ['deliver']);
84
84
  });
85
+
86
+ test('generateSkillAcknowledgment asks Donna in the session language and echoes the invocation', async () => {
87
+ const calls = [];
88
+ const session = {
89
+ language: 'es',
90
+ llm: { complete: async (request) => { calls.push(request); return 'Lanzado /deliver deliverable="Informe" — 1 paso en cola.'; } },
91
+ };
92
+ const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver deliverable="Informe"', objectives: 1 });
93
+ assert.equal(reply, 'Lanzado /deliver deliverable="Informe" — 1 paso en cola.');
94
+ assert.equal(calls.length, 1);
95
+ assert.match(calls[0].input, /es/);
96
+ assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
97
+ });
98
+
99
+ test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
100
+ const reply = await generateSkillAcknowledgment({ language: 'fr' }, { publicInput: '/wiki-ingest docs', objectives: 2 });
101
+ assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s) in progress.');
102
+ });
103
+
104
+ test('generateSkillAcknowledgment falls back when the LLM call fails', async () => {
105
+ const session = { language: 'en', llm: { complete: async () => { throw new Error('down'); } } };
106
+ const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
107
+ assert.equal(reply, 'Started /deliver — 1 step(s) in progress.');
108
+ });
109
+
110
+ test('generateSkillAcknowledgment announces an LLM failure instead of degrading silently', async () => {
111
+ // A degradation must announce itself: falling back to the neutral message
112
+ // with no trace anywhere makes "LLM unconfigured" (expected) and "LLM
113
+ // failing every call" (a real problem) look identical in the UI.
114
+ const session = { language: 'en', llm: { complete: async () => { throw new Error('provider timeout'); } } };
115
+ await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
116
+ const runtimeLogs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log');
117
+ assert.equal(runtimeLogs.length, 1);
118
+ assert.match(runtimeLogs[0].payload.detail ?? runtimeLogs[0].payload.message, /provider timeout/);
119
+ });
@@ -859,7 +859,7 @@ export function LeftPane(props: {
859
859
  above the composer keeps the current job in view while composing; the
860
860
  right pane keeps the full Plan/Queue/Logs detail.
861
861
  */}
862
- <box flexShrink={0} height={4} flexDirection="column" overflow="hidden">
862
+ <box flexShrink={0} height={4} flexDirection="column" overflow="hidden" backgroundColor="#111318">
863
863
  <ActivityPanel activities={props.activities} width={props.width - 2} />
864
864
  </box>
865
865
  <ChatInput
@@ -20,7 +20,10 @@ type LogLineParts = { time: string | null; message: string };
20
20
  // 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
21
21
  // wrapped status/error), so fewer, readable entries beat more, truncated ones.
22
22
  const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
23
- const PLAN_VIEWPORT_ROWS = 12;
23
+ // Hauteur du panneau Plan : 6 lignes visibles au maximum. Les etapes suivantes
24
+ // restent atteignables en faisant defiler la scrollbox (barre de defilement
25
+ // affichee des que le plan depasse la fenetre).
26
+ const PLAN_VIEWPORT_ROWS = 6;
24
27
 
25
28
  function wrapLine(value: string, width: number) {
26
29
  const max = Math.max(8, width);
@@ -194,12 +197,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
194
197
  // Keep one column for the native vertical scrollbar when the plan is long.
195
198
  const lineWidth = () => Math.max(8, props.width - 3);
196
199
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
197
- // A running step carries a thick left border, which costs one column: the
198
- // wrap width is therefore one less for it. The same function feeds both the
199
- // row-count memo and the render, so the viewport height can never undercount
200
- // a running step's wrapped lines and clip the last one.
201
- const isRunningStep = (step: PlanStep) => String(step.status ?? '').toLowerCase() === 'running';
202
- const stepTextWidth = (step: PlanStep) => lineWidth() - (isRunningStep(step) ? 1 : 0);
200
+ // Plus de liseré bleu à gauche : l'icône et la couleur du texte suffisent à
201
+ // distinguer une étape en cours, et le cadre se voyait aussi sur une étape en
202
+ // attente (jaune). Toutes les étapes utilisent donc la même largeur.
203
+ const stepTextWidth = (_step: PlanStep) => lineWidth();
203
204
  const icon = (rawStatus: string) => {
204
205
  const status = String(rawStatus ?? '').toLowerCase();
205
206
  if (DONE_STATUSES.includes(status)) return '[✓]';
@@ -241,11 +242,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
241
242
  {(step) => {
242
243
  // Wrap step descriptions over up to 2 lines instead of truncating —
243
244
  // "Ingest des 39 documents raw/untrac…" hid the actual target.
244
- const running = () => isRunningStep(step());
245
245
  const textWidth = () => stepTextWidth(step());
246
246
  const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
247
247
  return (
248
- <box flexShrink={0} flexDirection="column" border={running() ? ['left'] : undefined} borderStyle="heavy" borderColor="#89B4FA">
248
+ <box flexShrink={0} flexDirection="column">
249
249
  <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
250
250
  <Show when={lines()[1]}>
251
251
  <text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
@@ -265,7 +265,7 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
265
265
  const visibleSlots = () => visible().map((_activity, index) => index);
266
266
  const activityAt = (index: number) => visible()[index] ?? null;
267
267
  return (
268
- <box flexShrink={0} flexDirection="column" paddingX={1}>
268
+ <box flexShrink={0} flexDirection="column" paddingX={1} backgroundColor="#111318">
269
269
  <text width={lineWidth()} fg="#D6DEE8" content="Activity" />
270
270
  <Show when={visible().length > 0} fallback={<text width={lineWidth()} fg="#7F8C8D" content="no active jobs" />}>
271
271
  <Index each={visibleSlots()}>
@@ -385,7 +385,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
385
385
  .filter((line) => activeLogTab() === 'agent-status' ? isAgentStatus(line) : !isAgentStatus(line));
386
386
  const allLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()));
387
387
  return (
388
- <box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
388
+ <box flexGrow={2} flexDirection="column" paddingX={1} marginTop={6} focusable={false}>
389
389
  <text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
390
390
  <box height={1} flexDirection="row">
391
391
  <text
package/src/shell/repl.js CHANGED
@@ -1110,19 +1110,23 @@ export function shouldHandleFreeTextLocally(_line, session, { llmAvailable = Boo
1110
1110
  return { local: true, classification };
1111
1111
  }
1112
1112
 
1113
- // Shared by both the one-shot `/agent <question>` path in runLine and the
1114
- // interactive free-text-to-runtime path below: maps a submitRuntimeRun()
1115
- // outcome to the conversation message(s) it produces and a short log line,
1113
+ // Shared by every caller that turns a submitRuntimeRun()/submitRuntimeTurn()
1114
+ // outcome into the conversation message(s) it produces and a short log line —
1115
+ // the legacy TTY shell (runLine, the interactive free-text path, /approve's
1116
+ // ambiguous-choice resubmission below) and the OpenTUI shell (useAgent.ts) —
1116
1117
  // so the classification → message mapping isn't duplicated per call site.
1117
- function applyRuntimeOutcome(session, outcome, onLog, {
1118
+ // 'turn' (submitRuntimeTurn's default kind for a plain accepted turn) is
1119
+ // treated the same as submitRuntimeRun's 'accepted': the actual reply arrives
1120
+ // separately over the event stream, this call only logs.
1121
+ export function applyRuntimeOutcome(session, outcome, onLog, {
1118
1122
  ambiguousFallback = 'Runtime could not classify that message.',
1119
1123
  } = {}) {
1120
- if (outcome.kind === 'accepted') {
1124
+ if (outcome.kind === 'accepted' || outcome.kind === 'turn') {
1121
1125
  onLog('runtime: run accepted');
1122
- } else if (outcome.kind === 'queued') {
1126
+ } else if (outcome.kind === 'queued' || outcome.kind === 'enqueue_run') {
1123
1127
  conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
1124
1128
  onLog('runtime: control queued');
1125
- } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1129
+ } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'modify_run') {
1126
1130
  conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime control message accepted.') });
1127
1131
  onLog(`runtime: ${outcome.kind}`);
1128
1132
  } else if (outcome.kind === 'ambiguous') {
@@ -1,6 +1,6 @@
1
1
  import { createSignal } from 'solid-js';
2
2
  import { postRuntimeCancel } from '../runtime/client.js';
3
- import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeTurn } from './repl.js';
3
+ import { applyRuntimeOutcome, conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeTurn } from './repl.js';
4
4
 
5
5
  export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
6
6
  const [busy, setBusy] = createSignal(false);
@@ -41,24 +41,12 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
41
41
  runtime: { url: props.runtimeUrl },
42
42
  session: props.session,
43
43
  });
44
- if (outcome.kind === 'turn' || (outcome as any).result?.accepted === true) {
45
- props.addLog('runtime: agent turn accepted');
46
- } else if (outcome.kind === 'queued') {
47
- // The server localizes control-lane acknowledgements from the
48
- // session language (src/runtime/controlMessages.js) — always prefer
49
- // its explanation over a local hardcoded string.
50
- const explanation = (outcome as any).result?.explanation ?? 'Request added to the queue.';
51
- conversationMessages(props.session).push({ role: 'command', content: String(explanation) });
52
- props.addLog('runtime: control queued');
53
- } else if ((outcome as any).result?.explanation) {
54
- // Control-lane kinds (cancel / approve / observe / modify_run…):
55
- // surface the server's localized explanation instead of an error.
56
- conversationMessages(props.session).push({ role: 'command', content: String((outcome as any).result.explanation) });
57
- props.addLog(`runtime: ${outcome.kind}`);
58
- } else {
59
- conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
60
- props.addLog(`runtime error: ${outcome.message}`);
61
- }
44
+ // Same classification → message mapping as the legacy TTY shell —
45
+ // see applyRuntimeOutcome in repl.js. Control-lane acknowledgements
46
+ // (src/runtime/controlMessages.js) are deterministic and English-only
47
+ // by design, never localized; this only supplies the last-resort
48
+ // fallback text for a response that carries neither.
49
+ applyRuntimeOutcome(props.session, outcome, props.addLog);
62
50
  props.refresh();
63
51
  return { exit: false, runtime: true };
64
52
  }