@dotdrelle/wiki-manager 0.15.29 → 0.15.34

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.
@@ -112,3 +112,76 @@ test('agentRegistry marks unavailable boot agents and emits health changes on re
112
112
  assert.equal(events[1].payload.health, 'available');
113
113
  assert.equal(registry.snapshot()[0].health, 'available');
114
114
  });
115
+
116
+ test('discovery sends the workspace only to agents whose schema declares it', async () => {
117
+ const seen = {};
118
+ const registry = createAgentRegistry({
119
+ callTool: async (_mcp, server, _tool, args) => {
120
+ seen[server] = args;
121
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
122
+ },
123
+ });
124
+
125
+ await registry.discover({
126
+ workspace: 'acpi',
127
+ mcp: {
128
+ // Declares workspace: gets it, and can scope its vocabulary.
129
+ cme: {
130
+ status: 'connected',
131
+ tools: [{
132
+ name: 'agent_describe',
133
+ inputSchema: { type: 'object', properties: { workspace: { type: 'string' } }, additionalProperties: false },
134
+ }],
135
+ },
136
+ // Strict schema without workspace: sending it would be REJECTED, the
137
+ // agent would drop out of the registry, and its capabilities would
138
+ // silently vanish from objective resolution.
139
+ production: {
140
+ status: 'connected',
141
+ tools: [{
142
+ name: 'agent_describe',
143
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
144
+ }],
145
+ },
146
+ // Open schema: tolerant, so the workspace is worth sending.
147
+ connectors: {
148
+ status: 'connected',
149
+ tools: [{ name: 'agent_describe', inputSchema: { type: 'object', properties: {} } }],
150
+ },
151
+ // No schema published at all: assume nothing.
152
+ legacyish: {
153
+ status: 'connected',
154
+ tools: [{ name: 'agent_describe' }],
155
+ },
156
+ },
157
+ });
158
+
159
+ assert.deepEqual(seen.cme, { workspace: 'acpi' });
160
+ assert.deepEqual(seen.production, {});
161
+ assert.deepEqual(seen.connectors, { workspace: 'acpi' });
162
+ assert.deepEqual(seen.legacyish, {});
163
+ });
164
+
165
+ test('discovery sends no workspace argument when no workspace is active', async () => {
166
+ const seen = [];
167
+ const registry = createAgentRegistry({
168
+ callTool: async (_mcp, _server, _tool, args) => {
169
+ seen.push(args);
170
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
171
+ },
172
+ });
173
+
174
+ await registry.discover({
175
+ mcp: {
176
+ cme: {
177
+ status: 'connected',
178
+ tools: [{
179
+ name: 'agent_describe',
180
+ inputSchema: { type: 'object', properties: { workspace: { type: 'string' } } },
181
+ }],
182
+ },
183
+ },
184
+ });
185
+
186
+ assert.deepEqual(seen, [{}]);
187
+ });
@@ -243,10 +243,15 @@ export function normalizeTaskError(rawError, { fallbackCode = null, fallbackMess
243
243
  retryable: transientError(fallbackCode),
244
244
  };
245
245
  }
246
+ // The agent gave a real reason. `fallbackMessage` describes only WHERE the
247
+ // failure was observed ("agent_execute rejected task"), so letting it win
248
+ // here replaced the one actionable sentence we have with a generic one —
249
+ // and Donna, left with nothing to explain, invented a cause. The fallback
250
+ // is a last resort, never an override.
246
251
  const code = String(rawError);
247
252
  return {
248
253
  code,
249
- message: String(fallbackMessage ?? code),
254
+ message: code,
250
255
  retryable: transientError(code),
251
256
  };
252
257
  }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createDispatcher } from './dispatcher.js';
3
+ import { createDispatcher, normalizeTaskError } from './dispatcher.js';
4
4
 
5
5
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
6
  const session = {
@@ -159,3 +159,26 @@ test('dispatcher keeps a null error when a terminal status reports none', async
159
159
  assert.equal(result.ok, true);
160
160
  assert.equal(result.error, null);
161
161
  });
162
+
163
+ test('normalizeTaskError keeps the agent reason as the message, never the fallback', () => {
164
+ // Regression: the fallback describes only WHERE the failure was seen
165
+ // ("agent_execute rejected task"). Letting it win discarded the one
166
+ // actionable sentence — and left Donna to invent a cause.
167
+ const error = normalizeTaskError("Error: source 'acpi' not found", {
168
+ fallbackCode: 'execution_rejected',
169
+ fallbackMessage: 'agent_execute rejected task',
170
+ });
171
+
172
+ assert.equal(error.code, "Error: source 'acpi' not found");
173
+ assert.equal(error.message, "Error: source 'acpi' not found");
174
+ });
175
+
176
+ test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
177
+ const error = normalizeTaskError('', {
178
+ fallbackCode: 'execution_rejected',
179
+ fallbackMessage: 'agent_execute rejected task',
180
+ });
181
+
182
+ assert.equal(error.code, 'execution_rejected');
183
+ assert.equal(error.message, 'agent_execute rejected task');
184
+ });
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { resolveRuntimeAuthToken } from './auth.js';
7
- import { assertRuntimeNode, runtimeNodeExecutable, shutdownOwnedRuntime } from './lifecycle.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
8
8
 
9
9
  test('resolveRuntimeAuthToken: loopback host does not require token', () => {
10
10
  const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
@@ -27,67 +27,3 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
27
27
  assert.ok(runtimeNode.executable);
28
28
  assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
29
29
  });
