@dotdrelle/wiki-manager 0.15.66 → 0.15.71

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 (72) hide show
  1. package/.env.example +10 -3
  2. package/README.md +57 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +39 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/activity/activityAggregator.test.js +2 -2
  8. package/src/agent/graph.js +13 -11
  9. package/src/agent/skillRecursion.test.js +13 -12
  10. package/src/cli/wiki-manager.js +125 -37
  11. package/src/cli/wiki-manager.test.js +16 -16
  12. package/src/commands/slash.js +59 -5
  13. package/src/contracts/schemas.js +67 -0
  14. package/src/core/activity.js +5 -0
  15. package/src/core/agentEvents.js +139 -25
  16. package/src/core/agentEvents.test.js +26 -1
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/commandFailure.test.js +2 -2
  19. package/src/core/currentArtifact.test.js +5 -5
  20. package/src/core/dockerCompose.test.js +8 -40
  21. package/src/core/env.js +14 -0
  22. package/src/core/env.test.js +19 -0
  23. package/src/core/googleGrants.test.js +1 -1
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/mcp.test.js +1 -1
  26. package/src/core/otherWorkspacesRunning.test.js +6 -6
  27. package/src/core/runtimeEventAdapter.js +81 -0
  28. package/src/core/runtimeEventAdapter.test.js +61 -0
  29. package/src/core/runtimeLog.js +35 -1
  30. package/src/core/runtimeLog.test.js +27 -2
  31. package/src/core/skillChainView.test.js +2 -2
  32. package/src/core/skillCompiler.test.js +1 -1
  33. package/src/core/skillInvocation.js +13 -8
  34. package/src/core/skillInvocation.test.js +1 -1
  35. package/src/core/startupCheck.js +58 -0
  36. package/src/core/startupCheck.test.js +29 -1
  37. package/src/core/wikiSetup.js +25 -0
  38. package/src/core/wikiSetup.test.js +35 -0
  39. package/src/core/wikirc.test.js +6 -6
  40. package/src/core/workspaceInherit.test.js +14 -14
  41. package/src/orchestrator/agentRegistry.js +1 -22
  42. package/src/orchestrator/agentRegistry.test.js +6 -6
  43. package/src/orchestrator/assignmentManager.js +16 -4
  44. package/src/orchestrator/capabilityRegistry.js +8 -1
  45. package/src/orchestrator/dispatcher.js +405 -2
  46. package/src/orchestrator/dispatcher.test.js +158 -4
  47. package/src/orchestrator/objectiveResolver.js +10 -6
  48. package/src/orchestrator/objectiveResolver.test.js +26 -27
  49. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  50. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  51. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  52. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  53. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  54. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  55. package/src/orchestrator/providers/runtimeProviders.js +378 -0
  56. package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
  57. package/src/orchestrator/resultAggregator.js +35 -2
  58. package/src/orchestrator/resultAggregator.test.js +62 -0
  59. package/src/orchestrator/scheduler.test.js +4 -4
  60. package/src/runtime/delegation.test.js +11 -11
  61. package/src/runtime/recoveryManager.js +70 -5
  62. package/src/runtime/runner.test.js +1 -1
  63. package/src/runtime/server.test.js +2 -2
  64. package/src/runtime/skillChain.e2e.test.js +2 -2
  65. package/src/runtime/store.test.js +8 -5
  66. package/src/runtime/supervisor.js +5 -10
  67. package/src/runtime/workspaceIsolation.test.js +26 -26
  68. package/src/shell/RightPane.tsx +23 -3
  69. package/src/shell/StartupScreen.tsx +44 -7
  70. package/src/shell/repl.js +24 -2
  71. package/src/shell/repl.test.js +13 -0
  72. package/wiki-workspace +53 -3
