@dotdrelle/wiki-manager 0.15.66 → 0.15.70

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.
Files changed (50) hide show
  1. package/.env.example +10 -3
  2. package/README.md +54 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +35 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/agent/graph.js +12 -11
  8. package/src/agent/skillRecursion.test.js +13 -12
  9. package/src/cli/wiki-manager.js +124 -36
  10. package/src/commands/slash.js +38 -3
  11. package/src/contracts/schemas.js +67 -0
  12. package/src/core/activity.js +5 -0
  13. package/src/core/agentEvents.js +18 -1
  14. package/src/core/buildInfo.json +2 -2
  15. package/src/core/dockerCompose.test.js +8 -40
  16. package/src/core/env.js +14 -0
  17. package/src/core/env.test.js +19 -0
  18. package/src/core/googleGrants.test.js +1 -1
  19. package/src/core/mcp.js +1 -1
  20. package/src/core/runtimeEventAdapter.js +81 -0
  21. package/src/core/runtimeEventAdapter.test.js +61 -0
  22. package/src/core/skillChainView.test.js +2 -2
  23. package/src/core/skillCompiler.test.js +1 -1
  24. package/src/core/skillInvocation.js +13 -8
  25. package/src/core/startupCheck.js +58 -0
  26. package/src/core/startupCheck.test.js +29 -1
  27. package/src/orchestrator/agentRegistry.js +1 -22
  28. package/src/orchestrator/assignmentManager.js +16 -4
  29. package/src/orchestrator/capabilityRegistry.js +8 -1
  30. package/src/orchestrator/dispatcher.js +361 -2
  31. package/src/orchestrator/dispatcher.test.js +112 -1
  32. package/src/orchestrator/objectiveResolver.js +10 -6
  33. package/src/orchestrator/objectiveResolver.test.js +26 -27
  34. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  35. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  36. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  37. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  38. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  39. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  40. package/src/orchestrator/providers/runtimeProviders.js +325 -0
  41. package/src/orchestrator/providers/runtimeProviders.test.js +361 -0
  42. package/src/orchestrator/resultAggregator.js +35 -2
  43. package/src/orchestrator/resultAggregator.test.js +62 -0
  44. package/src/runtime/recoveryManager.js +70 -5
  45. package/src/runtime/skillChain.e2e.test.js +2 -2
  46. package/src/runtime/supervisor.js +5 -10
  47. package/src/shell/RightPane.tsx +9 -1
  48. package/src/shell/StartupScreen.tsx +44 -7
  49. package/src/shell/repl.test.js +13 -0
  50. package/wiki-workspace +19 -3