30
-
31
- test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
32
- const originalFetch = globalThis.fetch;
33
- const calls = [];
34
- const logs = [];
35
- globalThis.fetch = async (url, options = {}) => {
36
- calls.push({ url: String(url), method: options.method ?? 'GET' });
37
- return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
38
- status: calls.length === 1 ? 200 : 202,
39
- headers: { 'content-type': 'application/json' },
40
- });
41
- };
42
- try {
43
- const result = await shutdownOwnedRuntime(
44
- { url: 'http://127.0.0.1:7788', started: true, token: null },
45
- { log: (message) => logs.push(message), timeoutMs: 100 },
46
- );
47
- assert.equal(result.action, 'shutdown');
48
- assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
49
- assert.match(logs.join('\n'), /runtime arrêté/);
50
- } finally {
51
- globalThis.fetch = originalFetch;
52
- }
53
- });
54
-
55
- test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
56
- const originalFetch = globalThis.fetch;
57
- const calls = [];
58
- globalThis.fetch = async (_url, options = {}) => {
59
- calls.push(options.method ?? 'GET');
60
- return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
61
- status: calls.length === 1 ? 200 : 202,
62
- headers: { 'content-type': 'application/json' },
63
- });
64
- };
65
- try {
66
- const result = await shutdownOwnedRuntime(
67
- { url: 'http://127.0.0.1:7788', started: false, token: null },
68
- { timeoutMs: 100 },
69
- );
70
- assert.equal(result.action, 'shutdown');
71
- assert.deepEqual(calls, ['GET', 'POST']);
72
- } finally {
73
- globalThis.fetch = originalFetch;
74
- }
75
- });
76
-
77
- test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
78
- const originalFetch = globalThis.fetch;
79
- const logs = [];
80
- globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
81
- signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
82
- });
83
- try {
84
- const result = await shutdownOwnedRuntime(
85
- { url: 'http://127.0.0.1:7788', started: true, token: null },
86
- { log: (message) => logs.push(message), timeoutMs: 5 },
87
- );
88
- assert.equal(result.action, 'timeout');
89
- assert.match(logs.join('\n'), /délai de fermeture/);
90
- } finally {
91
- globalThis.fetch = originalFetch;
92
- }
93
- });
@@ -146,7 +146,8 @@ test('CME setup and source configuration stay outside export orchestration', asy
146
146
  const calledTools = [];
147
147
  globalThis.fetch = async (_url, options = {}) => {
148
148
  const body = JSON.parse(String(options.body ?? '{}'));
149
- calledTools.push(body.params?.name);
149
+ // Record tool calls only — the MCP session handshake is not a tool call.
150
+ if (body.method === 'tools/call') calledTools.push(body.params?.name);
150
151
  return {
151
152
  ok: true,
152
153
  status: 200,
@@ -139,36 +139,3 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
139
139
  return null;
140
140
  }
141
141
  }
142
-
143
- // Close the runtime attached to the shell when it is idle, including a process
144
- // reused at startup. Restricting cleanup to `started: true` left ownerless
145
- // runtimes occupying port 7788 with another manager directory/token.
146
- // An active run still survives the shell.
147
- export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
148
- if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
149
- const controller = new AbortController();
150
- const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
151
- try {
152
- const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
153
- if (!health) return { action: 'kept', reason: 'unreachable' };
154
- const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
155
- if (activeRuns.length > 0) {
156
- const labels = activeRuns
157
- .map((run) => [run.workspace, run.runId ? String(run.runId).slice(0, 8) : null].filter(Boolean).join('/'))
158
- .join(', ');
159
- log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
160
- return { action: 'kept', reason: 'run_active', activeRuns };
161
- }
162
- await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
163
- log('runtime arrêté (aucun run en cours).');
164
- return { action: 'shutdown' };
165
- } catch (err) {
166
- if (controller.signal.aborted) {
167
- log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
168
- return { action: 'timeout', reason: 'shutdown_timeout' };
169
- }
170
- return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
171
- } finally {
172
- clearTimeout(timeout);
173
- }
174
- }
@@ -401,7 +401,16 @@ test('replanRuntimeRun requires runtime approval for mutating replanned work', a
401
401
  test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
402
402
  const originalFetch = globalThis.fetch;
403
403
  let pollAttempts = 0;
404
- globalThis.fetch = async () => {
404
+ globalThis.fetch = async (_url, init) => {
405
+ // Count polls, not the one-off MCP session handshake.
406
+ if (JSON.parse(init.body).method === 'initialize') {
407
+ return {
408
+ ok: true,
409
+ status: 200,
410
+ headers: { get: () => null },
411
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
412
+ };
413
+ }
405
414
  pollAttempts += 1;
406
415
  return {
407
416
  ok: true,
@@ -93,7 +93,17 @@ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt m
93
93
  test('pollActivitiesOnce retries transient MCP poll failures', async () => {
94
94
  const originalFetch = globalThis.fetch;
95
95
  let attempts = 0;
96
- globalThis.fetch = async () => {
96
+ globalThis.fetch = async (_url, init) => {
97
+ // The session handshake is not the thing under test: answer it, and make
98
+ // the first *poll* the transient failure.
99
+ if (JSON.parse(init.body).method === 'initialize') {
100
+ return {
101
+ ok: true,
102
+ status: 200,
103
+ headers: { get: () => null },
104
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
105
+ };
106
+ }
97
107
  attempts += 1;
98
108
  if (attempts === 1) {
99
109
  return {