@@ -1,6 +1,95 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createDispatcher, normalizeTaskError } from './dispatcher.js';
3
+ import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
4
+
5
+ test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
6
+ const session = {
7
+ mcp: {
8
+ wiki: {
9
+ url: 'http://wiki:3000/mcp',
10
+ status: 'connected',
11
+ token: 't',
12
+ tools: [
13
+ { name: 'wiki_read_page' },
14
+ { name: 'wiki_search_context' },
15
+ { name: 'wiki_workspace_status' },
16
+ { name: 'help_read' },
17
+ // every mutation tool must be dropped, including the ones the old
18
+ // substring denylist ("write_page|add_source|…") let through:
19
+ { name: 'wiki_write_page' },
20
+ { name: 'wiki_add_source' },
21
+ { name: 'profile_update' },
22
+ { name: 'template_write' },
23
+ { name: 'build_context_write' },
24
+ // and a hypothetical future mutation tool the denylist could not know:
25
+ { name: 'wiki_delete_page' },
26
+ { name: 'wiki_move_page' },
27
+ ],
28
+ },
29
+ },
30
+ };
31
+ const [pool] = activeProfileMcp(session);
32
+ assert.deepEqual(
33
+ [...pool.tools].sort(),
34
+ ['help_read', 'wiki_read_page', 'wiki_search_context', 'wiki_workspace_status'],
35
+ );
36
+ });
37
+
38
+ test('activeProfileMcp hands the external runtime a safe connector\'s read tools by word-boundary verb', () => {
39
+ const session = {
40
+ mcp: {
41
+ wiki: {
42
+ url: 'http://wiki:3000/mcp', status: 'connected',
43
+ tools: [{ name: 'wiki_read_page' }],
44
+ },
45
+ exa: {
46
+ url: 'https://exa/mcp', status: 'connected', external: true,
47
+ tools: [
48
+ { name: 'web_search_exa' },
49
+ { name: 'research_paper_search' },
50
+ // a raw substring denylist wrongly dropped these safe reads:
51
+ { name: 'list_recent_updates' },
52
+ { name: 'get_created_items' },
53
+ // …and wrongly kept these, which use a verb it did not enumerate:
54
+ { name: 'crawl_site' },
55
+ { name: 'post_message' },
56
+ { name: 'run_report' },
57
+ { name: 'send_email' },
58
+ ],
59
+ },
60
+ },
61
+ };
62
+ const exaBlock = activeProfileMcp(session).find((block) => block.name === 'exa');
63
+ assert.deepEqual(
64
+ [...exaBlock.tools].sort(),
65
+ ['get_created_items', 'list_recent_updates', 'research_paper_search', 'web_search_exa'],
66
+ );
67
+ });
68
+
69
+ test('activeProfileMcp drops an external connector that is approval-gated or workspace-mutating', () => {
70
+ const session = {
71
+ mcp: {
72
+ wiki: { url: 'http://wiki:3000/mcp', status: 'connected', tools: [{ name: 'wiki_read_page' }] },
73
+ gated: { url: 'https://g/mcp', status: 'connected', external: true, requireApproval: ['search'], tools: [{ name: 'search' }] },
74
+ documents: { url: 'https://d/mcp', status: 'connected', external: true, tools: [{ name: 'get_document' }] },
75
+ },
76
+ };
77
+ const names = activeProfileMcp(session).map((block) => block.name);
78
+ assert.deepEqual(names, ['wiki']);
79
+ });
80
+
81
+ test('activeProfileMcp tolerates namespaced tool names', () => {
82
+ const session = {
83
+ mcp: {
84
+ wiki: {
85
+ url: 'http://wiki:3000/mcp',
86
+ status: 'connected',
87
+ tools: [{ name: 'wiki__wiki_read_page' }, { name: 'wiki__wiki_write_page' }],
88
+ },
89
+ },
90
+ };
91
+ assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
92
+ });
4
93
 