@@ -0,0 +1,409 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createAgentEvent, dispatchAgentEvent } from '../../core/agentEvents.js';
4
+ import { createDispatcher } from '../dispatcher.js';
5
+ import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
6
+ import { discoverRuntimeProviderAgents } from './runtimeProviders.js';
7
+
8
+ async function waitFor(predicate, { timeoutMs = 2000 } = {}) {
9
+ const deadline = Date.now() + timeoutMs;
10
+ while (Date.now() < deadline) {
11
+ if (predicate()) return;
12
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 2));
13
+ }
14
+ throw new Error('condition not met in time');
15
+ }
16
+
17
+ function externalAssignment(provider, agentInstanceId) {
18
+ return {
19
+ agentInstanceId,
20
+ serverName: null,
21
+ providerKind: 'external-runtime',
22
+ runtimeId: provider.runtime,
23
+ runtimeProvider: provider,
24
+ description: { agentType: 'external-runtime' },
25
+ };
26
+ }
27
+
28
+ test('dispatcher routes an external-runtime assignment to RuntimeProvider.execute', async () => {
29
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
30
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
31
+ const session = { workspace: 'test', mcp: {}, activities: {} };
32
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
33
+
34
+ const result = await dispatcher.execute(
35
+ { id: 'echo-a', label: 'Echo A', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
36
+ externalAssignment(provider, agents[0].agentInstanceId),
37
+ { runId: 'run-donna-1', attempt: { attemptId: 'echo-a:attempt-1', locks: [], release() {} } },
38
+ );
39
+
40
+ assert.equal(result.ok, true);
41
+ assert.equal(result.taskId, 'echo-a');
42
+ assert.equal(result.agentInstanceId, agents[0].agentInstanceId);
43
+ assert.equal(result.status, 'completed');
44
+ assert.ok(result.jobId);
45
+
46
+ const started = session.agentEvents?.find((event) => event.type === 'task.started');
47
+ assert.ok(started, 'task.started was dispatched');
48
+ assert.equal(started.payload.jobId, result.jobId);
49
+ });
50
+
51
+ test('dispatcher cancels the external run through the provider when the signal aborts', async () => {
52
+ const provider = createFakeRuntimeProvider({ autoCompleteMs: 10_000 });
53
+ const cancelledRunIds = [];
54
+ const originalCancel = provider.cancel.bind(provider);
55
+ provider.cancel = async (runId) => {
56
+ cancelledRunIds.push(String(runId));
57
+ return originalCancel(runId);
58
+ };
59
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
60
+ const session = { workspace: 'test', mcp: {}, activities: {} };
61
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 5 });
62
+ const controller = new AbortController();
63
+
64
+ const executing = dispatcher.execute(
65
+ { id: 'echo-b', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
66
+ externalAssignment(provider, agents[0].agentInstanceId),
67
+ { signal: controller.signal, attempt: { attemptId: 'echo-b:attempt-1', locks: [], release() {} } },
68
+ );
69
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 5));
70
+ controller.abort();
71
+
72
+ await assert.rejects(() => executing);
73
+ assert.equal(cancelledRunIds.length, 1, 'the external run was cancelled via the provider');
74
+ });
75
+
76
+ test('dispatcher maps runtime message events into assistant_message', async () => {
77
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
78
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
79
+ const session = { workspace: 'test', mcp: {}, activities: {} };
80
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
81
+
82
+ await dispatcher.execute(
83
+ { id: 'echo-c', label: 'Echo C', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
84
+ externalAssignment(provider, agents[0].agentInstanceId),
85
+ { runId: 'run-donna-2', attempt: { attemptId: 'echo-c:attempt-1', locks: [], release() {} } },
86
+ );
87
+
88
+ const message = session.agentEvents?.find((event) => event.type === 'assistant_message');
89
+ assert.ok(message, 'an assistant_message was dispatched');
90
+ assert.match(String(message.payload?.content ?? ''), /completed/);
91
+ });
92
+
93
+ test('dispatcher propagates a planExpansionRequest from the external runtime result (agent -> DAG)', async () => {
94
+ const provider = {
95
+ runtime: 'deepagents',
96
+ async describe() { return { runtime: 'deepagents', version: '0.6.10', protocolVersion: '1', health: 'available' }; },
97
+ async discoverCapabilities() { return [{ name: 'agent.review', operations: ['run'] }]; },
98
+ async execute() { return { runId: 'review-1', status: 'running' }; },
99
+ async status() {
100
+ return {
101
+ runId: 'review-1',
102
+ status: 'completed',
103
+ result: {
104
+ status: 'completed',
105
+ planExpansionRequest: {
106
+ capability: 'knowledge.pipeline',
107
+ operation: 'build',
108
+ objective: 'Build the deliverables the review flagged as stale.',
109
+ arguments: { templates: ['templates/rapport.md'] },
110
+ insertAfterTasks: ['review-a'],
111
+ },
112
+ },
113
+ };
114
+ },
115
+ async cancel() {},
116
+ async approve() {},
117
+ subscribe() { return () => {}; },
118
+ };
119
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
120
+ const session = { workspace: 'test', mcp: {}, activities: {} };
121
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
122
+
123
+ const result = await dispatcher.execute(
124
+ { id: 'review-a', requiredCapability: 'agent.review', operation: 'run', arguments: {} },
125
+ externalAssignment(provider, agents[0].agentInstanceId),
126
+ { runId: 'run-donna-3', attempt: { attemptId: 'review-a:attempt-1', locks: [], release() {} } },
127
+ );
128
+
129
+ assert.equal(result.ok, true);
130
+ assert.equal(result.planExpansionRequest.capability, 'knowledge.pipeline');
131
+ assert.equal(result.planExpansionRequest.operation, 'build');
132
+ });
133
+
134
+ test('dispatcher waits for a covered grant before unblocking the runtime HITL', async () => {
135
+ const provider = createFakeRuntimeProvider({ requireApproval: true });
136
+ const approvedCalls = [];
137
+ const originalApprove = provider.approve.bind(provider);
138
+ provider.approve = async (runId, decision) => {
139
+ approvedCalls.push({ runId, decision });
140
+ return originalApprove(runId, decision);
141
+ };
142
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
143
+ const session = { workspace: 'test', mcp: {}, activities: {} };
144
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 5 });
145
+ const runId = 'run-donna-approve';
146
+
147
+ const executing = dispatcher.execute(
148
+ { id: 'echo-h', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
149
+ externalAssignment(provider, agents[0].agentInstanceId),
150
+ { runId, attempt: { attemptId: 'echo-h:attempt-1', locks: [], release() {} } },
151
+ );
152
+
153
+ await waitFor(() => (session.agentEvents ?? []).some((event) => event.type === 'approval.requested'));
154
+ assert.equal(approvedCalls.length, 0, 'the runtime is not unblocked before a human grant');
155
+
156
+ dispatchAgentEvent(session, createAgentEvent('approval.granted', {
157
+ origin: 'test',
158
+ runId,
159
+ payload: { id: 'grant-run', scope: 'run', runId, approvalClasses: [] },
160
+ }));
161
+
162
+ const result = await executing;
163
+ assert.equal(result.ok, true);
164
+ assert.equal(approvedCalls.length, 1, 'approve is called once, only after the grant covers the request');
165
+ assert.equal(approvedCalls[0].decision.approved, true);
166
+ assert.deepEqual(approvedCalls[0].decision.scope, ['default']);
167
+ });
168
+
169
+ test('dispatcher gates a mutating runtime capability on approval (per-capability HITL)', async () => {
170
+ const provider = createFakeRuntimeProvider({
171
+ capabilities: [{ name: 'agent.research', operations: ['run'], mutationClass: 'ingest' }],
172
+ });
173
+ const approvedCalls = [];
174
+ const originalApprove = provider.approve.bind(provider);
175
+ provider.approve = async (runId, decision) => {
176
+ approvedCalls.push({ runId, decision });
177
+ return originalApprove(runId, decision);
178
+ };
179
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
180
+ const session = { workspace: 'test', mcp: {}, activities: {} };
181
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 5 });
182
+ const runId = 'run-donna-approve-2';
183
+
184
+ const executing = dispatcher.execute(
185
+ { id: 'research-a', requiredCapability: 'agent.research', operation: 'run', arguments: {} },
186
+ externalAssignment(provider, agents[0].agentInstanceId),
187
+ { runId, attempt: { attemptId: 'research-a:attempt-1', locks: [], release() {} } },
188
+ );
189
+
190
+ await waitFor(() => (session.agentEvents ?? []).some((event) => event.type === 'approval.requested'));
191
+ assert.equal(approvedCalls.length, 0, 'the runtime is not unblocked before a human grant');
192
+
193
+ dispatchAgentEvent(session, createAgentEvent('approval.granted', {
194
+ origin: 'test',
195
+ runId,
196
+ payload: { id: 'grant-run-2', scope: 'run', runId, approvalClasses: [] },
197
+ }));
198
+
199
+ const result = await executing;
200
+ assert.equal(result.ok, true);
201
+ assert.equal(approvedCalls.length, 1);
202
+ assert.deepEqual(approvedCalls[0].decision.scope, ['ingest'], 'the unblock carries the announced mutation class');
203
+ });
204
+
205
+ test("end-user dry-run: 'plan' completes without any approval; 'run' pauses with the ⏸ banner until /approve", async () => {
206
+ const provider = createFakeRuntimeProvider({
207
+ capabilities: [{ name: 'agent.plan', operations: ['plan', 'run'], defaultRequiresApproval: true }],
208
+ });
209
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
210
+
211
+ // Dry-run path: operation 'plan'
212
+ const drySession = { workspace: 'test', mcp: {}, activities: {} };
213
+ const dryDispatcher = createDispatcher({ session: drySession, pollIntervalMs: 1 });
214
+ const dry = await dryDispatcher.execute(
215
+ { id: 'plan-a', label: 'Propose', requiredCapability: 'agent.plan', operation: 'plan', arguments: {} },
216
+ externalAssignment(provider, agents[0].agentInstanceId),
217
+ { runId: 'run-plan-a', attempt: { attemptId: 'plan-a:attempt-1', locks: [], release() {} } },
218
+ );
219
+ assert.equal(dry.ok, true);
220
+ assert.equal(dry.status, 'completed');
221
+ assert.ok(
222
+ !(drySession.agentEvents ?? []).some((event) => event.type === 'approval.requested'),
223
+ 'a dry-run never asks for approval',
224
+ );
225
+ assert.ok(
226
+ (drySession.agentEvents ?? []).some((event) => event.type === 'assistant_message'
227
+ && /completed/.test(String(event.payload?.content ?? ''))),
228
+ 'the proposal is reported back to the user',
229
+ );
230
+
231
+ // Live path: operation 'run'
232
+ const liveSession = { workspace: 'test', mcp: {}, activities: {} };
233
+ const liveDispatcher = createDispatcher({ session: liveSession, pollIntervalMs: 5 });
234
+ const runId = 'run-plan-b';
235
+ const executing = liveDispatcher.execute(
236
+ { id: 'plan-b', label: 'Apply', requiredCapability: 'agent.plan', operation: 'run', arguments: {} },
237
+ externalAssignment(provider, agents[0].agentInstanceId),
238
+ { runId, attempt: { attemptId: 'plan-b:attempt-1', locks: [], release() {} } },
239
+ );
240
+
241
+ await waitFor(() => (liveSession.agentEvents ?? []).some((event) => event.type === 'approval.requested'));
242
+ const banner = (liveSession.agentEvents ?? []).find((event) => event.type === 'assistant_message'
243
+ && /Approval required/.test(String(event.payload?.content ?? '')));
244
+ assert.ok(banner, 'the ⏸ banner is shown before anything runs');
245
+
246
+ dispatchAgentEvent(liveSession, createAgentEvent('approval.granted', {
247
+ origin: 'test',
248
+ runId,
249
+ payload: { id: 'grant-plan-b', scope: 'run', runId, approvalClasses: [] },
250
+ }));
251
+
252
+ const result = await executing;
253
+ assert.equal(result.ok, true);
254
+ assert.equal(result.status, 'completed');
255
+ });
256
+
257
+ test('dispatcher sends the active profile model with the run', async () => {
258
+ const provider = createFakeRuntimeProvider();
259
+ const requests = [];
260
+ const originalExecute = provider.execute.bind(provider);
261
+ provider.execute = async (request) => {
262
+ requests.push(request);
263
+ return originalExecute(request);
264
+ };
265
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
266
+ const session = {
267
+ workspace: 'test',
268
+ mcp: {
269
+ wiki: {
270
+ status: 'connected',
271
+ url: 'http://localhost:3335/mcp/',
272
+ headers: { Authorization: 'Bearer wiki-token' },
273
+ tools: [
274
+ { name: 'wiki_search_context' },
275
+ { name: 'wiki_read_page' },
276
+ { name: 'wiki_write_page' },
277
+ ],
278
+ },
279
+ },
280
+ activities: {},
281
+ wikircConfig: { llm: { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret', temperature: 0.3 } },
282
+ language: 'fr',
283
+ };
284
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
285
+
286
+ await dispatcher.execute(
287
+ { id: 'echo-i', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
288
+ externalAssignment(provider, agents[0].agentInstanceId),
289
+ { runId: 'run-donna-4', attempt: { attemptId: 'echo-i:attempt-1', locks: [], release() {} } },
290
+ );
291
+
292
+ assert.deepEqual(requests[0].model, {
293
+ baseUrl: 'http://llm:11434/v1',
294
+ model: 'qwen3:14b',
295
+ apiKey: 'secret',
296
+ temperature: 0.3,
297
+ });
298
+ assert.equal(requests[0].language, 'fr', 'the workspace language travels with the run');
299
+ assert.deepEqual(requests[0].mcp, [{
300
+ name: 'wiki',
301
+ url: 'http://localhost:3335/mcp/',
302
+ headers: { Authorization: 'Bearer wiki-token' },
303
+ tools: ['wiki_search_context', 'wiki_read_page'],
304
+ }], 'the wiki MCP travels per run, read tools only — write tools never leave');
305
+ assert.match(requests[0].systemPrompt ?? '', /agentic analysis engine/);
306
+ assert.match(requests[0].systemPrompt ?? '', /agent\.echo/);
307
+ assert.match(requests[0].systemPrompt ?? '', /Reply in the workspace language: fr/);
308
+ });
309
+
310
+ test('dispatcher sends the wiki MCP pool with its bearer token (runtime eyes)', async () => {
311
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
312
+ const received = [];
313
+ const originalExecute = provider.execute.bind(provider);
314
+ provider.execute = async (request) => {
315
+ received.push(request);
316
+ return originalExecute(request);
317
+ };
318
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
319
+ const session = {
320
+ workspace: 'test',
321
+ workspacePath: null,
322
+ language: 'fr',
323
+ wikircConfig: { llm: { model: 'openai/gpt-test', baseUrl: 'http://127.0.0.1:9/v1', apiKey: 'k' } },
324
+ mcp: {
325
+ wiki: {
326
+ url: 'http://127.0.0.1:3201/mcp',
327
+ status: 'connected',
328
+ token: 'wiki-access-key',
329
+ tools: [{ name: 'wiki_list_pages' }, { name: 'wiki_read_page' }, { name: 'wiki_write_page' }, { name: 'wiki_add_source' }],
330
+ },
331
+ },
332
+ activities: {},
333
+ };
334
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
335
+
336
+ await dispatcher.execute(
337
+ { id: 'echo-a', label: 'Echo A', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
338
+ externalAssignment(provider, agents[0].agentInstanceId),
339
+ { runId: 'run-donna-eyes', attempt: { attemptId: 'echo-a:attempt-1', locks: [], release() {} } },
340
+ );
341
+
342
+ assert.equal(received.length, 1);
343
+ const mcp = received[0].mcp ?? [];
344
+ assert.equal(mcp.length, 1);
345
+ assert.equal(mcp[0].name, 'wiki');
346
+ // Without the Authorization header the workspace MCP server rejects the
347
+ // gateway's connection ("invalid or missing bearer token") and the Deep
348
+ // Agent runs blind — the first gateway E2E failed exactly this way.
349
+ assert.equal(mcp[0].headers?.Authorization, 'Bearer wiki-access-key');
350
+ assert.ok(mcp[0].tools.includes('wiki_list_pages'));
351
+ assert.ok(!mcp[0].tools.includes('wiki_write_page'), 'write tools never reach the runtime');
352
+ assert.ok(!mcp[0].tools.includes('wiki_add_source'));
353
+ });
354
+
355
+ test('dispatcher sends the active profile model with the run', async () => {
356
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
357
+ const received = [];
358
+ const originalExecute = provider.execute.bind(provider);
359
+ provider.execute = async (request) => {
360
+ received.push(request);
361
+ return originalExecute(request);
362
+ };
363
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
364
+ const session = {
365
+ workspace: 'test',
366
+ workspacePath: null,
367
+ language: 'fr',
368
+ wikircConfig: { llm: { model: 'openai/gpt-test', baseUrl: 'http://127.0.0.1:9/v1', apiKey: 'k', temperature: 0.2 } },
369
+ mcp: {},
370
+ activities: {},
371
+ };
372
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
373
+
374
+ await dispatcher.execute(
375
+ { id: 'echo-a', label: 'Echo A', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
376
+ externalAssignment(provider, agents[0].agentInstanceId),
377
+ { runId: 'run-donna-model', attempt: { attemptId: 'echo-a:attempt-1', locks: [], release() {} } },
378
+ );
379
+
380
+ assert.equal(received[0].model.model, 'openai/gpt-test');
381
+ assert.equal(received[0].model.baseUrl, 'http://127.0.0.1:9/v1');
382
+ assert.equal(received[0].model.apiKey, 'k');
383
+ assert.equal(received[0].model.temperature, 0.2);
384
+ });
385
+
386
+ test('dispatcher announces when the runtime is dispatched without its wiki MCP pool', async () => {
387
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.echo', operations: ['run'] }] });
388
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'fake', provider }]);
389
+ const session = {
390
+ workspace: 'test',
391
+ workspacePath: null,
392
+ language: 'fr',
393
+ wikircConfig: { llm: { model: 'openai/gpt-test', baseUrl: 'http://127.0.0.1:9/v1', apiKey: 'k' } },
394
+ mcp: {},
395
+ activities: {},
396
+ };
397
+ const dispatcher = createDispatcher({ session, pollIntervalMs: 1 });
398
+
399
+ await dispatcher.execute(
400
+ { id: 'echo-a', label: 'Echo A', requiredCapability: 'agent.echo', operation: 'run', arguments: {} },
401
+ externalAssignment(provider, agents[0].agentInstanceId),
402
+ { runId: 'run-donna-blind', attempt: { attemptId: 'echo-a:attempt-1', locks: [], release() {} } },
403
+ );
404
+
405
+ const blind = session.agentEvents?.filter((event) => event.type === 'runtime_log'
406
+ && event.payload?.event === 'runtime.blind');
407
+ assert.equal(blind.length, 1, 'running blind must be announced exactly once');
408
+ assert.match(String(blind[0].payload?.detail ?? ''), /no workspace wiki MCP pool/);
409
+ });
@@ -0,0 +1,164 @@
1
+ import {
2
+ RUNTIME_PROTOCOL_VERSION,
3
+ RuntimeProviderUnavailableError,
4
+ normalizeRuntimeEvent,
5
+ } from './runtimeProvider.js';
6
+
7
+ /**
8
+ * FakeRuntimeProvider — vérifie toute la plomberie du contrat sans introduire
9
+ * de LLM (RFC § 26). Expose la capability `agent.echo` et simule
10
+ * discover / execute / status / events / cancel / approve, avec des runs
11
+ * indépendants.
12
+ *
13
+ * `requireApproval: true` simule le human-in-the-loop : après l'analyse, le
14
+ * run émet `approval_required` et reste `waiting_approval` jusqu'à
15
+ * `approve()`. C'est ce qui permet de tester le gate d'approbation du
16
+ * dispatcher sans runtime réel.
17
+ */
18
+ export function createFakeRuntimeProvider({
19
+ runtime = 'fake',
20
+ version = '0.0.1',
21
+ capabilities = [{ name: 'agent.echo', operations: ['run'] }],
22
+ available = true,
23
+ autoCompleteMs = 0,
24
+ requireApproval = false,
25
+ proposal = null,
26
+ } = {}) {
27
+ const runs = new Map();
28
+ let sequence = 0;
29
+
30
+ function nextRunId() {
31
+ sequence += 1;
32
+ return `${runtime}-${sequence}`;
33
+ }
34
+
35
+ function emit(run, event) {
36
+ const normalized = normalizeRuntimeEvent({ ...event, runId: run.runId });
37
+ run.events.push(normalized);
38
+ for (const listener of run.listeners) listener(normalized);
39
+ }
40
+
41
+ function runFor(runId) {
42
+ const run = runs.get(String(runId));
43
+ if (!run) throw new RuntimeProviderUnavailableError(runtime, `unknown run "${runId}"`);
44
+ return run;
45
+ }
46
+
47
+ function complete(run) {
48
+ run.status = 'completed';
49
+ run.timer = null;
50
+ // Mirrors the gateway: a structural proposal rides the structured
51
+ // `result.planExpansionRequest` field, which is what the manager's DAG
52
+ // integration reads — never a prose-only message.
53
+ run.result = {
54
+ status: 'completed',
55
+ content: `completed ${String(run.objective ?? '')}`,
56
+ ...(proposal && typeof proposal === 'object' ? { planExpansionRequest: proposal } : {}),
57
+ };
58
+ emit(run, { type: 'message', content: run.result.content });
59
+ emit(run, { type: 'run_completed' });
60
+ }
61
+
62
+ return {
63
+ async describe() {
64
+ return {
65
+ runtime,
66
+ version,
67
+ protocolVersion: RUNTIME_PROTOCOL_VERSION,
68
+ health: available ? 'available' : 'unavailable',
69
+ capabilities: capabilities.map((capability) => ({ ...capability })),
70
+ };
71
+ },
72
+ async discoverCapabilities() {
73
+ if (!available) throw new RuntimeProviderUnavailableError(runtime, 'down');
74
+ return capabilities.map((capability) => ({ ...capability }));
75
+ },
76
+ async execute(request = {}) {
77
+ if (!available) throw new RuntimeProviderUnavailableError(runtime, 'down');
78
+ const runId = nextRunId();
79
+ const capabilityName = String(request.capability ?? '');
80
+ const declared = (capabilities ?? []).find((capability) => capability?.name === capabilityName) ?? null;
81
+ const operationName = String(request.operation ?? 'run');
82
+ // The 'plan' operation is the dry-run: it proposes, never acts, so it
83
+ // never pauses for approval — even on a mutating capability. Global
84
+ // requireApproval stays a force for every operation.
85
+ const mutating = requireApproval
86
+ || ((Boolean(declared?.mutationClass) || declared?.defaultRequiresApproval === true)
87
+ && operationName !== 'plan');
88
+ const run = {
89
+ runId,
90
+ status: 'running',
91
+ objective: String(request.objective ?? request.input ?? ''),
92
+ events: [],
93
+ listeners: new Set(),
94
+ timer: null,
95
+ awaitingApproval: false,
96
+ };
97
+ runs.set(runId, run);
98
+ emit(run, { type: 'run_started' });
99
+ emit(run, { type: 'tool_started', tool: 'echo' });
100
+ emit(run, {
101
+ type: 'tool_finished',
102
+ tool: 'echo',
103
+ resultSummary: `echoed "${run.objective}"`,
104
+ });
105
+ if (mutating) {
106
+ run.status = 'waiting_approval';
107
+ run.awaitingApproval = true;
108
+ emit(run, {
109
+ type: 'approval_required',
110
+ approvalId: `${runId}-proposal`,
111
+ reason: 'analysis complete before execution',
112
+ proposal: {
113
+ summary: `Analysis for "${run.objective}": read-only inspection, then the announced mutation.`,
114
+ readTools: ['echo'],
115
+ mutations: [{ kind: declared?.mutationClass ?? 'default', target: 'workspace', summary: run.objective }],
116
+ },
117
+ });
118
+ } else {
119
+ run.timer = setTimeout(() => complete(run), autoCompleteMs);
120
+ }
121
+ return { runId, status: run.status };
122
+ },
123
+ async status(runId) {
124
+ const run = runFor(runId);
125
+ return {
126
+ runId: run.runId,
127
+ status: run.status,
128
+ ...(run.result ? { result: run.result } : {}),
129
+ };
130
+ },
131
+ async cancel(runId) {
132
+ const run = runFor(runId);
133
+ if (run.timer) {
134
+ clearTimeout(run.timer);
135
+ run.timer = null;
136
+ }
137
+ run.status = 'cancelled';
138
+ run.awaitingApproval = false;
139
+ emit(run, { type: 'run_cancelled' });
140
+ },
141
+ async approve(runId, { approved = true, reason = null, scope = null } = {}) {
142
+ const run = runFor(runId);
143
+ if (!run.awaitingApproval) return;
144
+ run.awaitingApproval = false;
145
+ if (!approved) {
146
+ run.status = 'cancelled';
147
+ emit(run, { type: 'run_cancelled', ...(reason ? { error: reason } : {}) });
148
+ return;
149
+ }
150
+ void scope;
151
+ run.status = 'running';
152
+ run.timer = setTimeout(() => complete(run), autoCompleteMs);
153
+ },
154
+ subscribe(runId, listener) {
155
+ const run = runFor(runId);
156
+ run.listeners.add(listener);
157
+ // Rejoue les événements déjà émis avant la souscription : le listener
158
+ // doit voir l'historique complet du run, pas seulement ce qui arrive
159
+ // après son attachement (comportement fidèle aux flux d'événements).
160
+ for (const event of [...run.events]) listener(event);
161
+ return () => run.listeners.delete(listener);
162
+ },
163
+ };
164
+ }