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.
@@ -87,16 +87,43 @@ function daemonLabel(record) {
87
87
  return `${record.toolName} (${record.instanceId || "global"})`;
88
88
  }
89
89
 
90
- function daemonResultSummary(results) {
91
- const states = new Map();
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.diagnostic?.state || result.outcome;
94
- states.set(state, (states.get(state) || 0) + 1);
105
+ const state = daemonState(result);
106
+ if (!groups.has(state)) groups.set(state, []);
107
+ groups.get(state).push(result);
95
108
  }
96
- return [...states.entries()]
97
- .sort(([left], [right]) => left.localeCompare(right))
98
- .map(([state, count]) => `${count} ${state}`)
99
- .join(", ");
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("", "Daemons");
225
- lines.push(...reportRow("Checked", report.daemons.length));
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("Role", report.infrastructure.role || "unknown"));
233
- lines.push(...reportRow("Daemon", report.infrastructure.daemon?.state || "unknown"));
234
- lines.push(...reportRow("Endpoint", report.infrastructure.endpoint || "not configured"));
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("Jobs", `active=${report.infrastructure.jobs?.active ?? "unknown"}, queued=${report.infrastructure.jobs?.queued ?? "unknown"}, failed=${report.infrastructure.jobs?.failed ?? "unknown"}`));
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
- export function formatToolUsageReport(tools) {
4
- const lines = ["Arisa tools", "===========", "Usage count"];
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
- const nameWidth = Math.max(0, ...sortedTools.map((tool) => String(tool.name).length));
11
- const countWidth = Math.max(1, ...sortedTools.map((tool) => String(tool.count).length));
12
- for (const tool of sortedTools) {
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
- lines.push(`- ${name} ${count}`);
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 withTyping(ctx, work) {
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
- clearInterval(timer);
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 enqueuePrompt({
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
- await enqueuePrompt({
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
 
@@ -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 master-slave lock matches the catalog source", async () => {
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 source = fileURLToPath(new URL("../../tools/master-slave/", import.meta.url));
66
-
67
- assert.deepEqual(
68
- await verifyOfficialToolTree(source, lock.tools["master-slave"].files),
69
- { files: Object.keys(lock.tools["master-slave"].files).length }
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
- validate: async () => {}
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" });
@@ -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], /campaign-draft-runner/);
38
- assert.match(rows[1], /gmail-workspace/);
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));