5
94
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
95
  const session = {
@@ -197,13 +286,13 @@ test('normalizeTaskError keeps the agent reason as the message, never the fallba
197
286
  // Regression: the fallback describes only WHERE the failure was seen
198
287
  // ("agent_execute rejected task"). Letting it win discarded the one
199
288
  // actionable sentence — and left Donna to invent a cause.
200
- const error = normalizeTaskError("Error: source 'acpi' not found", {
289
+ const error = normalizeTaskError("Error: source 'acme' not found", {
201
290
  fallbackCode: 'execution_rejected',
202
291
  fallbackMessage: 'agent_execute rejected task',
203
292
  });
204
293
 
205
- assert.equal(error.code, "Error: source 'acpi' not found");
206
- assert.equal(error.message, "Error: source 'acpi' not found");
294
+ assert.equal(error.code, "Error: source 'acme' not found");
295
+ assert.equal(error.message, "Error: source 'acme' not found");
207
296
  });
208
297
 
209
298
  test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
@@ -215,3 +304,68 @@ test('normalizeTaskError falls back only when the agent reports no reason at all
215
304
  assert.equal(error.code, 'execution_rejected');
216
305
  assert.equal(error.message, 'agent_execute rejected task');
217
306
  });
307
+
308
+ test('dispatcher passes confirm=true to agent_execute for an approval-gated task', async () => {
309
+ let executeArgs;
310
+ const session = {
311
+ workspace: 'test',
312
+ mcp: {
313
+ production: {
314
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
315
+ },
316
+ },
317
+ activities: {},
318
+ };
319
+ const dispatcher = createDispatcher({
320
+ session,
321
+ pollIntervalMs: 1,
322
+ callTool: async (_mcp, _server, tool, args) => {
323
+ if (tool === 'agent_execute') {
324
+ executeArgs = args;
325
+ return { accepted: true, jobId: 'job-ingest', status: 'queued' };
326
+ }
327
+ return { jobId: 'job-ingest', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
328
+ },
329
+ });
330
+
331
+ await dispatcher.execute(
332
+ { id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_apply', arguments: {}, requiresApproval: true },
333
+ { serverName: 'production', agentInstanceId: 'production-main' },
334
+ { runId: 'run-donna-confirm', attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
335
+ );
336
+
337
+ assert.equal(executeArgs.arguments.confirm, true, 'covered mutating task must carry confirm=true');
338
+ assert.equal(executeArgs.constraints.requireApprovalForMutations, true);
339
+ });
340
+
341
+ test('dispatcher does not invent confirm=true for a non-gated task', async () => {
342
+ let executeArgs;
343
+ const session = {
344
+ workspace: 'test',
345
+ mcp: {
346
+ production: {
347
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
348
+ },
349
+ },
350
+ activities: {},
351
+ };
352
+ const dispatcher = createDispatcher({
353
+ session,
354
+ pollIntervalMs: 1,
355
+ callTool: async (_mcp, _server, tool, args) => {
356
+ if (tool === 'agent_execute') {
357
+ executeArgs = args;
358
+ return { accepted: true, jobId: 'job-doctor', status: 'queued' };
359
+ }
360
+ return { jobId: 'job-doctor', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
361
+ },
362
+ });
363
+
364
+ await dispatcher.execute(
365
+ { id: 'doctor-a', requiredCapability: 'workspace.diagnose', operation: 'doctor', arguments: {} },
366
+ { serverName: 'production', agentInstanceId: 'production-main' },
367
+ { runId: 'run-donna-doctor', attempt: { attemptId: 'doctor-a:attempt-1', locks: [], release() {} } },
368
+ );
369
+
370
+ assert.equal(executeArgs.arguments.confirm, undefined);
371
+ });
@@ -113,11 +113,10 @@ function resolveMentionedRegistryOperation(objective, candidates) {
113
113
  // A capability with more than one operation may declare
114
114
  // aliasOperations, mapping the specific alias phrase that matched to
115
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.
116
+ // (alphabetical) is a silent guess — a multi-operation capability's
117
+ // other operations become structurally unreachable from natural
118
+ // language, and the alphabetical first one may well be the destructive
119
+ // one.
121
120
  const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
122
121
  return { capability: candidate.id, operation };
123
122
  })
@@ -176,7 +175,10 @@ function registrySnapshot(session) {
176
175
  const registry = session?.capabilityRegistry;
177
176
  if (registry?.snapshot) return registry.snapshot();
178
177
  if (registry && typeof registry === 'object') return registry;
179
- const agents = session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? [];
178
+ const agents = [
179
+ ...(session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? []),
180
+ ...(session?.runtimeProviderAgents ?? []),
181
+ ];
180
182
  const snapshot = {};
181
183
  for (const agent of agents) {
182
184
  for (const capability of agent?.description?.capabilities ?? []) {
@@ -187,6 +189,8 @@ function registrySnapshot(session) {
187
189
  capability,
188
190
  description: agent.description,
189
191
  health: agent.health,
192
+ providerKind: agent.providerKind ?? null,
193
+ runtimeProvider: agent.runtimeProvider ?? null,
190
194
  });
191
195
  }
192
196
  }
@@ -122,53 +122,52 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
122
122
  assert.equal(result.operation, 'doctor');
123
123
  });
124
124
 
125
- const concepts = makeCapability('knowledge.concepts', {
126
- operations: ['concepts', 'reclassify-concepts'],
127
- aliases: ['concept grid', 'reclassify concepts', 'file unclassified concepts'],
125
+ const snapshot = makeCapability('data.snapshot', {
126
+ operations: ['snapshot', 'prune'],
127
+ aliases: ['take a snapshot', 'prune snapshots', 'clean old snapshots'],
128
128
  aliasOperations: {
129
- 'concept grid': 'concepts',
130
- 'reclassify concepts': 'reclassify-concepts',
131
- 'file unclassified concepts': 'reclassify-concepts',
129
+ 'take a snapshot': 'snapshot',
130
+ 'prune snapshots': 'prune',
131
+ 'clean old snapshots': 'prune',
132
132
  },
133
- description: 'Synthesize the concept grid or file unclassified pages into it.',
133
+ description: 'Take a snapshot of the workspace or prune old snapshots.',
134
134
  });
135
135
 
136
136
  /*
137
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.
138
+ alphabetizes to ["prune", "snapshot"], so a naive alias hit defaulting to
139
+ operations[0] would ALWAYS resolve to "prune" — the destructive operation —
140
+ even for aliases explicitly authored to reach the safe "snapshot" one.
141
+ aliasOperations must be consulted first.
143
142
  */
144
- test('resolveObjective routes "reclassify concepts" to reclassify-concepts, not operations[0]', async () => {
145
- const session = sessionWith([provider('production-1', concepts)]);
143
+ test('resolveObjective routes "prune snapshots" to prune, not operations[0]', async () => {
144
+ const session = sessionWith([provider('production-1', snapshot)]);
146
145
  session.llm.completeWithTools = async () => {
147
146
  throw new Error('the aliased operation must not depend on LLM selection');
148
147
  };
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');
148
+ const result = await resolveObjective('Please prune the old snapshots', session);
149
+ assert.equal(result.capability, 'data.snapshot');
150
+ assert.equal(result.operation, 'prune');
152
151
  });
153
152
 
154
- test('resolveObjective routes "file unclassified concepts" to reclassify-concepts', async () => {
155
- const session = sessionWith([provider('production-1', concepts)]);
153
+ test('resolveObjective routes "clean old snapshots" to prune', async () => {
154
+ const session = sessionWith([provider('production-1', snapshot)]);
156
155
  session.llm.completeWithTools = async () => {
157
156
  throw new Error('the aliased operation must not depend on LLM selection');
158
157
  };
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');
158
+ const result = await resolveObjective('Clean old snapshots of the workspace', session);
159
+ assert.equal(result.capability, 'data.snapshot');
160
+ assert.equal(result.operation, 'prune');
162
161
  });
163
162
 
164
- test('resolveObjective routes "concept grid" to the concepts operation', async () => {
165
- const session = sessionWith([provider('production-1', concepts)]);
163
+ test('resolveObjective routes "take a snapshot" to the snapshot operation', async () => {
164
+ const session = sessionWith([provider('production-1', snapshot)]);
166
165
  session.llm.completeWithTools = async () => {
167
166
  throw new Error('the aliased operation must not depend on LLM selection');
168
167
  };
169
- const result = await resolveObjective('Rebuild the concept grid', session);
170
- assert.equal(result.capability, 'knowledge.concepts');
171
- assert.equal(result.operation, 'concepts');
168
+ const result = await resolveObjective('Take a snapshot of the workspace', session);
169
+ assert.equal(result.capability, 'data.snapshot');
170
+ assert.equal(result.operation, 'snapshot');
172
171
  });
173
172
 
174
173
  test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {
@@ -0,0 +1,168 @@
1
+ import {
2
+ RUNTIME_PROTOCOL_VERSION,
3
+ RuntimeProviderUnavailableError,
4
+ normalizeRuntimeEvent,
5
+ } from './runtimeProvider.js';
6
+
7
+ /**
8
+ * DeepAgentsProvider — client HTTP vers un runtime Deep Agents externe
9
+ * (RFC § 11, option A). Implémente le contrat RuntimeProvider :
10
+ *
11
+ * GET {endpoint}/health -> { ok, version? } (describe)
12
+ * GET {endpoint}/capabilities -> [{ name, operations }] (discover)
13
+ * POST {endpoint}/runs -> { runId, status } (execute)
14
+ * GET {endpoint}/runs/:id -> { runId, status, result? } (status)
15
+ * POST {endpoint}/runs/:id/cancel -> { ok } (cancel)
16
+ * GET {endpoint}/runs/:id/events -> SSE `data: {json}` (subscribe)
17
+ *
18
+ * `fetchImpl` est injectable pour les tests ; par défaut `globalThis.fetch`
19
+ * (Node 22 / Bun). Un runtime injoignable se manifeste par une `describe()`
20
+ * qui retourne `health: 'unavailable'` (jamais une exception) : l'isolation de
21
+ * panne du discovery s'appuie dessus.
22
+ */
23
+ export function createDeepAgentsProvider({
24
+ id = 'deepagents',
25
+ endpoint = 'http://agent-runtime:7789',
26
+ capabilities = null,
27
+ fetchImpl = globalThis.fetch,
28
+ headers = {},
29
+ version = null,
30
+ timeoutMs = 10_000,
31
+ } = {}) {
32
+ const base = String(endpoint).replace(/\/+$/, '');
33
+
34
+ async function httpJson(path, { method = 'GET', body = null, signal = null } = {}) {
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
37
+ const onAbort = () => controller.abort();
38
+ signal?.addEventListener?.('abort', onAbort, { once: true });
39
+ try {
40
+ const response = await fetchImpl(`${base}${path}`, {
41
+ method,
42
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...headers },
43
+ ...(body !== null ? { body: JSON.stringify(body) } : {}),
44
+ signal: controller.signal,
45
+ });
46
+ if (!response.ok) {
47
+ throw new RuntimeProviderUnavailableError(id, `HTTP ${response.status} on ${method} ${path}`);
48
+ }
49
+ return await response.json();
50
+ } catch (error) {
51
+ if (error instanceof RuntimeProviderUnavailableError) throw error;
52
+ throw new RuntimeProviderUnavailableError(id, error instanceof Error ? error.message : String(error));
53
+ } finally {
54
+ clearTimeout(timer);
55
+ signal?.removeEventListener?.('abort', onAbort);
56
+ }
57
+ }
58
+
59
+ return {
60
+ async describe() {
61
+ let health = 'available';
62
+ let runtimeVersion = version ?? null;
63
+ let lastError = null;
64
+ try {
65
+ const info = await httpJson('/health');
66
+ runtimeVersion = info?.version ?? runtimeVersion;
67
+ health = info?.ok === false ? 'unavailable' : 'available';
68
+ } catch (error) {
69
+ health = 'unavailable';
70
+ lastError = error?.reason ?? (error instanceof Error ? error.message : String(error));
71
+ }
72
+ return {
73
+ runtime: id,
74
+ version: runtimeVersion ?? 'unknown',
75
+ protocolVersion: RUNTIME_PROTOCOL_VERSION,
76
+ health,
77
+ ...(lastError ? { error: lastError } : {}),
78
+ };
79
+ },
80
+ async discoverCapabilities() {
81
+ if (Array.isArray(capabilities)) return capabilities;
82
+ const list = await httpJson('/capabilities');
83
+ return Array.isArray(list) ? list : [];
84
+ },
85
+ async execute(request = {}) {
86
+ const accepted = await httpJson('/runs', {
87
+ method: 'POST',
88
+ body: {
89
+ objective: request.objective ?? request.input ?? null,
90
+ operation: request.operation ?? null,
91
+ capability: request.capability ?? null,
92
+ arguments: request.arguments ?? {},
93
+ workspace: request.workspace ?? null,
94
+ model: request.model ?? null,
95
+ language: request.language ?? null,
96
+ mcp: Array.isArray(request.mcp) ? request.mcp : [],
97
+ systemPrompt: request.systemPrompt ?? null,
98
+ },
99
+ });
100
+ const runId = String(accepted?.runId ?? '');
101
+ if (!runId) throw new RuntimeProviderUnavailableError(id, 'execute did not return runId');
102
+ return { runId, status: String(accepted?.status ?? 'running') };
103
+ },
104
+ async status(runId) {
105
+ const state = await httpJson(`/runs/${encodeURIComponent(String(runId))}`);
106
+ return {
107
+ runId: String(state?.runId ?? runId),
108
+ status: String(state?.status ?? 'running'),
109
+ ...(state?.result ? { result: state.result } : {}),
110
+ // The gateway reports its failure at the TOP level; dropping it here
111
+ // swallowed the only actionable sentence ("Unable to infer model
112
+ // provider…") and left the manager to invent a cause.
113
+ ...(state?.error ? { error: state.error } : {}),
114
+ };
115
+ },
116
+ async cancel(runId) {
117
+ await httpJson(`/runs/${encodeURIComponent(String(runId))}/cancel`, { method: 'POST' });
118
+ },
119
+ async approve(runId, { approved = true, reason = null, scope = null } = {}) {
120
+ await httpJson(`/runs/${encodeURIComponent(String(runId))}/approve`, {
121
+ method: 'POST',
122
+ body: {
123
+ approved: approved === true,
124
+ ...(reason ? { reason } : {}),
125
+ ...(scope ? { scope } : {}),
126
+ },
127
+ });
128
+ },
129
+ subscribe(runId, listener) {
130
+ const controller = new AbortController();
131
+ const url = `${base}/runs/${encodeURIComponent(String(runId))}/events`;
132
+ void (async () => {
133
+ try {
134
+ const response = await fetchImpl(url, {
135
+ headers: { accept: 'text/event-stream', ...headers },
136
+ signal: controller.signal,
137
+ });
138
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
139
+ const reader = response.body.getReader();
140
+ const decoder = new TextDecoder();
141
+ let buffer = '';
142
+ while (true) {
143
+ const { done, value } = await reader.read();
144
+ if (done) break;
145
+ buffer += decoder.decode(value, { stream: true });
146
+ const blocks = buffer.split('\n\n');
147
+ buffer = blocks.pop() ?? '';
148
+ for (const block of blocks) {
149
+ let data = '';
150
+ for (const line of block.split('\n')) {
151
+ if (line.startsWith('data: ')) data += line.slice(6);
152
+ }
153
+ if (!data) continue;
154
+ try {
155
+ listener(normalizeRuntimeEvent(JSON.parse(data)));
156
+ } catch {
157
+ // malformed or out-of-contract frame — skip
158
+ }
159
+ }
160
+ }
161
+ } catch {
162
+ // stream ended or aborted — the unsubscribe path is a no-op
163
+ }
164
+ })();
165
+ return () => controller.abort();
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,178 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createDeepAgentsProvider } from './deepAgentsProvider.js';
4
+ import { RUNTIME_PROTOCOL_VERSION, assertRuntimeProvider } from './runtimeProvider.js';
5
+ import { resolveRuntimeProviders } from './runtimeProviders.js';
6
+
7
+ function jsonResponse(status, data, ok = status < 400) {
8
+ return { ok, status, json: async () => data };
9
+ }
10
+
11
+ function mockFetch(routes) {
12
+ const calls = [];
13
+ const fetchImpl = async (url, options = {}) => {
14
+ const parsed = new URL(url);
15
+ const key = `${options.method ?? 'GET'} ${parsed.pathname}`;
16
+ calls.push({ method: options.method ?? 'GET', path: parsed.pathname, body: options.body, url });
17
+ const handler = routes[key];
18
+ if (!handler) return { ok: false, status: 404, json: async () => ({}) };
19
+ return handler(parsed, options);
20
+ };
21
+ fetchImpl.calls = calls;
22
+ return fetchImpl;
23
+ }
24
+
25
+ function sseBody(events) {
26
+ const encoder = new TextEncoder();
27
+ const chunks = events.map((event) => encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
28
+ return new ReadableStream({
29
+ start(controller) {
30
+ for (const chunk of chunks) controller.enqueue(chunk);
31
+ controller.close();
32
+ },
33
+ });
34
+ }
35
+
36
+ async function waitFor(predicate, { timeoutMs = 1000 } = {}) {
37
+ const deadline = Date.now() + timeoutMs;
38
+ while (Date.now() < deadline) {
39
+ if (predicate()) return;
40
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 2));
41
+ }
42
+ throw new Error('condition not met in time');
43
+ }
44
+
45
+ test('describe reports available and reads the version from /health', async () => {
46
+ const fetchImpl = mockFetch({
47
+ 'GET /health': () => jsonResponse(200, { ok: true, version: '0.6.10' }),
48
+ });
49
+ const provider = createDeepAgentsProvider({ id: 'deepagents', endpoint: 'http://agent-runtime:8080', fetchImpl });
50
+ assertRuntimeProvider(provider);
51
+
52
+ const description = await provider.describe();
53
+ assert.equal(description.runtime, 'deepagents');
54
+ assert.equal(description.version, '0.6.10');
55
+ assert.equal(description.protocolVersion, RUNTIME_PROTOCOL_VERSION);
56
+ assert.equal(description.health, 'available');
57
+ });
58
+
59
+ test('describe reports unavailable when /health fails, without throwing', async () => {
60
+ const fetchImpl = mockFetch({}); // every route 404
61
+ const provider = createDeepAgentsProvider({ id: 'deepagents', endpoint: 'http://agent-runtime:8080', fetchImpl });
62
+
63
+ const description = await provider.describe();
64
+ assert.equal(description.health, 'unavailable');
65
+ assert.ok(description.error, 'the reason is carried');
66
+ });
67
+
68
+ test('discoverCapabilities fetches /capabilities', async () => {
69
+ const fetchImpl = mockFetch({
70
+ 'GET /capabilities': () => jsonResponse(200, [{ name: 'agent.review', operations: ['run'] }]),
71
+ });
72
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
73
+
74
+ assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
75
+ });
76
+
77
+ test('discoverCapabilities uses the static config when provided', async () => {
78
+ const fetchImpl = mockFetch({});
79
+ const provider = createDeepAgentsProvider({
80
+ endpoint: 'http://agent-runtime:8080',
81
+ capabilities: [{ name: 'agent.review', operations: ['run'] }],
82
+ fetchImpl,
83
+ });
84
+
85
+ assert.deepEqual(await provider.discoverCapabilities(), [{ name: 'agent.review', operations: ['run'] }]);
86
+ assert.equal(fetchImpl.calls.length, 0, 'no HTTP call when capabilities are static');
87
+ });
88
+
89
+ test('execute POSTs /runs and returns the runId', async () => {
90
+ const fetchImpl = mockFetch({
91
+ 'POST /runs': () => jsonResponse(200, { runId: 'run-1', status: 'running' }),
92
+ });
93
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
94
+
95
+ const run = await provider.execute({
96
+ objective: 'analyze the demo workspace',
97
+ operation: 'run',
98
+ arguments: {},
99
+ model: { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret' },
100
+ });
101
+ assert.deepEqual(run, { runId: 'run-1', status: 'running' });
102
+ assert.equal(fetchImpl.calls[0].path, '/runs');
103
+ const sent = JSON.parse(fetchImpl.calls[0].body);
104
+ assert.equal(sent.objective, 'analyze the demo workspace');
105
+ assert.deepEqual(sent.model, { baseUrl: 'http://llm:11434/v1', model: 'qwen3:14b', apiKey: 'secret' });
106
+ });
107
+
108
+ test('status and cancel hit the run-scoped routes', async () => {
109
+ const fetchImpl = mockFetch({
110
+ 'GET /runs/run-1': () => jsonResponse(200, { runId: 'run-1', status: 'completed', result: { status: 'completed' } }),
111
+ 'POST /runs/run-1/cancel': () => jsonResponse(200, { ok: true }),
112
+ });
113
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
114
+
115
+ const status = await provider.status('run-1');
116
+ assert.equal(status.status, 'completed');
117
+ assert.equal(status.result.status, 'completed');
118
+
119
+ await provider.cancel('run-1');
120
+ assert.equal(fetchImpl.calls.at(-1).path, '/runs/run-1/cancel');
121
+ });
122
+
123
+ test('status forwards the top-level error reported by the gateway', async () => {
124
+ const fetchImpl = mockFetch({
125
+ 'GET /runs/run-2': () => jsonResponse(200, { runId: 'run-2', status: 'failed', error: 'Unable to infer model provider' }),
126
+ });
127
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
128
+
129
+ const status = await provider.status('run-2');
130
+ assert.equal(status.status, 'failed');
131
+ assert.equal(status.error, 'Unable to infer model provider');
132
+ });
133
+
134
+ test('subscribe parses the SSE stream and forwards normalized events', async () => {
135
+ const fetchImpl = mockFetch({
136
+ 'GET /runs/run-1/events': () => ({
137
+ ok: true,
138
+ status: 200,
139
+ body: sseBody([
140
+ { type: 'tool_started', tool: 'wiki_search' },
141
+ { type: 'tool_finished', tool: 'wiki_search', resultSummary: '17 found' },
142
+ ]),
143
+ }),
144
+ });
145
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
146
+
147
+ const events = [];
148
+ provider.subscribe('run-1', (event) => events.push(event));
149
+
150
+ await waitFor(() => events.length === 2);
151
+ assert.deepEqual(events.map((event) => event.type), ['tool_started', 'tool_finished']);
152
+ assert.equal(events[0].tool, 'wiki_search');
153
+ });
154
+
155
+ test('approve posts the decision to the run approval route', async () => {
156
+ const fetchImpl = mockFetch({
157
+ 'POST /runs/run-1/approve': () => jsonResponse(200, { ok: true }),
158
+ });
159
+ const provider = createDeepAgentsProvider({ endpoint: 'http://agent-runtime:8080', fetchImpl });
160
+
161
+ await provider.approve('run-1', { approved: true, scope: ['email'] });
162
+
163
+ assert.equal(fetchImpl.calls.at(-1).path, '/runs/run-1/approve');
164
+ const sent = JSON.parse(fetchImpl.calls.at(-1).body);
165
+ assert.equal(sent.approved, true);
166
+ assert.deepEqual(sent.scope, ['email']);
167
+ });
168
+
169
+ test('the deepagents factory is reachable from the agentRuntimes config', () => {
170
+ const { providers, skipped } = resolveRuntimeProviders([
171
+ { id: 'deepagents', type: 'deepagents', endpoint: 'http://agent-runtime:8080', capabilities: [{ name: 'agent.review', operations: ['run'] }] },
172
+ ]);
173
+
174
+ assert.equal(skipped.length, 0);
175
+ assert.equal(providers.length, 1);
176
+ assert.equal(providers[0].type, 'deepagents');
177
+ assertRuntimeProvider(providers[0].provider);
178
+ });