arisa 5.1.13 → 5.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +2 -0
- package/package.json +7 -8
- package/src/core/tools/official-tool-installer.js +78 -6
- package/src/core/tools/tool-dependencies.js +99 -0
- package/src/core/tools/tool-registry.js +50 -6
- package/src/official-tools.lock.json +157 -5
- package/src/runtime/arisa-capabilities.js +12 -1
- package/src/runtime/create-app.js +1 -0
- package/src/runtime/doctor.js +71 -19
- package/src/runtime/tool-usage-report.js +25 -10
- package/src/transport/telegram/bot.js +38 -6
- package/test/capabilities-security.test.js +21 -0
- package/test/context-and-task-bounds.test.js +28 -2
- package/test/doctor.test.js +55 -0
- package/test/official-tool-dependencies.test.js +23 -0
- package/test/official-tool-installer.test.js +37 -9
- package/test/tool-dependencies.test.js +53 -0
- package/test/tool-registry-run.test.js +19 -1
- package/test/tool-usage.test.js +26 -4
package/src/runtime/doctor.js
CHANGED
|
@@ -87,16 +87,43 @@ function daemonLabel(record) {
|
|
|
87
87
|
return `${record.toolName} (${record.instanceId || "global"})`;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function
|
|
91
|
-
|
|
90
|
+
function daemonState(result) {
|
|
91
|
+
return result.diagnostic?.state || result.outcome;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function daemonStateLabel(state) {
|
|
95
|
+
return String(state || "unknown")
|
|
96
|
+
.split("-")
|
|
97
|
+
.map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part)
|
|
98
|
+
.join(" ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function daemonReportLines(results) {
|
|
102
|
+
const priority = ["ready", "starting", "degraded", "unhealthy", "restarting", "stopped", "failed"];
|
|
103
|
+
const groups = new Map();
|
|
92
104
|
for (const result of results) {
|
|
93
|
-
const state = result
|
|
94
|
-
|
|
105
|
+
const state = daemonState(result);
|
|
106
|
+
if (!groups.has(state)) groups.set(state, []);
|
|
107
|
+
groups.get(state).push(result);
|
|
95
108
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
const states = [...groups.keys()].sort((left, right) => {
|
|
110
|
+
const leftIndex = priority.indexOf(left);
|
|
111
|
+
const rightIndex = priority.indexOf(right);
|
|
112
|
+
if (leftIndex === -1 && rightIndex === -1) return left.localeCompare(right);
|
|
113
|
+
if (leftIndex === -1) return 1;
|
|
114
|
+
if (rightIndex === -1) return -1;
|
|
115
|
+
return leftIndex - rightIndex;
|
|
116
|
+
});
|
|
117
|
+
const lines = [];
|
|
118
|
+
for (const state of states) {
|
|
119
|
+
const group = groups.get(state);
|
|
120
|
+
lines.push(` ${daemonStateLabel(state)} (${group.length})`);
|
|
121
|
+
for (const result of group.sort((left, right) => daemonLabel(left.record).localeCompare(daemonLabel(right.record)))) {
|
|
122
|
+
const scope = result.record.scope?.type || (result.record.instanceId === "global" ? "global" : "chat");
|
|
123
|
+
lines.push(...wrapReportText(`${result.record.toolName} [${scope}]`, { firstPrefix: " - ", nextPrefix: " " }));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
function formatTokenCount(tokens) {
|
|
@@ -123,6 +150,24 @@ function formatUptime(seconds) {
|
|
|
123
150
|
return [days ? `${days}d` : "", hours ? `${hours}h` : "", `${minutes}m`].filter(Boolean).join(" ");
|
|
124
151
|
}
|
|
125
152
|
|
|
153
|
+
function masterSlaveMode(infrastructure) {
|
|
154
|
+
const parts = [infrastructure.role || "unknown", infrastructure.daemon?.state || "unknown"];
|
|
155
|
+
if (infrastructure.paired != null) parts.push(infrastructure.paired ? "paired" : "unpaired");
|
|
156
|
+
return parts.join(" · ");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function masterSlaveActivity(jobs) {
|
|
160
|
+
const active = jobs?.active;
|
|
161
|
+
const queued = jobs?.queued;
|
|
162
|
+
const failed = jobs?.failed;
|
|
163
|
+
if ([active, queued, failed].every((value) => value === 0)) return "idle";
|
|
164
|
+
return `${active ?? "?"} active · ${queued ?? "?"} queued · ${failed ?? "?"} failed`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function compactEndpoint(endpoint) {
|
|
168
|
+
return String(endpoint || "not configured").replace(/^tcp:\/\//, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
126
171
|
export async function inspectSystemResources({ diskPath = arisaHomeDir } = {}) {
|
|
127
172
|
const [load1, load5, load15] = os.loadavg();
|
|
128
173
|
const memoryTotal = os.totalmem();
|
|
@@ -221,22 +266,18 @@ export function formatDoctorReport(report) {
|
|
|
221
266
|
lines.push(...reportRow("Large", large));
|
|
222
267
|
lines.push(...reportRow("Ineff.", inefficient));
|
|
223
268
|
if (measured.length) lines.push(...reportRow("Max", `${Math.max(...measured.map((context) => context.percent)).toFixed(1)}%`));
|
|
224
|
-
lines.push("",
|
|
225
|
-
lines.push(...
|
|
226
|
-
if (report.daemons.length) lines.push(...reportRow("Status", daemonResultSummary(report.daemons)));
|
|
269
|
+
lines.push("", `Daemons (${report.daemons.length})`);
|
|
270
|
+
if (report.daemons.length) lines.push(...daemonReportLines(report.daemons));
|
|
227
271
|
if (report.infrastructure) {
|
|
228
272
|
lines.push("", "Master/Slave");
|
|
229
273
|
if (report.infrastructure.error) {
|
|
230
274
|
lines.push(...reportRow("Status", `unavailable: ${report.infrastructure.error}`));
|
|
231
275
|
} else {
|
|
232
|
-
lines.push(...reportRow("
|
|
233
|
-
lines.push(...reportRow("
|
|
234
|
-
lines.push(...reportRow("
|
|
235
|
-
lines.push(...reportRow("Identity", report.infrastructure.identityFingerprint || "not configured"));
|
|
236
|
-
lines.push(...reportRow("Paired", report.infrastructure.paired == null ? "n/a" : report.infrastructure.paired ? "yes" : "no"));
|
|
276
|
+
lines.push(...reportRow("Mode", masterSlaveMode(report.infrastructure)));
|
|
277
|
+
lines.push(...reportRow("Endpoint", compactEndpoint(report.infrastructure.endpoint)));
|
|
278
|
+
lines.push(...reportRow("Activity", masterSlaveActivity(report.infrastructure.jobs)));
|
|
237
279
|
lines.push(...reportRow("Tools", report.infrastructure.toolCount ?? "unknown"));
|
|
238
|
-
lines.push(...reportRow("
|
|
239
|
-
lines.push(...reportRow("Pending secrets", report.infrastructure.pendingSecrets ?? "unknown"));
|
|
280
|
+
lines.push(...reportRow("Secrets", `${report.infrastructure.pendingSecrets ?? "unknown"} pending`));
|
|
240
281
|
}
|
|
241
282
|
}
|
|
242
283
|
if (report.system) {
|
|
@@ -273,7 +314,8 @@ export async function runDoctor({
|
|
|
273
314
|
stopDaemon = stopManagedDaemon,
|
|
274
315
|
unregisterDaemon = unregisterManagedDaemon,
|
|
275
316
|
inspectResources = inspectSystemResources,
|
|
276
|
-
inspectInfrastructure = null
|
|
317
|
+
inspectInfrastructure = null,
|
|
318
|
+
inspectToolDependencies = null
|
|
277
319
|
}) {
|
|
278
320
|
assertDoctorPolicy(doctorPolicy);
|
|
279
321
|
const runtime = await agentManager.getRuntimeDiagnostic({
|
|
@@ -290,6 +332,16 @@ export async function runDoctor({
|
|
|
290
332
|
infrastructure: null
|
|
291
333
|
};
|
|
292
334
|
addContextAttention(report);
|
|
335
|
+
if (inspectToolDependencies) {
|
|
336
|
+
try {
|
|
337
|
+
for (const issue of await inspectToolDependencies()) {
|
|
338
|
+
const installed = issue.installedVersion ? `; installed ${issue.installedVersion}` : "";
|
|
339
|
+
report.attention.push(`Tool dependency ${issue.type}: ${issue.tool} requires ${issue.dependency}@${issue.range || "valid"}${installed}.`);
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
report.attention.push(`Tool dependency inspection failed: ${error?.message || error}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
293
345
|
if (inspectInfrastructure) {
|
|
294
346
|
try {
|
|
295
347
|
report.infrastructure = await inspectInfrastructure();
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { renderTextReport } from "./report-format.js";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
if (!tools.length) lines.push(" (none installed)");
|
|
6
|
-
|
|
7
|
-
const sortedTools = [...tools].sort((left, right) =>
|
|
3
|
+
function sortedTools(tools) {
|
|
4
|
+
return [...tools].sort((left, right) =>
|
|
8
5
|
Number(right.count) - Number(left.count) || String(left.name).localeCompare(String(right.name))
|
|
9
6
|
);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function usageRows(tools, nameWidth, countWidth) {
|
|
10
|
+
if (!tools.length) return [" (none)"];
|
|
11
|
+
return sortedTools(tools).map((tool) => {
|
|
13
12
|
const name = String(tool.name).padEnd(nameWidth);
|
|
14
13
|
const count = String(tool.count).padStart(countWidth);
|
|
15
|
-
|
|
16
|
-
}
|
|
14
|
+
return `- ${name} ${count}`;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatToolUsageReport(tools) {
|
|
19
|
+
const official = tools.filter((tool) => tool.official);
|
|
20
|
+
const local = tools.filter((tool) => !tool.official);
|
|
21
|
+
const nameWidth = Math.max(0, ...tools.map((tool) => String(tool.name).length));
|
|
22
|
+
const countWidth = Math.max(1, ...tools.map((tool) => String(tool.count).length));
|
|
23
|
+
const lines = [
|
|
24
|
+
"Arisa tools",
|
|
25
|
+
"===========",
|
|
26
|
+
"Official",
|
|
27
|
+
...usageRows(official, nameWidth, countWidth),
|
|
28
|
+
"",
|
|
29
|
+
"Local",
|
|
30
|
+
...usageRows(local, nameWidth, countWidth)
|
|
31
|
+
];
|
|
17
32
|
return renderTextReport(lines);
|
|
18
33
|
}
|
|
@@ -435,16 +435,30 @@ function sanitizeSessionHandoff(text) {
|
|
|
435
435
|
return `${sanitized.slice(0, 3997).trim()}...`;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
-
async function
|
|
439
|
-
await ctx.api.sendChatAction(ctx.chat.id, "typing");
|
|
438
|
+
export async function startTelegramTyping(ctx) {
|
|
439
|
+
await ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => {});
|
|
440
440
|
const timer = setInterval(() => {
|
|
441
441
|
ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => {});
|
|
442
442
|
}, 4000);
|
|
443
|
+
return () => clearInterval(timer);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function ensureQueuedTelegramTyping(chatState, ctx) {
|
|
447
|
+
if (chatState.stopQueuedTyping) return;
|
|
448
|
+
chatState.stopQueuedTyping = await startTelegramTyping(ctx);
|
|
449
|
+
}
|
|
443
450
|
|
|
451
|
+
export function stopQueuedTelegramTyping(chatState) {
|
|
452
|
+
chatState.stopQueuedTyping?.();
|
|
453
|
+
chatState.stopQueuedTyping = null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function withTyping(ctx, work) {
|
|
457
|
+
const stopTyping = await startTelegramTyping(ctx);
|
|
444
458
|
try {
|
|
445
459
|
return await work();
|
|
446
460
|
} finally {
|
|
447
|
-
|
|
461
|
+
stopTyping();
|
|
448
462
|
}
|
|
449
463
|
}
|
|
450
464
|
|
|
@@ -460,7 +474,8 @@ export function createChatStateStore() {
|
|
|
460
474
|
beforeNextPrompt: null,
|
|
461
475
|
activeSession: null,
|
|
462
476
|
activeSteers: [],
|
|
463
|
-
assistantMessages: new Map()
|
|
477
|
+
assistantMessages: new Map(),
|
|
478
|
+
stopQueuedTyping: null
|
|
464
479
|
};
|
|
465
480
|
states.set(String(chatId), state);
|
|
466
481
|
return state;
|
|
@@ -559,6 +574,7 @@ export async function drainChatPromptQueue({
|
|
|
559
574
|
chatState.continueAfterClose = false;
|
|
560
575
|
}
|
|
561
576
|
} finally {
|
|
577
|
+
stopQueuedTelegramTyping(chatState);
|
|
562
578
|
chatState.processing = false;
|
|
563
579
|
chatState.activeSession = null;
|
|
564
580
|
chatState.activeSteers = [];
|
|
@@ -1069,6 +1085,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
1069
1085
|
const chatState = getChatState(ctx.chat.id);
|
|
1070
1086
|
|
|
1071
1087
|
if (chatState.processing) {
|
|
1088
|
+
await ensureQueuedTelegramTyping(chatState, ctx);
|
|
1072
1089
|
const incomingPrompt = await buildIncomingPrompt(ctx);
|
|
1073
1090
|
const busyMessageMode = typeof ctx.message?.text === "string"
|
|
1074
1091
|
? resolveTelegramBusyMessageMode(config, ctx.chat.id)
|
|
@@ -1115,6 +1132,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
1115
1132
|
timer.unref?.();
|
|
1116
1133
|
}
|
|
1117
1134
|
|
|
1135
|
+
async function enqueueAsyncPrompt({ chatId, prompt, label }) {
|
|
1136
|
+
const ctx = { chat: { id: chatId }, api: bot.api };
|
|
1137
|
+
const chatState = getChatState(chatId);
|
|
1138
|
+
if (chatState.processing) await ensureQueuedTelegramTyping(chatState, ctx);
|
|
1139
|
+
return enqueuePrompt({ chatId, prompt, label, ctx });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1118
1142
|
async function dispatchTask(task) {
|
|
1119
1143
|
const chatId = task.payload?.chatId;
|
|
1120
1144
|
if (!chatId) {
|
|
@@ -1128,7 +1152,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
1128
1152
|
return;
|
|
1129
1153
|
}
|
|
1130
1154
|
logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
|
|
1131
|
-
await
|
|
1155
|
+
await enqueueAsyncPrompt({
|
|
1132
1156
|
chatId,
|
|
1133
1157
|
prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
|
|
1134
1158
|
label: `scheduled task ${task.id}`
|
|
@@ -1139,7 +1163,15 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
1139
1163
|
|
|
1140
1164
|
if (task.kind === "agent_event") {
|
|
1141
1165
|
logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
|
|
1142
|
-
|
|
1166
|
+
const acknowledgement = String(task.payload?.acknowledgement || "").trim();
|
|
1167
|
+
if (acknowledgement) {
|
|
1168
|
+
try {
|
|
1169
|
+
await bot.api.sendMessage(chatId, acknowledgement);
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
logger?.log("telegram", `agent event acknowledgement failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
await enqueueAsyncPrompt({
|
|
1143
1175
|
chatId,
|
|
1144
1176
|
prompt: await buildAsyncEventPrompt(task, resourceNotes),
|
|
1145
1177
|
label: `agent event ${task.id}`
|
|
@@ -142,6 +142,27 @@ test("requires chatId for chat-scoped IPC methods", async () => {
|
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test("agent events preserve a bounded immediate acknowledgement", async () => {
|
|
146
|
+
const capabilities = createCapabilities();
|
|
147
|
+
const created = await capabilities.dispatch({
|
|
148
|
+
method: "agent.enqueueEvent",
|
|
149
|
+
toolName: "browser-session-bridge",
|
|
150
|
+
chatId: "chat-a",
|
|
151
|
+
params: {
|
|
152
|
+
prompt: "Continue the pending authorization flow",
|
|
153
|
+
acknowledgement: "Authorization received. Continuing now."
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.equal(created.payload.acknowledgement, "Authorization received. Continuing now.");
|
|
158
|
+
await assert.rejects(() => capabilities.dispatch({
|
|
159
|
+
method: "agent.enqueueEvent",
|
|
160
|
+
toolName: "browser-session-bridge",
|
|
161
|
+
chatId: "chat-a",
|
|
162
|
+
params: { prompt: "Continue", acknowledgement: "x".repeat(501) }
|
|
163
|
+
}), /at most 500 characters/);
|
|
164
|
+
});
|
|
165
|
+
|
|
145
166
|
test("delivers only artifacts resolved from the requesting chat", async () => {
|
|
146
167
|
const artifact = { id: "artifact-1", chatId: "chat-a", path: "/safe/chat-a/file.txt" };
|
|
147
168
|
const deliveries = [];
|
|
@@ -4,13 +4,38 @@ import {
|
|
|
4
4
|
collectText,
|
|
5
5
|
createChatStateStore,
|
|
6
6
|
drainChatPromptQueue,
|
|
7
|
+
ensureQueuedTelegramTyping,
|
|
7
8
|
isSilentReply,
|
|
8
9
|
queueChatPrompt,
|
|
9
10
|
resolveTelegramBusyMessageMode,
|
|
10
|
-
routeBusyPrompt
|
|
11
|
+
routeBusyPrompt,
|
|
12
|
+
stopQueuedTelegramTyping
|
|
11
13
|
} from "../src/transport/telegram/bot.js";
|
|
12
14
|
import { selectScheduledTasks } from "../src/core/agent/agent-manager.js";
|
|
13
15
|
|
|
16
|
+
test("queued Telegram prompts start typing immediately and share one indicator", async () => {
|
|
17
|
+
let actions = 0;
|
|
18
|
+
const chatState = { stopQueuedTyping: null };
|
|
19
|
+
const ctx = {
|
|
20
|
+
chat: { id: 879964957 },
|
|
21
|
+
api: {
|
|
22
|
+
async sendChatAction(chatId, action) {
|
|
23
|
+
assert.equal(chatId, 879964957);
|
|
24
|
+
assert.equal(action, "typing");
|
|
25
|
+
actions += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
await ensureQueuedTelegramTyping(chatState, ctx);
|
|
31
|
+
await ensureQueuedTelegramTyping(chatState, ctx);
|
|
32
|
+
assert.equal(actions, 1);
|
|
33
|
+
assert.equal(typeof chatState.stopQueuedTyping, "function");
|
|
34
|
+
|
|
35
|
+
stopQueuedTelegramTyping(chatState);
|
|
36
|
+
assert.equal(chatState.stopQueuedTyping, null);
|
|
37
|
+
});
|
|
38
|
+
|
|
14
39
|
function createSession(events) {
|
|
15
40
|
const listeners = new Set();
|
|
16
41
|
return {
|
|
@@ -134,7 +159,8 @@ test("chat state uses one queue for numeric and string chat IDs", () => {
|
|
|
134
159
|
beforeNextPrompt: null,
|
|
135
160
|
activeSession: null,
|
|
136
161
|
activeSteers: [],
|
|
137
|
-
assistantMessages: new Map()
|
|
162
|
+
assistantMessages: new Map(),
|
|
163
|
+
stopQueuedTyping: null
|
|
138
164
|
});
|
|
139
165
|
});
|
|
140
166
|
|
package/test/doctor.test.js
CHANGED
|
@@ -87,6 +87,61 @@ test("reports Pi context size and retained-content inefficiency", async () => {
|
|
|
87
87
|
assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test("lists each checked daemon with its scope and state", async () => {
|
|
91
|
+
const { report } = await run({
|
|
92
|
+
repairs: [
|
|
93
|
+
{
|
|
94
|
+
record: { toolName: "master-slave", instanceId: "global", scope: { type: "global" } },
|
|
95
|
+
diagnostic: { state: "ready" },
|
|
96
|
+
outcome: "healthy"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
record: { toolName: "context-vault", instanceId: "chat-1", scope: { type: "chat" } },
|
|
100
|
+
diagnostic: { state: "stopped" },
|
|
101
|
+
outcome: "stopped"
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
report.infrastructure = {
|
|
107
|
+
role: "master",
|
|
108
|
+
daemon: { state: "ready" },
|
|
109
|
+
endpoint: "tcp://198.74.61.48:4719",
|
|
110
|
+
paired: null,
|
|
111
|
+
identityFingerprint: "unnecessarily-long-fingerprint",
|
|
112
|
+
toolCount: 0,
|
|
113
|
+
jobs: { active: 0, queued: 0, failed: 0 },
|
|
114
|
+
pendingSecrets: 0
|
|
115
|
+
};
|
|
116
|
+
const formatted = formatDoctorReport(report);
|
|
117
|
+
assert.match(formatted, /Daemons \(2\)\n Ready \(1\)\n - master-slave \[global\]/);
|
|
118
|
+
assert.match(formatted, / Stopped \(1\)\n - context-vault \[chat\]/);
|
|
119
|
+
assert.match(formatted, /Master\/Slave\n Mode master · ready/);
|
|
120
|
+
assert.match(formatted, /Endpoint 198\.74\.61\.48:4719/);
|
|
121
|
+
assert.match(formatted, /Activity idle/);
|
|
122
|
+
assert.doesNotMatch(formatted, /Identity|fingerprint|Paired/);
|
|
123
|
+
assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("reports missing tool dependencies as attention items", async () => {
|
|
127
|
+
const report = await runDoctor({
|
|
128
|
+
agentManager: { getRuntimeDiagnostic: async () => runtime() },
|
|
129
|
+
toolProcessSupervisor: { repair: async () => [] },
|
|
130
|
+
daemonPolicy,
|
|
131
|
+
doctorPolicy,
|
|
132
|
+
listProcesses: async () => [],
|
|
133
|
+
serviceStatus: async () => ({ running: false }),
|
|
134
|
+
inspectResources: async () => system,
|
|
135
|
+
inspectToolDependencies: async () => [{
|
|
136
|
+
tool: "magnific-mcp",
|
|
137
|
+
type: "missing",
|
|
138
|
+
dependency: "mcp-client",
|
|
139
|
+
range: "^0.1.0"
|
|
140
|
+
}]
|
|
141
|
+
});
|
|
142
|
+
assert.match(report.attention.join("\n"), /magnific-mcp requires mcp-client@\^0\.1\.0/);
|
|
143
|
+
});
|
|
144
|
+
|
|
90
145
|
test("stops only a registered duplicate Arisa service with verified identity", async () => {
|
|
91
146
|
const duplicatePid = 321;
|
|
92
147
|
const { report, stopped } = await run({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
async function manifest(name) {
|
|
6
|
+
return JSON.parse(await readFile(new URL(`../../tools/${name}/tool.manifest.json`, import.meta.url), "utf8"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("official orchestrators declare their hard tool dependencies", async () => {
|
|
10
|
+
assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.1.0" });
|
|
11
|
+
assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
|
|
12
|
+
"pr-campaign": "^0.1.0",
|
|
13
|
+
"gmail-workspace": "^0.1.0"
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.2.0" });
|
|
16
|
+
assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("optional tool integrations do not become hard dependencies", async () => {
|
|
20
|
+
assert.deepEqual((await manifest("whatsapp-web")).toolDependencies, undefined);
|
|
21
|
+
assert.deepEqual((await manifest("pr-campaign")).toolDependencies, undefined);
|
|
22
|
+
assert.deepEqual((await manifest("master-slave")).toolDependencies, undefined);
|
|
23
|
+
});
|
|
@@ -60,14 +60,16 @@ test("verifies the exact file set and digests", async (t) => {
|
|
|
60
60
|
await assert.rejects(() => verifyOfficialToolTree(source, files), /unexpected=extra.js/);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test("bundled
|
|
63
|
+
test("every bundled official tool lock matches the catalog source", async () => {
|
|
64
64
|
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
for (const [name, entry] of Object.entries(lock.tools)) {
|
|
66
|
+
const source = fileURLToPath(new URL(`../../tools/${name}/`, import.meta.url));
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
await verifyOfficialToolTree(source, entry.files),
|
|
69
|
+
{ files: Object.keys(entry.files).length },
|
|
70
|
+
name
|
|
71
|
+
);
|
|
72
|
+
}
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
test("rejects symbolic links before deployment", async (t) => {
|
|
@@ -83,15 +85,18 @@ test("installs a verified staged tree without overwriting an existing tool", asy
|
|
|
83
85
|
await mkdir(path.join(checkoutDir, "tools"), { recursive: true });
|
|
84
86
|
await cp(source, path.join(checkoutDir, "tools", "master-slave"), { recursive: true });
|
|
85
87
|
};
|
|
88
|
+
const lifecycle = [];
|
|
86
89
|
const result = await installLockedOfficialTool({
|
|
87
90
|
toolName: "master-slave",
|
|
88
91
|
lock: lock(files),
|
|
89
92
|
destination,
|
|
90
93
|
scratchRoot: root,
|
|
91
94
|
checkout,
|
|
92
|
-
|
|
95
|
+
installDependencies: async () => { lifecycle.push("dependencies"); },
|
|
96
|
+
validate: async () => { lifecycle.push("validate"); }
|
|
93
97
|
});
|
|
94
98
|
assert.equal(result.commit, "a".repeat(40));
|
|
99
|
+
assert.deepEqual(lifecycle, ["dependencies", "validate"]);
|
|
95
100
|
assert.equal(await readFile(path.join(destination, "index.js"), "utf8"), "process.stdout.write('ok');\n");
|
|
96
101
|
await assert.rejects(
|
|
97
102
|
() => installLockedOfficialTool({ toolName: "master-slave", lock: lock(files), destination, scratchRoot: root, checkout }),
|
|
@@ -111,8 +116,31 @@ test("loads the bundled lock before selecting the canonical tool destination", a
|
|
|
111
116
|
return { installed: true };
|
|
112
117
|
}
|
|
113
118
|
});
|
|
114
|
-
assert.deepEqual(result, { installed: true });
|
|
119
|
+
assert.deepEqual(result, { installed: true, dependencies: [] });
|
|
115
120
|
assert.equal(calls[0].toolName, "master-slave");
|
|
116
121
|
assert.deepEqual(calls[0].lock, lock(files));
|
|
117
122
|
assert.match(calls[0].destination, /tools\/master-slave$/);
|
|
118
123
|
});
|
|
124
|
+
|
|
125
|
+
test("installs locked tool dependencies before the requested tool", async (t) => {
|
|
126
|
+
const { root, files } = await fixture(t);
|
|
127
|
+
const dependencyLock = lock(files);
|
|
128
|
+
dependencyLock.tools = {
|
|
129
|
+
"mcp-client": { version: "0.1.0", toolDependencies: {}, files },
|
|
130
|
+
"magnific-mcp": { version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" }, files }
|
|
131
|
+
};
|
|
132
|
+
const lockFile = path.join(root, "dependency-lock.json");
|
|
133
|
+
await writeFile(lockFile, `${JSON.stringify(dependencyLock)}\n`);
|
|
134
|
+
const calls = [];
|
|
135
|
+
const result = await installBundledOfficialTool("magnific-mcp", {
|
|
136
|
+
lockFile,
|
|
137
|
+
resolveInstalledVersion: async () => undefined,
|
|
138
|
+
install: async ({ toolName }) => {
|
|
139
|
+
calls.push(toolName);
|
|
140
|
+
return { toolName, installed: true };
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
assert.deepEqual(calls, ["mcp-client", "magnific-mcp"]);
|
|
144
|
+
assert.equal(result.toolName, "magnific-mcp");
|
|
145
|
+
assert.deepEqual(result.dependencies, [{ name: "mcp-client", version: "0.1.0", status: "installed" }]);
|
|
146
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
inspectToolDependencies,
|
|
5
|
+
normalizeToolDependencies,
|
|
6
|
+
resolveToolDependencyPlan,
|
|
7
|
+
satisfiesToolVersion
|
|
8
|
+
} from "../src/core/tools/tool-dependencies.js";
|
|
9
|
+
|
|
10
|
+
test("normalizes strict tool dependency maps and supports exact and caret versions", () => {
|
|
11
|
+
assert.deepEqual(normalizeToolDependencies({ "mcp-client": "^0.1.0" }), { "mcp-client": "^0.1.0" });
|
|
12
|
+
assert.equal(satisfiesToolVersion("0.1.9", "^0.1.0"), true);
|
|
13
|
+
assert.equal(satisfiesToolVersion("0.2.0", "^0.1.0"), false);
|
|
14
|
+
assert.equal(satisfiesToolVersion("1.4.0", "^1.2.3"), true);
|
|
15
|
+
assert.equal(satisfiesToolVersion("2.0.0", "^1.2.3"), false);
|
|
16
|
+
assert.throws(() => normalizeToolDependencies({ "../bad": "^1.0.0" }), /Invalid tool dependency name/);
|
|
17
|
+
assert.throws(() => normalizeToolDependencies({ valid: ">=1.0.0" }), /Unsupported tool dependency range/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("resolves dependencies before dependents and detects invalid graphs", () => {
|
|
21
|
+
const entries = {
|
|
22
|
+
"mcp-client": { version: "0.1.0", toolDependencies: {} },
|
|
23
|
+
"magnific-mcp": { version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" } }
|
|
24
|
+
};
|
|
25
|
+
assert.deepEqual(resolveToolDependencyPlan(entries, "magnific-mcp"), ["mcp-client", "magnific-mcp"]);
|
|
26
|
+
assert.throws(
|
|
27
|
+
() => resolveToolDependencyPlan({ a: { version: "1.0.0", toolDependencies: { b: "^1.0.0" } } }, "a"),
|
|
28
|
+
/not locked/
|
|
29
|
+
);
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => resolveToolDependencyPlan({
|
|
32
|
+
a: { version: "1.0.0", toolDependencies: { b: "^1.0.0" } },
|
|
33
|
+
b: { version: "1.0.0", toolDependencies: { a: "^1.0.0" } }
|
|
34
|
+
}, "a"),
|
|
35
|
+
/Circular/
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("reports missing and incompatible installed tool dependencies", () => {
|
|
40
|
+
const tools = new Map([
|
|
41
|
+
["magnific-mcp", { name: "magnific-mcp", version: "0.1.0", toolDependencies: { "mcp-client": "^0.1.0" } }]
|
|
42
|
+
]);
|
|
43
|
+
assert.deepEqual(inspectToolDependencies(tools), [{
|
|
44
|
+
tool: "magnific-mcp",
|
|
45
|
+
type: "missing",
|
|
46
|
+
dependency: "mcp-client",
|
|
47
|
+
range: "^0.1.0"
|
|
48
|
+
}]);
|
|
49
|
+
tools.set("mcp-client", { name: "mcp-client", version: "0.2.0", toolDependencies: {} });
|
|
50
|
+
assert.equal(inspectToolDependencies(tools)[0].type, "incompatible");
|
|
51
|
+
tools.get("mcp-client").version = "0.1.4";
|
|
52
|
+
assert.deepEqual(inspectToolDependencies(tools), []);
|
|
53
|
+
});
|
|
@@ -104,6 +104,7 @@ test("loads and lists installed tools from the user tools directory", async () =
|
|
|
104
104
|
version: null,
|
|
105
105
|
packageDigest: null,
|
|
106
106
|
requirements: [],
|
|
107
|
+
toolDependencies: {},
|
|
107
108
|
description: "Fake test tool",
|
|
108
109
|
input: ["text/plain"],
|
|
109
110
|
output: ["text/plain"],
|
|
@@ -144,6 +145,23 @@ test("shows semantic metadata in tool help", async () => {
|
|
|
144
145
|
assert.match(help, /Assigned skills:/);
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
test("reports dependency status in help and blocks a tool with a missing dependency", async () => {
|
|
149
|
+
await resetHome();
|
|
150
|
+
await createFakeTool("dependent-tool", {
|
|
151
|
+
version: "1.0.0",
|
|
152
|
+
toolDependencies: { "base-tool": "^1.0.0" }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const registry = new ToolRegistry();
|
|
156
|
+
await registry.load();
|
|
157
|
+
|
|
158
|
+
assert.match(await registry.help("dependent-tool"), /base-tool@\^1\.0\.0: missing/);
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => registry.run({ name: "dependent-tool", request: { args: {} } }),
|
|
161
|
+
/Tool dependency missing/
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
147
165
|
test("runs a registered tool process with an enriched request and cleans up request files", async () => {
|
|
148
166
|
await resetHome();
|
|
149
167
|
await createFakeTool("fake-tool");
|
|
@@ -178,7 +196,7 @@ test("runs a registered tool process with an enriched request and cleans up requ
|
|
|
178
196
|
});
|
|
179
197
|
assert.equal(result.output.env.ARISA_PACKAGE_DIR, arisaPackageDir);
|
|
180
198
|
assert.equal(result.output.env.ARISA_IPC_SOCKET, arisaIpcSocketFile);
|
|
181
|
-
assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1 }]);
|
|
199
|
+
assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1, official: false }]);
|
|
182
200
|
|
|
183
201
|
const requestFile = result.output.requestFile;
|
|
184
202
|
await assert.rejects(() => access(requestFile), { code: "ENOENT" });
|
package/test/tool-usage.test.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { ToolRegistry } from "../src/core/tools/tool-registry.js";
|
|
6
7
|
import { ToolUsageStore } from "../src/core/tools/tool-usage-store.js";
|
|
7
8
|
import { formatToolUsageReport } from "../src/runtime/tool-usage-report.js";
|
|
8
9
|
|
|
@@ -26,16 +27,37 @@ test("counts concurrent tool uses per chat", async () => {
|
|
|
26
27
|
}
|
|
27
28
|
});
|
|
28
29
|
|
|
30
|
+
test("reports recorded usage for local tools not present in the startup registry", async () => {
|
|
31
|
+
const registry = new ToolRegistry({
|
|
32
|
+
usageStore: {
|
|
33
|
+
counts: async () => ({ "creator-scout": 4 })
|
|
34
|
+
},
|
|
35
|
+
resolveOfficialToolNames: async () => new Set(["gmail-workspace"])
|
|
36
|
+
});
|
|
37
|
+
registry.tools.set("gmail-workspace", {
|
|
38
|
+
name: "gmail-workspace",
|
|
39
|
+
input: ["application/json"],
|
|
40
|
+
output: ["application/json"]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(await registry.usage("chat-1"), [
|
|
44
|
+
{ name: "creator-scout", count: 4, official: false },
|
|
45
|
+
{ name: "gmail-workspace", count: 0, official: true }
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
29
49
|
test("formats narrow tool usage counts with bullets and right-aligned numbers", () => {
|
|
30
50
|
const report = formatToolUsageReport([
|
|
31
|
-
{ name: "gmail-workspace", count: 3 },
|
|
32
|
-
{ name: "campaign-draft-runner", count: 12 }
|
|
51
|
+
{ name: "gmail-workspace", count: 3, official: true },
|
|
52
|
+
{ name: "campaign-draft-runner", count: 12, official: false }
|
|
33
53
|
]);
|
|
54
|
+
assert.match(report, /Official\n- gmail-workspace/);
|
|
55
|
+
assert.match(report, /Local\n- campaign-draft-runner/);
|
|
34
56
|
assert.match(report, /- campaign-draft-runner 12/);
|
|
35
57
|
assert.match(report, /- gmail-workspace\s+3/);
|
|
36
58
|
const rows = report.split("\n").filter((line) => line.startsWith("- "));
|
|
37
|
-
assert.match(rows[0], /
|
|
38
|
-
assert.match(rows[1], /
|
|
59
|
+
assert.match(rows[0], /gmail-workspace/);
|
|
60
|
+
assert.match(rows[1], /campaign-draft-runner/);
|
|
39
61
|
assert.deepEqual(rows.map((line) => line.match(/\d+$/).index + line.match(/\d+$/)[0].length), [27, 27]);
|
|
40
62
|
assert.deepEqual(rows.map((line) => line.length), [27, 27]);
|
|
41
63
|
assert.ok(report.split("\n").every((line) => [...line].length <= 35));
|